mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 17:38:36 +00:00
Compare commits
34 Commits
feat/fully
...
v3.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77916019cd | ||
|
|
02aa669578 | ||
|
|
d8809e09e7 | ||
|
|
04fdd6f8a9 | ||
|
|
3097f3aa76 | ||
|
|
9c3eabdda2 | ||
|
|
7c4da8b8bc | ||
|
|
1e4c3464d2 | ||
|
|
5695f69430 | ||
|
|
d0bbac6b66 | ||
|
|
51e2c045ec | ||
|
|
b585c3e943 | ||
|
|
8150a21395 | ||
|
|
0470e8d1a7 | ||
|
|
031b373bac | ||
|
|
586fd6b7f6 | ||
|
|
51765da0b1 | ||
|
|
5b02aad92a | ||
|
|
606d03180f | ||
|
|
a86551ad27 | ||
|
|
60f8611dd7 | ||
|
|
09be7131cc | ||
|
|
d45a975335 | ||
|
|
6f82eae80f | ||
|
|
ab2326deb3 | ||
|
|
5a4d337ed3 | ||
|
|
cc197e0c30 | ||
|
|
9fa6acca4e | ||
|
|
da892243d2 | ||
|
|
994b60678e | ||
|
|
e001b6ba51 | ||
|
|
59453536a6 | ||
|
|
7fc4ff274d | ||
|
|
663ece708e |
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -1,2 +0,0 @@
|
||||
# Force LF line endings for all text files
|
||||
* text=auto eol=lf
|
||||
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -1 +1 @@
|
||||
* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno @sid-bruno @vijayh-bruno
|
||||
* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
name: 'Run Auth E2E Tests - Linux'
|
||||
description: 'Run Auth E2E tests on Linux'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run Auth E2E tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
xvfb-run npm run test:e2e:auth
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-auth-linux
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
@@ -1,30 +0,0 @@
|
||||
name: 'Run OAuth1 CLI Tests - Linux'
|
||||
description: 'Run OAuth1 CLI tests on Linux'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run BRU format CLI tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
|
||||
|
||||
# navigate to BRU test collection directory
|
||||
cd tests/auth/oauth1/fixtures/collections/bru
|
||||
|
||||
echo "=== BRU Format Collection Run ==="
|
||||
node $BRU_CLI run --env Local --output junit-bru.xml --format junit
|
||||
|
||||
- name: Run YML format CLI tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
|
||||
|
||||
# navigate to YML test collection directory
|
||||
cd tests/auth/oauth1/fixtures/collections/yml
|
||||
|
||||
echo "=== YML Format Collection Run ==="
|
||||
node $BRU_CLI run --env Local --output junit-yml.xml --format junit
|
||||
@@ -1,15 +0,0 @@
|
||||
name: 'Setup Auth Feature Dependencies - Linux'
|
||||
description: 'Setup feature-specific dependencies for auth tests on Linux'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Install additional OS dependencies for auth tests
|
||||
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
|
||||
|
||||
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
|
||||
@@ -1,16 +0,0 @@
|
||||
name: 'Start Test Server - Linux'
|
||||
description: 'Start the bruno-tests mock server for OAuth1 CLI tests on Linux'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Start test server
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd packages/bruno-tests
|
||||
|
||||
echo "starting test server in background"
|
||||
node src/index.js &
|
||||
|
||||
echo "server started with PID: $!"
|
||||
@@ -1,17 +0,0 @@
|
||||
name: 'Run Auth E2E Tests - macOS'
|
||||
description: 'Run Auth E2E tests on macOS'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run Auth E2E tests
|
||||
shell: bash
|
||||
run: |
|
||||
npm run test:e2e:auth
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-auth-macos
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
@@ -1,30 +0,0 @@
|
||||
name: 'Run OAuth1 CLI Tests - macOS'
|
||||
description: 'Run OAuth1 CLI tests on macOS'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run BRU format CLI tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
|
||||
|
||||
# navigate to BRU test collection directory
|
||||
cd tests/auth/oauth1/fixtures/collections/bru
|
||||
|
||||
echo "=== BRU Format Collection Run ==="
|
||||
node $BRU_CLI run --env Local --output junit-bru.xml --format junit
|
||||
|
||||
- name: Run YML format CLI tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
|
||||
|
||||
# navigate to YML test collection directory
|
||||
cd tests/auth/oauth1/fixtures/collections/yml
|
||||
|
||||
echo "=== YML Format Collection Run ==="
|
||||
node $BRU_CLI run --env Local --output junit-yml.xml --format junit
|
||||
@@ -1,16 +0,0 @@
|
||||
name: 'Start Test Server - macOS'
|
||||
description: 'Start the bruno-tests mock server for OAuth1 CLI tests on macOS'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Start test server
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd packages/bruno-tests
|
||||
|
||||
echo "starting test server in background"
|
||||
node src/index.js &
|
||||
|
||||
echo "server started with PID: $!"
|
||||
@@ -1,17 +0,0 @@
|
||||
name: 'Run Auth E2E Tests - Windows'
|
||||
description: 'Run Auth E2E tests on Windows'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run Auth E2E tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
npm run test:e2e:auth
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-auth-windows
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
@@ -1,34 +0,0 @@
|
||||
name: 'Run OAuth1 CLI Tests - Windows'
|
||||
description: 'Run OAuth1 CLI tests on Windows'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run BRU format CLI tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$BRU_CLI = "..\..\..\..\..\..\packages\bruno-cli\bin\bru.js"
|
||||
|
||||
# navigate to BRU test collection directory
|
||||
Set-Location tests\auth\oauth1\fixtures\collections\bru
|
||||
|
||||
Write-Host "=== BRU Format Collection Run ==="
|
||||
$process = Start-Process -FilePath "node" -ArgumentList "$BRU_CLI run --env Local --output junit-bru.xml --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
|
||||
if ($process.ExitCode -ne 0) { exit 1 }
|
||||
|
||||
- name: Run YML format CLI tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$BRU_CLI = "..\..\..\..\..\..\packages\bruno-cli\bin\bru.js"
|
||||
|
||||
# navigate to YML test collection directory
|
||||
Set-Location tests\auth\oauth1\fixtures\collections\yml
|
||||
|
||||
Write-Host "=== YML Format Collection Run ==="
|
||||
$process = Start-Process -FilePath "node" -ArgumentList "$BRU_CLI run --env Local --output junit-yml.xml --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
|
||||
if ($process.ExitCode -ne 0) { exit 1 }
|
||||
@@ -1,14 +0,0 @@
|
||||
name: 'Start Test Server - Windows'
|
||||
description: 'Start the bruno-tests mock server for OAuth1 CLI tests on Windows'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Start test server
|
||||
shell: pwsh
|
||||
run: |
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
Set-Location packages\bruno-tests
|
||||
|
||||
Write-Host "starting test server in background"
|
||||
Start-Process -FilePath "node" -ArgumentList "src\index.js" -PassThru -WindowStyle Hidden
|
||||
@@ -5,10 +5,6 @@ inputs:
|
||||
description: 'Skip building libraries'
|
||||
required: false
|
||||
default: 'false'
|
||||
shell:
|
||||
description: 'Shell to use (bash, pwsh)'
|
||||
required: false
|
||||
default: 'bash'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
@@ -20,12 +16,12 @@ runs:
|
||||
cache-dependency-path: './package-lock.json'
|
||||
|
||||
- name: Install node dependencies
|
||||
shell: ${{ inputs.shell }}
|
||||
shell: bash
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Build libraries
|
||||
if: inputs.skip-build != 'true'
|
||||
shell: ${{ inputs.shell }}
|
||||
shell: bash
|
||||
run: |
|
||||
npm run build:graphql-docs
|
||||
npm run build:bruno-query
|
||||
|
||||
@@ -7,13 +7,13 @@ runs:
|
||||
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-ssl
|
||||
name: playwright-report-linux
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
@@ -12,6 +12,6 @@ runs:
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-macos-ssl
|
||||
name: playwright-report-macos
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
@@ -12,6 +12,6 @@ runs:
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-windows-ssl
|
||||
name: playwright-report-windows
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
name: 'Run Benchmark Tests'
|
||||
description: 'Run Playwright benchmark tests and compare against baseline'
|
||||
inputs:
|
||||
os:
|
||||
description: 'Operating system (ubuntu, macos, windows)'
|
||||
default: 'ubuntu'
|
||||
update-baseline:
|
||||
description: 'Update baseline instead of comparing'
|
||||
default: 'false'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run Benchmark Tests (Ubuntu)
|
||||
if: inputs.os == 'ubuntu'
|
||||
shell: bash
|
||||
run: xvfb-run npm run test:benchmark
|
||||
|
||||
- name: Run Benchmark Tests
|
||||
if: inputs.os != 'ubuntu'
|
||||
shell: bash
|
||||
run: npm run test:benchmark
|
||||
|
||||
- name: Update Baseline
|
||||
if: inputs.update-baseline == 'true'
|
||||
shell: bash
|
||||
run: >-
|
||||
node tests/benchmarks/utils/compare.js
|
||||
--results tests/benchmarks/results/mounting.json
|
||||
--baseline tests/benchmarks/mounting/baseline.${{ inputs.os }}.json
|
||||
--update-baseline
|
||||
|
||||
- name: Compare Against Baseline
|
||||
if: inputs.update-baseline != 'true'
|
||||
shell: bash
|
||||
run: >-
|
||||
node tests/benchmarks/utils/compare.js
|
||||
--results tests/benchmarks/results/mounting.json
|
||||
--baseline tests/benchmarks/mounting/baseline.${{ inputs.os }}.json
|
||||
37
.github/actions/tests/run-cli-tests/action.yml
vendored
37
.github/actions/tests/run-cli-tests/action.yml
vendored
@@ -1,41 +1,20 @@
|
||||
name: 'Run CLI Tests'
|
||||
description: 'Setup dependencies, start local testbench and run CLI tests'
|
||||
inputs:
|
||||
shell:
|
||||
description: 'Shell to use (bash, pwsh)'
|
||||
required: false
|
||||
default: 'bash'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Install Test Collection Dependencies
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm ci --prefix packages/bruno-tests/collection
|
||||
|
||||
- name: Run Local Testbench and CLI Tests
|
||||
if: inputs.shell != 'pwsh'
|
||||
shell: ${{ inputs.shell }}
|
||||
- name: Run Local Testbench
|
||||
shell: bash
|
||||
run: |
|
||||
npm start --workspace=packages/bruno-tests &
|
||||
sleep 5
|
||||
cd packages/bruno-tests/collection
|
||||
node ../../bruno-cli/bin/bru.js run --env Prod --output junit.xml --format junit --sandbox developer
|
||||
|
||||
- name: Run Local Testbench and CLI Tests - Windows
|
||||
if: inputs.shell == 'pwsh'
|
||||
shell: pwsh
|
||||
- name: Install Test Collection Dependencies
|
||||
shell: bash
|
||||
run: npm ci --prefix packages/bruno-tests/collection
|
||||
|
||||
- name: Run CLI Tests
|
||||
shell: bash
|
||||
run: |
|
||||
$process = Start-Process "npm.cmd" `
|
||||
-ArgumentList "start","--workspace=packages/bruno-tests" `
|
||||
-NoNewWindow `
|
||||
-PassThru
|
||||
|
||||
Start-Sleep -Seconds 5
|
||||
|
||||
if ($process.HasExited) {
|
||||
Write-Error "Server exited early"
|
||||
exit 1
|
||||
}
|
||||
|
||||
cd packages/bruno-tests/collection
|
||||
node ../../bruno-cli/bin/bru.js run --env Prod --output junit.xml --format junit --sandbox developer
|
||||
|
||||
10
.github/actions/tests/run-e2e-tests/action.yml
vendored
10
.github/actions/tests/run-e2e-tests/action.yml
vendored
@@ -4,23 +4,19 @@ inputs:
|
||||
os:
|
||||
description: 'Operating system (ubuntu, macos, windows)'
|
||||
default: 'ubuntu'
|
||||
shell:
|
||||
description: 'Shell to use (bash, pwsh)'
|
||||
required: false
|
||||
default: 'bash'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Install Test Collection Dependencies
|
||||
shell: ${{ inputs.shell }}
|
||||
shell: bash
|
||||
run: npm ci --prefix packages/bruno-tests/collection
|
||||
|
||||
- name: Run Playwright Tests (Ubuntu)
|
||||
if: inputs.os == 'ubuntu'
|
||||
shell: bash
|
||||
run: xvfb-run dbus-run-session -- npm run test:e2e
|
||||
run: xvfb-run npm run test:e2e
|
||||
|
||||
- name: Run Playwright Tests
|
||||
if: inputs.os != 'ubuntu'
|
||||
shell: ${{ inputs.shell }}
|
||||
shell: bash
|
||||
run: npm run test:e2e
|
||||
|
||||
35
.github/actions/tests/run-unit-tests/action.yml
vendored
35
.github/actions/tests/run-unit-tests/action.yml
vendored
@@ -1,53 +1,48 @@
|
||||
name: 'Run Unit Tests'
|
||||
description: 'Setup dependencies and run unit tests for all packages'
|
||||
inputs:
|
||||
shell:
|
||||
description: 'Shell to use (bash, pwsh)'
|
||||
required: false
|
||||
default: 'bash'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Test Package bruno-js
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test:ci --workspace=packages/bruno-js
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-js
|
||||
|
||||
- name: Test Package bruno-cli
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test:ci --workspace=packages/bruno-cli
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-cli
|
||||
|
||||
- name: Test Package bruno-query
|
||||
shell: ${{ inputs.shell }}
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-query
|
||||
|
||||
- name: Test Package bruno-lang
|
||||
shell: ${{ inputs.shell }}
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-lang
|
||||
|
||||
- name: Test Package bruno-schema
|
||||
shell: ${{ inputs.shell }}
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-schema
|
||||
|
||||
- name: Test Package bruno-app
|
||||
shell: ${{ inputs.shell }}
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-app
|
||||
|
||||
- name: Test Package bruno-common
|
||||
shell: ${{ inputs.shell }}
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-common
|
||||
|
||||
- name: Test Package bruno-converters
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test:ci --workspace=packages/bruno-converters
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-converters
|
||||
|
||||
- name: Test Package bruno-electron
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test:ci --workspace=packages/bruno-electron
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-electron
|
||||
|
||||
- name: Test Package bruno-requests
|
||||
shell: ${{ inputs.shell }}
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-requests
|
||||
|
||||
- name: Test Package bruno-filestore
|
||||
shell: ${{ inputs.shell }}
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-filestore
|
||||
|
||||
88
.github/workflows/benchmarks.yml
vendored
88
.github/workflows/benchmarks.yml
vendored
@@ -1,88 +0,0 @@
|
||||
name: Benchmarks
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
update-baseline:
|
||||
description: 'Update baseline with current results instead of comparing'
|
||||
type: boolean
|
||||
default: false
|
||||
pull_request:
|
||||
branches: [main, 'release/v*']
|
||||
|
||||
jobs:
|
||||
benchmark:
|
||||
name: Performance Benchmarks (${{ matrix.os }})
|
||||
timeout-minutes: 60
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-24.04, macos-latest, windows-latest]
|
||||
include:
|
||||
- os: ubuntu-24.04
|
||||
os-name: ubuntu
|
||||
- os: macos-latest
|
||||
os-name: macos
|
||||
- os: windows-latest
|
||||
os-name: windows
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install System Dependencies (Ubuntu)
|
||||
if: matrix.os-name == 'ubuntu'
|
||||
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
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Configure Chrome Sandbox
|
||||
if: matrix.os-name == 'ubuntu'
|
||||
run: |
|
||||
sudo chown root node_modules/electron/dist/chrome-sandbox
|
||||
sudo chmod 4755 node_modules/electron/dist/chrome-sandbox
|
||||
|
||||
- name: Run Benchmark Tests
|
||||
uses: ./.github/actions/tests/run-benchmark-tests
|
||||
with:
|
||||
os: ${{ matrix.os-name }}
|
||||
update-baseline: ${{ github.event.inputs.update-baseline || 'false' }}
|
||||
|
||||
- name: Upload Benchmark Results
|
||||
uses: actions/upload-artifact@v6
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: benchmark-results-${{ matrix.os-name }}
|
||||
path: |
|
||||
tests/benchmarks/results/
|
||||
benchmark-report/
|
||||
retention-days: 30
|
||||
|
||||
- name: Commit Updated Baseline
|
||||
if: github.event.inputs.update-baseline == 'true'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add tests/benchmarks/mounting/baseline.${{ matrix.os-name }}.json
|
||||
git diff --staged --quiet || git commit -m "chore: update ${{ matrix.os-name }} benchmark baseline" && git push
|
||||
|
||||
- name: Comment Benchmark Results on PR
|
||||
if: github.event_name == 'pull_request' && !cancelled()
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const run = require('./tests/benchmarks/utils/pr-comment.js');
|
||||
await run({
|
||||
github,
|
||||
context,
|
||||
resultsPath: 'tests/benchmarks/results/mounting.json',
|
||||
baselinePath: 'tests/benchmarks/mounting/baseline.${{ matrix.os-name }}.json',
|
||||
title: 'Benchmark Results — Collection Mount (${{ matrix.os-name }})'
|
||||
});
|
||||
2
.github/workflows/npm-bru-cli.yml
vendored
2
.github/workflows/npm-bru-cli.yml
vendored
@@ -43,7 +43,7 @@ jobs:
|
||||
bru run --env Prod --output junit.xml --format junit --sandbox developer
|
||||
|
||||
- name: Publish Test Report
|
||||
uses: dorny/test-reporter@v3
|
||||
uses: dorny/test-reporter@v2
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: Test Report
|
||||
|
||||
91
.github/workflows/ssl-tests.yml
vendored
Normal file
91
.github/workflows/ssl-tests.yml
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
name: SSL Tests
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
tests-for-linux:
|
||||
name: SSL Tests - Linux
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- 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@v6
|
||||
|
||||
- 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@v6
|
||||
|
||||
- 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
|
||||
123
.github/workflows/tests-macos.yml
vendored
123
.github/workflows/tests-macos.yml
vendored
@@ -1,123 +0,0 @@
|
||||
name: macOS Tests
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main, 'release/v*']
|
||||
pull_request:
|
||||
branches: [main, 'release/v*']
|
||||
|
||||
jobs:
|
||||
unit-test:
|
||||
name: Unit Tests (macOS)
|
||||
timeout-minutes: 60
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Run Unit Tests
|
||||
uses: ./.github/actions/tests/run-unit-tests
|
||||
|
||||
cli-test:
|
||||
name: CLI Tests (macOS)
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Run CLI Tests
|
||||
uses: ./.github/actions/tests/run-cli-tests
|
||||
|
||||
- name: Publish Test Report
|
||||
uses: EnricoMi/publish-unit-test-result-action/macos@v2
|
||||
if: always()
|
||||
with:
|
||||
check_name: CLI Test Results (macOS)
|
||||
files: packages/bruno-tests/collection/junit.xml
|
||||
comment_mode: off
|
||||
check_run: false
|
||||
|
||||
e2e-test:
|
||||
name: Playwright E2E Tests (macOS)
|
||||
timeout-minutes: 180
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Run E2E Tests
|
||||
uses: ./.github/actions/tests/run-e2e-tests
|
||||
with:
|
||||
os: macos
|
||||
|
||||
- name: Upload Playwright Report
|
||||
uses: actions/upload-artifact@v6
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report-macos
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
ssl-test:
|
||||
name: SSL Tests (macOS)
|
||||
timeout-minutes: 60
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- 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
|
||||
|
||||
oauth1-tests:
|
||||
name: OAuth 1.0 Auth Tests (macOS)
|
||||
timeout-minutes: 60
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Run Auth E2E Tests
|
||||
uses: ./.github/actions/auth/oauth1/macos/run-auth-e2e-tests
|
||||
|
||||
- name: Start Test Server
|
||||
uses: ./.github/actions/auth/oauth1/macos/start-test-server
|
||||
|
||||
- name: Run OAuth1 CLI Tests
|
||||
uses: ./.github/actions/auth/oauth1/macos/run-oauth1-cli-tests
|
||||
134
.github/workflows/tests-windows.yml
vendored
134
.github/workflows/tests-windows.yml
vendored
@@ -1,134 +0,0 @@
|
||||
name: Windows Tests
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main, 'release/v*']
|
||||
pull_request:
|
||||
branches: [main, 'release/v*']
|
||||
|
||||
jobs:
|
||||
unit-test:
|
||||
name: Unit Tests (Windows)
|
||||
if: false # @TODO: Temporarily disabled. Remove this once the tests are fixed.
|
||||
timeout-minutes: 60
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
with:
|
||||
shell: pwsh
|
||||
|
||||
- name: Run Unit Tests
|
||||
uses: ./.github/actions/tests/run-unit-tests
|
||||
with:
|
||||
shell: pwsh
|
||||
|
||||
cli-test:
|
||||
name: CLI Tests (Windows)
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
with:
|
||||
shell: pwsh
|
||||
|
||||
- name: Run CLI Tests
|
||||
uses: ./.github/actions/tests/run-cli-tests
|
||||
with:
|
||||
shell: pwsh
|
||||
|
||||
- name: Publish Test Report
|
||||
uses: EnricoMi/publish-unit-test-result-action/windows@v2
|
||||
if: always()
|
||||
with:
|
||||
check_name: CLI Test Results (Windows)
|
||||
files: packages/bruno-tests/collection/junit.xml
|
||||
comment_mode: off
|
||||
check_run: false
|
||||
|
||||
e2e-test:
|
||||
name: Playwright E2E Tests (Windows)
|
||||
timeout-minutes: 180
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
with:
|
||||
shell: pwsh
|
||||
|
||||
- name: Run E2E Tests
|
||||
uses: ./.github/actions/tests/run-e2e-tests
|
||||
with:
|
||||
os: windows
|
||||
shell: pwsh
|
||||
|
||||
- name: Upload Playwright Report
|
||||
uses: actions/upload-artifact@v6
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report-windows
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
ssl-test:
|
||||
name: SSL Tests (Windows)
|
||||
timeout-minutes: 60
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
with:
|
||||
shell: pwsh
|
||||
|
||||
- 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
|
||||
|
||||
oauth1-tests:
|
||||
name: OAuth 1.0 Auth Tests (Windows)
|
||||
timeout-minutes: 60
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Run Auth E2E Tests
|
||||
uses: ./.github/actions/auth/oauth1/windows/run-auth-e2e-tests
|
||||
|
||||
- name: Start Test Server
|
||||
uses: ./.github/actions/auth/oauth1/windows/start-test-server
|
||||
|
||||
- name: Run OAuth1 CLI Tests
|
||||
uses: ./.github/actions/auth/oauth1/windows/run-oauth1-cli-tests
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Linux Tests
|
||||
name: Tests
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
unit-test:
|
||||
name: Unit Tests (Linux)
|
||||
name: Unit Tests
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
uses: ./.github/actions/tests/run-unit-tests
|
||||
|
||||
cli-test:
|
||||
name: CLI Tests (Linux)
|
||||
name: CLI Tests
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
checks: write
|
||||
@@ -42,14 +42,13 @@ jobs:
|
||||
uses: EnricoMi/publish-unit-test-result-action@v2
|
||||
if: always()
|
||||
with:
|
||||
check_name: CLI Test Results (Linux)
|
||||
check_name: CLI Test Results
|
||||
files: packages/bruno-tests/collection/junit.xml
|
||||
comment_mode: always
|
||||
check_run: false
|
||||
|
||||
e2e-test:
|
||||
name: Playwright E2E Tests (Linux)
|
||||
timeout-minutes: 180
|
||||
name: Playwright E2E Tests
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -59,8 +58,7 @@ jobs:
|
||||
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 \
|
||||
gsettings-desktop-schemas dbus-x11
|
||||
xvfb
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
@@ -79,61 +77,6 @@ jobs:
|
||||
uses: actions/upload-artifact@v6
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report-linux
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
ssl-test:
|
||||
name: SSL Tests (Linux)
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- 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
|
||||
|
||||
oauth1-tests:
|
||||
name: OAuth 1.0 Auth Tests (Linux)
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Setup Feature Dependencies
|
||||
uses: ./.github/actions/auth/oauth1/linux/setup-feature-specific-deps
|
||||
|
||||
- name: Run Auth E2E Tests
|
||||
uses: ./.github/actions/auth/oauth1/linux/run-auth-e2e-tests
|
||||
|
||||
- name: Start Test Server
|
||||
uses: ./.github/actions/auth/oauth1/linux/start-test-server
|
||||
|
||||
- name: Run OAuth1 CLI Tests
|
||||
uses: ./.github/actions/auth/oauth1/linux/run-oauth1-cli-tests
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -58,10 +58,6 @@ skills-lock.json
|
||||
# Playwright
|
||||
/blob-report/
|
||||
|
||||
# Benchmark results (generated at runtime)
|
||||
tests/benchmarks/results/
|
||||
/benchmark-report/
|
||||
|
||||
# Development plan files
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
@@ -71,4 +67,4 @@ AGENTS.md
|
||||
packages/bruno-filestore/dist
|
||||
packages/bruno-requests/dist
|
||||
packages/bruno-schema-types/dist
|
||||
packages/bruno-converters/dist
|
||||
packages/bruno-converters/dist
|
||||
|
||||
@@ -59,47 +59,6 @@ Remember, these rules are here to make our codebase harmonious. If something doe
|
||||
|
||||
- Tests should be fast enough to run continuously. Avoid long-running operations unless absolutely necessary; prefer lightweight fixtures and isolated units.
|
||||
|
||||
### E2E Tests
|
||||
|
||||
When reviewing Electron-specific Playwright tests, treat `<project-root>/tests/**` as the canonical location for specs, typically matching `<project-root>/tests/**/*.spec.{ts,js}`. For broader Playwright workflow guidance, also refer to `docs/playwright-testing-guide.md`.
|
||||
|
||||
Goal: rewrite or critique the tests so they are genuinely behavioural, maintainable, and safely parallelizable.
|
||||
|
||||
Rules:
|
||||
1. Tests must verify user-visible behaviour, not implementation details.
|
||||
- Prefer assertions on UI state, persisted data, windows, dialogs, filesystem effects, and app-level outcomes.
|
||||
- Avoid hardcoded waits, brittle selectors, fake internal state checks, and “click then expect mock called” tests unless the user behaviour is the point.
|
||||
|
||||
2. Tests must be Electron-aware.
|
||||
- Use Electron app launch patterns correctly.
|
||||
- Handle main window, secondary windows, dialogs, menus, native prompts, clipboard, file pickers, and IPC-driven UI behaviour through observable outcomes.
|
||||
- Do not reach into app internals unless absolutely necessary for setup or controlled test fixtures.
|
||||
|
||||
3. Tests must be parallel-safe.
|
||||
- No shared user data directories.
|
||||
- No shared ports, files, DBs, caches, clipboard assumptions, or global app state.
|
||||
- Each test gets isolated temp paths, unique workspace/project names, and deterministic cleanup.
|
||||
- Avoid test ordering assumptions.
|
||||
|
||||
4. No hardcoded mess.
|
||||
- Replace magic timeouts with event-driven waits.
|
||||
- Replace brittle text/index selectors with role, label, test id, or stable user-facing selectors.
|
||||
- Replace duplicated setup with fixtures.
|
||||
- Replace hardcoded absolute paths with temp dirs.
|
||||
- Replace random sleeps with waiting for actual app signals.
|
||||
|
||||
5. Every test should follow this shape:
|
||||
- Arrange: create isolated fixture state.
|
||||
- Act: perform real user actions.
|
||||
- Assert: verify observable behavioural outcome.
|
||||
- Cleanup: remove isolated resources.
|
||||
|
||||
For each test file:
|
||||
- Identify behavioural vs non-behavioural tests.
|
||||
- Flag brittle selectors, hardcoded waits, shared state, serial dependencies, and fake assertions.
|
||||
- Rewrite the tests using Playwright best practices for Electron.
|
||||
- Make them parallel-ready.
|
||||
- Explain briefly why each rewrite is better.
|
||||
|
||||
## UI Specific instructions
|
||||
|
||||
@@ -116,8 +75,6 @@ For each test file:
|
||||
- Avoid: `import * as React from "react";` then `React.useCallback(...)`
|
||||
- Add `data-testid` to testable elements for Playwright
|
||||
- Co-locate utilities that are truly component-specific next to the component, otherwise place shared items under a common folder
|
||||
- Avoid mixed controlled and uncontrolled state in React components. A component is either controlled or uncontrolled. State needs a single source of truth instead of being computed by props and then recomputed internally.
|
||||
- SHOULD: Use derived state variables instead of adding unneeded `React.useState` / `useState` hooks.
|
||||
|
||||
|
||||
## Readability and Abstractions
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 153 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 615 KiB After Width: | Height: | Size: 153 KiB |
@@ -324,7 +324,7 @@ test('should create and execute HTTP request', async ({ page, createTmpDir }) =>
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// Execute request
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
await page.locator('#send-request').getByRole('img').nth(2).click();
|
||||
|
||||
// Verify response
|
||||
await expect(page.getByRole('main')).toContainText('200 OK');
|
||||
|
||||
1736
package-lock.json
generated
1736
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -23,7 +23,7 @@
|
||||
"@eslint/compat": "^1.3.2",
|
||||
"@faker-js/faker": "^7.6.0",
|
||||
"@jest/globals": "^29.2.0",
|
||||
"@opencollection/types": "0.9.1",
|
||||
"@opencollection/types": "~0.8.0",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
|
||||
@@ -32,19 +32,18 @@
|
||||
"@storybook/react-webpack5": "^10.1.10",
|
||||
"@stylistic/eslint-plugin": "^5.3.1",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^22.14.1",
|
||||
"@typescript-eslint/parser": "^8.39.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"cross-env": "10.1.0",
|
||||
"eslint": "^9.39.4",
|
||||
"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.23",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nano-staged": "^0.8.0",
|
||||
"playwright": "^1.51.1",
|
||||
"pretty-quick": "^3.1.3",
|
||||
@@ -81,10 +80,8 @@
|
||||
"watch:common": "npm run watch --workspace=packages/bruno-common",
|
||||
"watch:requests": "npm run watch --workspace=packages/bruno-requests",
|
||||
"test:codegen": "node playwright/codegen.ts",
|
||||
"test:e2e": "playwright test --project=default --project=system-pac",
|
||||
"test:e2e": "playwright test --project=default",
|
||||
"test:e2e:ssl": "playwright test --project=ssl",
|
||||
"test:e2e:auth": "playwright test --project=auth",
|
||||
"test:benchmark": "playwright test --config=playwright.benchmark.config.ts",
|
||||
"lint": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" npx eslint",
|
||||
"lint:fix": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" npx eslint --fix",
|
||||
"prepare": "husky"
|
||||
@@ -95,9 +92,7 @@
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"axios": "1.16.0",
|
||||
"rollup": "3.30.0",
|
||||
"pbkdf2": "3.1.5",
|
||||
"rollup": "3.29.5",
|
||||
"electron-store": {
|
||||
"conf": {
|
||||
"json-schema-typed": "8.0.1"
|
||||
@@ -108,4 +103,4 @@
|
||||
"ajv": "^8.17.1",
|
||||
"git-url-parse": "^14.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"],
|
||||
"presets": ["@babel/preset-env", "@babel/preset-react"],
|
||||
"plugins": [["styled-components", { "ssr": true }]]
|
||||
}
|
||||
@@ -1,19 +1,3 @@
|
||||
global.ResizeObserver = class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn()
|
||||
}))
|
||||
});
|
||||
|
||||
jest.mock('nanoid', () => {
|
||||
return {
|
||||
nanoid: () => {}
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"sass": "^1.46.0",
|
||||
"semver": "^7.7.1",
|
||||
"shell-quote": "^1.8.4",
|
||||
"shell-quote": "^1.8.3",
|
||||
"strip-json-comments": "^5.0.1",
|
||||
"styled-components": "^5.3.3",
|
||||
"swagger-ui-react": "^5.31.0",
|
||||
@@ -100,7 +100,6 @@
|
||||
"@babel/core": "^7.27.1",
|
||||
"@babel/preset-env": "^7.27.2",
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@babel/preset-typescript": "^7.22.0",
|
||||
"@rsbuild/core": "^1.1.2",
|
||||
"@rsbuild/plugin-babel": "^1.0.3",
|
||||
"@rsbuild/plugin-node-polyfill": "1.2.0",
|
||||
|
||||
@@ -38,9 +38,6 @@ export default defineConfig({
|
||||
dynamicImportMode: "eager",
|
||||
},
|
||||
},
|
||||
rules: [
|
||||
{ test: /\.md$/, type: 'asset/source' }
|
||||
]
|
||||
},
|
||||
ignoreWarnings: [
|
||||
(warning) => warning.message.includes('Critical dependency: the request of a dependency is an expression') && warning?.moduleDescriptor?.name?.includes('flow-parser')
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 10;
|
||||
|
||||
.ai-assist-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover,
|
||||
&.open {
|
||||
opacity: 1;
|
||||
color: ${(props) => props.theme.colors.accent};
|
||||
background: ${(props) => props.theme.colors.accent}10;
|
||||
border-color: ${(props) => props.theme.input.border};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${(props) => props.theme.colors.accent}55;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-assist-popup {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
width: 360px;
|
||||
background: ${(props) => props.theme.bg};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.md};
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.input.border};
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.text};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
svg {
|
||||
color: ${(props) => props.theme.colors.accent};
|
||||
}
|
||||
}
|
||||
|
||||
.popup-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.popup-body {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.popup-input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.popup-suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.suggestion-chip {
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: 999px;
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: ${(props) => props.theme.text};
|
||||
border-color: ${(props) => props.theme.colors.accent}80;
|
||||
background: ${(props) => props.theme.colors.accent}10;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.popup-error {
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
background: ${(props) => props.theme.colors.bg.danger}15;
|
||||
}
|
||||
|
||||
.popup-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid ${(props) => props.theme.input.border};
|
||||
}
|
||||
|
||||
.popup-hint {
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.popup-loading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid ${(props) => props.theme.input.border};
|
||||
border-top-color: ${(props) => props.theme.colors.accent};
|
||||
border-radius: 50%;
|
||||
animation: ai-assist-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ai-assist-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.btn-generate {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid ${(props) => props.theme.colors.accent};
|
||||
background: ${(props) => props.theme.colors.accent};
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.preview-code {
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: 8px 10px;
|
||||
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.preview-modes {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.preview-mode-btn {
|
||||
padding: 2px 6px;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
|
||||
&.active {
|
||||
color: ${(props) => props.theme.text};
|
||||
border-color: ${(props) => props.theme.input.border};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
}
|
||||
|
||||
&:hover:not(.active) {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -1,232 +0,0 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import get from 'lodash/get';
|
||||
import { IconStars, IconX, IconArrowBackUp } from '@tabler/icons';
|
||||
import { aiGenerateScript } from 'utils/ai';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const SUGGESTIONS = {
|
||||
'tests': [
|
||||
{ label: 'Status 200', prompt: 'Add a test asserting the response status code is 200' },
|
||||
{ label: 'JSON body', prompt: 'Add tests validating the JSON response body structure and key fields' },
|
||||
{ label: 'Headers', prompt: 'Add a test checking the content-type response header' },
|
||||
{ label: 'Response time', prompt: 'Add a test asserting the response time is below 1000ms' }
|
||||
],
|
||||
'pre-request': [
|
||||
{ label: 'Auth header', prompt: 'Set an Authorization header from an environment token variable' },
|
||||
{ label: 'Timestamp', prompt: 'Set a variable named "timestamp" containing the current epoch ms' },
|
||||
{ label: 'Random ID', prompt: 'Set a variable named "requestId" containing a random UUID-style id' }
|
||||
],
|
||||
'post-response': [
|
||||
{ label: 'Save token', prompt: 'Extract a token from the response body and save it to an environment variable' },
|
||||
{ label: 'Save id', prompt: 'Extract the primary id from the response body and save it to a variable' },
|
||||
{ label: 'Log response', prompt: 'Log the response status and a short summary of the body' }
|
||||
]
|
||||
};
|
||||
|
||||
const TITLES = {
|
||||
'tests': 'Generate Tests',
|
||||
'pre-request': 'Generate Pre-Request Script',
|
||||
'post-response': 'Generate Post-Response Script'
|
||||
};
|
||||
|
||||
const isValidType = (t) => SUGGESTIONS[t] !== undefined;
|
||||
|
||||
const AIAssist = ({ scriptType, currentScript, requestContext, onApply }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [generated, setGenerated] = useState(null);
|
||||
const buttonRef = useRef(null);
|
||||
|
||||
const focusOnMount = useCallback((el) => {
|
||||
el?.focus();
|
||||
}, []);
|
||||
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const isAiEnabled = get(preferences, 'ai.enabled', false);
|
||||
|
||||
const suggestions = useMemo(() => SUGGESTIONS[scriptType] || [], [scriptType]);
|
||||
const title = TITLES[scriptType] || 'Generate with AI';
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const attachPopup = useCallback((el) => {
|
||||
if (!el) return undefined;
|
||||
const onDocMouseDown = (e) => {
|
||||
if (!el.contains(e.target) && !buttonRef.current?.contains(e.target)) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') close();
|
||||
};
|
||||
document.addEventListener('mousedown', onDocMouseDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDocMouseDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [close]);
|
||||
|
||||
const handleGenerate = useCallback(
|
||||
async (overridePrompt) => {
|
||||
const text = (overridePrompt ?? prompt).trim();
|
||||
if (!text || isLoading) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await aiGenerateScript({
|
||||
scriptType,
|
||||
prompt: text,
|
||||
currentScript: currentScript || '',
|
||||
requestContext
|
||||
});
|
||||
if (result?.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
if (result?.content) {
|
||||
setGenerated(result.content);
|
||||
} else {
|
||||
setError('No content was generated. Try rephrasing your prompt.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Failed to generate script');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[prompt, isLoading, scriptType, currentScript, requestContext]
|
||||
);
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
if (generated == null) return;
|
||||
onApply(generated);
|
||||
setGenerated(null);
|
||||
setPrompt('');
|
||||
close();
|
||||
}, [generated, onApply, close]);
|
||||
|
||||
const handleBackToPrompt = useCallback(() => {
|
||||
setGenerated(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
if (!isAiEnabled || !isValidType(scriptType)) return null;
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className={`ai-assist-trigger ${isOpen ? 'open' : ''}`}
|
||||
onClick={() => setIsOpen((v) => !v)}
|
||||
title={title}
|
||||
type="button"
|
||||
aria-label={title}
|
||||
>
|
||||
<IconStars size={14} strokeWidth={1.75} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div ref={attachPopup} className="ai-assist-popup" role="dialog" aria-label={title}>
|
||||
<div className="popup-header">
|
||||
<span className="popup-title">
|
||||
<IconStars size={12} strokeWidth={1.75} />
|
||||
{title}
|
||||
</span>
|
||||
<button className="popup-close" onClick={close} type="button" aria-label="Close">
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{generated == null ? (
|
||||
<>
|
||||
<div className="popup-body">
|
||||
<textarea
|
||||
ref={focusOnMount}
|
||||
className="popup-input"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleGenerate();
|
||||
}
|
||||
}}
|
||||
placeholder="Describe what you want to generate..."
|
||||
rows={3}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
{!isLoading && !prompt && suggestions.length > 0 && (
|
||||
<div className="popup-suggestions">
|
||||
{suggestions.map((s) => (
|
||||
<button
|
||||
key={s.label}
|
||||
className="suggestion-chip"
|
||||
type="button"
|
||||
onClick={() => handleGenerate(s.prompt)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="popup-error">{error}</div>}
|
||||
</div>
|
||||
|
||||
<div className="popup-footer">
|
||||
{isLoading ? (
|
||||
<span className="popup-loading">
|
||||
<span className="loading-spinner" />
|
||||
Generating...
|
||||
</span>
|
||||
) : (
|
||||
<span className="popup-hint">⌘ + Enter to generate</span>
|
||||
)}
|
||||
<button
|
||||
className="btn-generate"
|
||||
type="button"
|
||||
onClick={() => handleGenerate()}
|
||||
disabled={!prompt.trim() || isLoading}
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="popup-body">
|
||||
<div className="preview-section">
|
||||
<span className="preview-label">Preview · replaces current script</span>
|
||||
<pre className="preview-code">{generated}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="popup-footer">
|
||||
<button className="btn-secondary" type="button" onClick={handleBackToPrompt}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<IconArrowBackUp size={12} /> Back
|
||||
</span>
|
||||
</button>
|
||||
<button className="btn-generate" type="button" onClick={handleApply}>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIAssist;
|
||||
@@ -61,17 +61,6 @@ const StyledWrapper = styled.div`
|
||||
.cm-variable-invalid {
|
||||
color: ${(props) => props.theme.codemirror.variable.invalid};
|
||||
}
|
||||
|
||||
.CodeMirror-matchingbracket {
|
||||
background: ${(props) => props.theme.status.success.background} !important;
|
||||
text-decoration: unset;
|
||||
}
|
||||
|
||||
.CodeMirror-nonmatchingbracket {
|
||||
color: ${(props) => props.theme.colors.text.danger} !important;
|
||||
background: ${(props) => props.theme.status.danger.background} !important;
|
||||
text-decoration: unset;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -57,6 +57,16 @@ export default class CodeEditor extends React.Component {
|
||||
scrollbarStyle: 'overlay',
|
||||
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
|
||||
extraKeys: {
|
||||
'Cmd-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Ctrl-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Cmd-F': 'findPersistent',
|
||||
'Ctrl-F': 'findPersistent',
|
||||
'Cmd-H': 'replace',
|
||||
|
||||
@@ -1,75 +1,14 @@
|
||||
import { memo } from 'react';
|
||||
import SwaggerUI from 'swagger-ui-react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { serializeBody } from './serializeBody';
|
||||
|
||||
const serializeHeaders = (headers) => {
|
||||
if (!headers) return {};
|
||||
if (typeof headers.entries === 'function') {
|
||||
const out = {};
|
||||
for (const [k, v] of headers.entries()) out[k] = v;
|
||||
return out;
|
||||
}
|
||||
return { ...headers };
|
||||
};
|
||||
|
||||
const proxiedFetch = async (url, options = {}) => {
|
||||
const result = await window.ipcRenderer.invoke('renderer:swagger-fetch', {
|
||||
url,
|
||||
method: options.method || 'GET',
|
||||
headers: serializeHeaders(options.headers),
|
||||
body: serializeBody(options.body)
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
const err = new TypeError(result.message);
|
||||
err.code = result.code;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// The Response constructor throws if a null-body status carries a body.
|
||||
const nullBodyStatus = [101, 204, 205, 304].includes(result.status);
|
||||
const bodyBytes = !nullBodyStatus && result.bodyBase64
|
||||
? Uint8Array.from(atob(result.bodyBase64), (c) => c.charCodeAt(0))
|
||||
: null;
|
||||
|
||||
// Build Headers manually so multi-value response headers (e.g. Set-Cookie,
|
||||
// which axios returns as string[]) end up as repeated entries rather than
|
||||
// joined via toString(). new Headers({ 'set-cookie': ['a','b'] }) coerces
|
||||
// the array to "a,b", which is invalid Set-Cookie syntax.
|
||||
const responseHeaders = new Headers();
|
||||
for (const [name, value] of Object.entries(result.headers || {})) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => responseHeaders.append(name, String(v)));
|
||||
} else if (value != null) {
|
||||
responseHeaders.append(name, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(bodyBytes, {
|
||||
status: result.status,
|
||||
statusText: result.statusText,
|
||||
headers: responseHeaders
|
||||
});
|
||||
};
|
||||
|
||||
const requestInterceptor = (req) => {
|
||||
req.userFetch = proxiedFetch;
|
||||
return req;
|
||||
};
|
||||
|
||||
const Swagger = ({ spec, onComplete }) => {
|
||||
const Swagger = ({ spec }) => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="swagger-root w-full">
|
||||
<SwaggerUI
|
||||
spec={spec}
|
||||
onComplete={onComplete}
|
||||
requestInterceptor={requestInterceptor}
|
||||
/>
|
||||
<SwaggerUI spec={spec} />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Swagger);
|
||||
export default Swagger;
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
// Serializes a SwaggerUI fetch body for transport across the renderer ↔ main
|
||||
// IPC bridge in `renderer:swagger-fetch`. Only types that survive Electron's
|
||||
// structured-clone serialization (and that our axios bridge knows how to send
|
||||
// as an HTTP body) are supported. Multipart / binary types throw so the user
|
||||
// gets a clear message in the SwaggerUI response panel instead of a silent
|
||||
// failure.
|
||||
|
||||
const detectBodyType = (body) => {
|
||||
if (body == null) return 'null';
|
||||
if (typeof body === 'string') return 'string';
|
||||
if (typeof FormData !== 'undefined' && body instanceof FormData) return 'FormData';
|
||||
if (typeof File !== 'undefined' && body instanceof File) return 'File';
|
||||
if (typeof Blob !== 'undefined' && body instanceof Blob) return 'Blob';
|
||||
if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) return 'URLSearchParams';
|
||||
if (typeof ArrayBuffer !== 'undefined' && body instanceof ArrayBuffer) return 'ArrayBuffer';
|
||||
if (ArrayBuffer.isView && ArrayBuffer.isView(body)) return body.constructor?.name || 'TypedArray';
|
||||
if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) return 'ReadableStream';
|
||||
return typeof body;
|
||||
};
|
||||
|
||||
export const UNSUPPORTED_BODY_TYPE_CODE = 'UNSUPPORTED_BODY_TYPE';
|
||||
|
||||
// Mapping from Web API class name (the raw detected type) to the user-facing
|
||||
// subject used in the error message. SwaggerUI itself supports these body
|
||||
// types fine; the limitation is Bruno's renderer↔main IPC bridge, not Swagger.
|
||||
const BODY_TYPE_LABEL_MAP = {
|
||||
File: 'File upload',
|
||||
Blob: 'Binary file upload',
|
||||
FormData: 'Multipart form data',
|
||||
ArrayBuffer: 'Binary data',
|
||||
ReadableStream: 'Streaming upload'
|
||||
};
|
||||
|
||||
const mapBodyTypeToLabel = (typeName) => {
|
||||
if (BODY_TYPE_LABEL_MAP[typeName]) return BODY_TYPE_LABEL_MAP[typeName];
|
||||
// TypedArrays (Uint8Array, Float32Array, etc.) share a label.
|
||||
if (typeof typeName === 'string' && typeName.endsWith('Array')) return 'Binary data';
|
||||
return 'This request body type';
|
||||
};
|
||||
|
||||
export const UNSUPPORTED_BODY_MESSAGE = (typeName) =>
|
||||
`${mapBodyTypeToLabel(typeName)} via the Swagger Try-it-out panel isn't supported in Bruno yet. `
|
||||
+ `Supported body types: JSON, URL-encoded forms, plain text. `
|
||||
+ `Create a Bruno request to test this endpoint.`;
|
||||
|
||||
// Build a TypeError that carries the detected type as a property so downstream
|
||||
// catchers can branch on `err.code` / `err.bodyType` instead of regex-parsing
|
||||
// the message. `err.bodyType` keeps the raw Web API class name for diagnostics;
|
||||
// the user-visible message uses the friendly subject above.
|
||||
const unsupportedBodyError = (typeName) => {
|
||||
const err = new TypeError(UNSUPPORTED_BODY_MESSAGE(typeName));
|
||||
err.code = UNSUPPORTED_BODY_TYPE_CODE;
|
||||
err.bodyType = typeName;
|
||||
return err;
|
||||
};
|
||||
|
||||
export const serializeBody = (body) => {
|
||||
const typeName = detectBodyType(body);
|
||||
|
||||
switch (typeName) {
|
||||
case 'null':
|
||||
return undefined;
|
||||
case 'string':
|
||||
return body;
|
||||
case 'URLSearchParams':
|
||||
return body.toString();
|
||||
case 'FormData':
|
||||
case 'File':
|
||||
case 'Blob':
|
||||
case 'ArrayBuffer':
|
||||
case 'ReadableStream':
|
||||
throw unsupportedBodyError(typeName);
|
||||
default:
|
||||
// TypedArrays land here (Uint8Array, etc.) — also unsupported by the bridge.
|
||||
if (ArrayBuffer.isView && ArrayBuffer.isView(body)) {
|
||||
throw unsupportedBodyError(typeName);
|
||||
}
|
||||
// Plain objects, numbers, booleans — pass through. SwaggerUI rarely sends
|
||||
// these as body directly (it stringifies JSON before fetch), but keep the
|
||||
// path open rather than rejecting unexpectedly.
|
||||
return body;
|
||||
}
|
||||
};
|
||||
@@ -1,95 +0,0 @@
|
||||
import { serializeBody, UNSUPPORTED_BODY_MESSAGE, UNSUPPORTED_BODY_TYPE_CODE } from './serializeBody';
|
||||
|
||||
// Helper: invoke serializeBody and return the thrown error (or fail).
|
||||
const catchSerializeError = (body) => {
|
||||
try {
|
||||
serializeBody(body);
|
||||
} catch (err) {
|
||||
return err;
|
||||
}
|
||||
throw new Error('expected serializeBody to throw');
|
||||
};
|
||||
|
||||
describe('serializeBody', () => {
|
||||
describe('supported body types', () => {
|
||||
it('returns undefined for null', () => {
|
||||
expect(serializeBody(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for undefined', () => {
|
||||
expect(serializeBody(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns string bodies as-is', () => {
|
||||
expect(serializeBody('{"name":"doggie"}')).toBe('{"name":"doggie"}');
|
||||
expect(serializeBody('plain text')).toBe('plain text');
|
||||
});
|
||||
|
||||
it('stringifies URLSearchParams', () => {
|
||||
const params = new URLSearchParams({ a: '1', b: '2' });
|
||||
expect(serializeBody(params)).toBe('a=1&b=2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsupported body types (BRU-3300)', () => {
|
||||
it('throws TypeError for FormData using "Multipart form data" subject', () => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', new Blob(['x']));
|
||||
expect(() => serializeBody(fd)).toThrow(TypeError);
|
||||
expect(() => serializeBody(fd)).toThrow(/Multipart form data/);
|
||||
expect(() => serializeBody(fd)).toThrow(/Create a Bruno request/);
|
||||
});
|
||||
|
||||
it('throws TypeError for Blob using "Binary file upload" subject', () => {
|
||||
const blob = new Blob(['payload']);
|
||||
expect(() => serializeBody(blob)).toThrow(TypeError);
|
||||
expect(() => serializeBody(blob)).toThrow(/Binary file upload/);
|
||||
});
|
||||
|
||||
it('throws TypeError for File using "File upload" subject', () => {
|
||||
const file = new File(['payload'], 'test.txt', { type: 'text/plain' });
|
||||
expect(() => serializeBody(file)).toThrow(TypeError);
|
||||
expect(() => serializeBody(file)).toThrow(/File upload/);
|
||||
});
|
||||
|
||||
it('throws TypeError for ArrayBuffer using "Binary data" subject', () => {
|
||||
const buf = new ArrayBuffer(8);
|
||||
expect(() => serializeBody(buf)).toThrow(TypeError);
|
||||
expect(() => serializeBody(buf)).toThrow(/Binary data/);
|
||||
});
|
||||
|
||||
it('throws TypeError for TypedArray using "Binary data" subject', () => {
|
||||
const u8 = new Uint8Array([1, 2, 3]);
|
||||
expect(() => serializeBody(u8)).toThrow(TypeError);
|
||||
expect(() => serializeBody(u8)).toThrow(/Binary data/);
|
||||
});
|
||||
|
||||
it('message attributes the limitation to Bruno, not Swagger', () => {
|
||||
expect(UNSUPPORTED_BODY_MESSAGE('FormData')).toMatch(/isn't supported in Bruno yet/);
|
||||
});
|
||||
|
||||
it('message lists supported alternatives', () => {
|
||||
expect(UNSUPPORTED_BODY_MESSAGE('FormData')).toMatch(/JSON, URL-encoded forms, plain text/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error metadata preservation (Bijin review feedback)', () => {
|
||||
it('attaches err.code = UNSUPPORTED_BODY_TYPE so callers can branch programmatically', () => {
|
||||
const err = catchSerializeError(new FormData());
|
||||
expect(err.code).toBe(UNSUPPORTED_BODY_TYPE_CODE);
|
||||
expect(UNSUPPORTED_BODY_TYPE_CODE).toBe('UNSUPPORTED_BODY_TYPE');
|
||||
});
|
||||
|
||||
it('attaches err.bodyType naming the specific unsupported type', () => {
|
||||
expect(catchSerializeError(new FormData()).bodyType).toBe('FormData');
|
||||
expect(catchSerializeError(new Blob(['x'])).bodyType).toBe('Blob');
|
||||
expect(catchSerializeError(new File(['x'], 'a.txt')).bodyType).toBe('File');
|
||||
expect(catchSerializeError(new ArrayBuffer(4)).bodyType).toBe('ArrayBuffer');
|
||||
expect(catchSerializeError(new Uint8Array([1, 2])).bodyType).toBe('Uint8Array');
|
||||
});
|
||||
|
||||
it('thrown error is still a TypeError instance', () => {
|
||||
expect(catchSerializeError(new FormData())).toBeInstanceOf(TypeError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,26 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React, { useState, useEffect, Suspense } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { IconDeviceFloppy, IconLoader2 } from '@tabler/icons';
|
||||
import { IconDeviceFloppy } from '@tabler/icons';
|
||||
import CodeEditor from './FileEditor/CodeEditor/index';
|
||||
import Swagger from './Renderers/Swagger';
|
||||
import { useDragResize } from 'hooks/useDragResize';
|
||||
|
||||
const MIN_LEFT_PANE_WIDTH = 300;
|
||||
const MIN_RIGHT_PANE_WIDTH = 450;
|
||||
|
||||
/**
|
||||
* Shared split-pane spec viewer: CodeEditor (left) + Swagger preview (right).
|
||||
*
|
||||
* Props:
|
||||
* - content (string) The spec content (YAML/JSON string)
|
||||
* - readOnly (boolean) If true, editor is not editable and save icon is hidden
|
||||
* - onSave (fn) Called with current editor content on save (editable mode only)
|
||||
* - leftPaneWidth (number|null) Persisted left pane width in px; null = use 50/50 default
|
||||
* - onLeftPaneWidthChange (fn) Persist the new width (called on mouseup / double-click / resize-clamp)
|
||||
* - content (string) The spec content (YAML/JSON string)
|
||||
* - readOnly (boolean) If true, editor is not editable and save icon is hidden
|
||||
* - onSave (function) Called with current editor content on save (editable mode only)
|
||||
*/
|
||||
const SpecViewer = ({ content, readOnly, onSave, leftPaneWidth, onLeftPaneWidthChange }) => {
|
||||
const SpecViewer = ({ content, readOnly, onSave }) => {
|
||||
const { displayedTheme, theme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const [editorContent, setEditorContent] = useState(content);
|
||||
|
||||
// Sync editor when saved content changes from outside (e.g. after save completes)
|
||||
useEffect(() => {
|
||||
setEditorContent(content);
|
||||
}, [content]);
|
||||
@@ -36,85 +31,38 @@ const SpecViewer = ({ content, readOnly, onSave, leftPaneWidth, onLeftPaneWidthC
|
||||
if (onSave) onSave(editorContent);
|
||||
};
|
||||
|
||||
const mainSectionRef = useRef(null);
|
||||
const { dragging, dragWidth, dragbarProps } = useDragResize({
|
||||
containerRef: mainSectionRef,
|
||||
width: leftPaneWidth,
|
||||
onWidthChange: onLeftPaneWidthChange,
|
||||
minLeft: MIN_LEFT_PANE_WIDTH,
|
||||
minRight: MIN_RIGHT_PANE_WIDTH
|
||||
});
|
||||
|
||||
const effectiveWidth = dragging ? dragWidth : leftPaneWidth;
|
||||
const leftPaneStyle = effectiveWidth != null
|
||||
? { width: `${effectiveWidth}px`, flexShrink: 0 }
|
||||
: { flex: '1 1 50%', minWidth: 0 };
|
||||
|
||||
const [swaggerReady, setSwaggerReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSwaggerReady(false);
|
||||
}, [content]);
|
||||
|
||||
const handleSwaggerComplete = useCallback(() => {
|
||||
// Double rAF: wait for one full paint cycle so Swagger is actually on screen
|
||||
// before hiding the loader — avoids a flash of unrendered content.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setSwaggerReady(true));
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={mainSectionRef}
|
||||
className={`main flex flex-grow pl-4 relative ${dragging ? 'dragging' : ''}`}
|
||||
>
|
||||
<div
|
||||
className="api-spec-left-pane flex flex-grow relative h-full"
|
||||
style={leftPaneStyle}
|
||||
>
|
||||
<CodeEditor
|
||||
theme={displayedTheme}
|
||||
value={readOnly ? content : editorContent}
|
||||
readOnly={readOnly ? 'nocursor' : false}
|
||||
onEdit={readOnly ? undefined : (val) => setEditorContent(val)}
|
||||
onSave={readOnly ? undefined : handleSave}
|
||||
mode="yaml"
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
/>
|
||||
{!readOnly && onSave && (
|
||||
<IconDeviceFloppy
|
||||
onClick={handleSave}
|
||||
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
|
||||
strokeWidth={1.5}
|
||||
size={22}
|
||||
className={`absolute right-0 top-0 m-4 ${
|
||||
hasChanges ? 'cursor-pointer opacity-100' : 'cursor-default opacity-50'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="dragbar-wrapper" {...dragbarProps}>
|
||||
<div className="dragbar-handle" />
|
||||
</div>
|
||||
<div
|
||||
className="api-spec-right-pane relative"
|
||||
style={{ flex: '1 1 50%', minWidth: 0 }}
|
||||
>
|
||||
<div style={{ visibility: swaggerReady ? 'visible' : 'hidden', height: '100%' }}>
|
||||
<Swagger spec={content} onComplete={handleSwaggerComplete} />
|
||||
</div>
|
||||
{!swaggerReady && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center gap-2"
|
||||
style={{ background: theme.bg }}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2 opacity-70">
|
||||
<IconLoader2 size={20} className="animate-spin" />
|
||||
<span>Generating preview…</span>
|
||||
</div>
|
||||
<section className="main flex flex-grow pl-4 relative">
|
||||
<div className="w-full grid grid-cols-2">
|
||||
<div className="col-span-1">
|
||||
<div className="flex flex-grow relative">
|
||||
<CodeEditor
|
||||
theme={displayedTheme}
|
||||
value={readOnly ? content : editorContent}
|
||||
readOnly={readOnly ? 'nocursor' : false}
|
||||
onEdit={readOnly ? undefined : (val) => setEditorContent(val)}
|
||||
onSave={readOnly ? undefined : handleSave}
|
||||
mode="yaml"
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
/>
|
||||
{!readOnly && onSave && (
|
||||
<IconDeviceFloppy
|
||||
onClick={handleSave}
|
||||
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
|
||||
strokeWidth={1.5}
|
||||
size={22}
|
||||
className={`absolute right-0 top-0 m-4 ${
|
||||
hasChanges ? 'cursor-pointer opacity-100' : 'cursor-default opacity-50'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Suspense fallback="">
|
||||
<Swagger spec={content} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -17,35 +17,6 @@ const StyledWrapper = styled.div`
|
||||
.react-tooltip {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
section.main.dragging {
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
div.dragbar-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 10px;
|
||||
min-width: 10px;
|
||||
padding: 0;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
|
||||
div.dragbar-handle {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
|
||||
}
|
||||
|
||||
&:hover div.dragbar-handle {
|
||||
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { forwardRef, useRef, useCallback } from 'react';
|
||||
import React, { forwardRef, useRef } from 'react';
|
||||
import find from 'lodash/find';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { IconFileCode, IconDots } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import SpecViewer from './SpecViewer';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { openApiSpec, saveApiSpecToFile, updateApiSpecPanelLeftPaneWidth } from 'providers/ReduxStore/slices/apiSpec';
|
||||
import { openApiSpec, saveApiSpecToFile } from 'providers/ReduxStore/slices/apiSpec';
|
||||
import { useState } from 'react';
|
||||
import CreateApiSpec from 'components/Sidebar/ApiSpecs/CreateApiSpec';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -21,16 +21,7 @@ const ApiSpecPanel = () => {
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
let apiSpec = find(apiSpecs, (c) => c.uid === activeApiSpecUid);
|
||||
const { filename, pathname, raw, uid, leftPaneWidth } = apiSpec || {};
|
||||
|
||||
const handleLeftPaneWidthChange = useCallback(
|
||||
(w) => {
|
||||
if (!uid) return;
|
||||
dispatch(updateApiSpecPanelLeftPaneWidth({ uid, leftPaneWidth: w }));
|
||||
},
|
||||
[dispatch, uid]
|
||||
);
|
||||
|
||||
const { filename, pathname, raw, uid } = apiSpec || {};
|
||||
if (!uid) {
|
||||
return <div className="p-4 opacity-50">API Spec not found!</div>;
|
||||
}
|
||||
@@ -88,8 +79,6 @@ const ApiSpecPanel = () => {
|
||||
<SpecViewer
|
||||
content={raw}
|
||||
onSave={(content) => dispatch(saveApiSpecToFile({ uid, content }))}
|
||||
leftPaneWidth={leftPaneWidth ?? null}
|
||||
onLeftPaneWidthChange={handleLeftPaneWidthChange}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -52,14 +52,6 @@ const AppTitleBar = () => {
|
||||
const { ipcRenderer } = window;
|
||||
if (!ipcRenderer) return;
|
||||
|
||||
ipcRenderer.invoke('renderer:window-is-fullscreen')
|
||||
.then((fullscreen) => {
|
||||
setIsFullScreen(fullscreen);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error getting initial fullscreen state:', error);
|
||||
});
|
||||
|
||||
const removeEnterFullScreenListener = ipcRenderer.on('main:enter-full-screen', () => {
|
||||
setIsFullScreen(true);
|
||||
});
|
||||
@@ -152,10 +144,8 @@ const AppTitleBar = () => {
|
||||
|
||||
const handleOpenWorkspace = async () => {
|
||||
try {
|
||||
const result = await dispatch(openWorkspaceDialog());
|
||||
if (result) {
|
||||
toast.success('Workspace opened successfully');
|
||||
}
|
||||
await dispatch(openWorkspaceDialog());
|
||||
toast.success('Workspace opened successfully');
|
||||
} catch (error) {
|
||||
toast.error(error.message || 'Failed to open workspace');
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import React from 'react';
|
||||
import { render, waitFor, act } from '@testing-library/react';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import { Provider } from 'react-redux';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
|
||||
jest.mock('ui/MenuDropdown', () => ({ children }) => <div>{children}</div>);
|
||||
jest.mock('ui/ActionIcon', () => ({ children, onClick, label }) => (
|
||||
<button onClick={onClick} aria-label={label}>{children}</button>
|
||||
));
|
||||
jest.mock('components/ResponsePane/ResponseLayoutToggle', () => () => null);
|
||||
|
||||
import AppTitleBar from './index';
|
||||
|
||||
const theme = {
|
||||
text: '#333',
|
||||
sidebar: {
|
||||
bg: '#fff',
|
||||
color: '#333',
|
||||
muted: '#888',
|
||||
collection: { item: { hoverBg: '#eee' } }
|
||||
},
|
||||
dropdown: { color: '#333', mutedText: '#888', hoverBg: '#eee' }
|
||||
};
|
||||
|
||||
const mockStore = configureStore({
|
||||
reducer: {
|
||||
workspaces: (state = { workspaces: [], activeWorkspaceUid: null }) => state,
|
||||
app: (state = { preferences: {}, sidebarCollapsed: false }) => state,
|
||||
logs: (state = { isConsoleOpen: false }) => state
|
||||
}
|
||||
});
|
||||
|
||||
const renderWithProviders = () => render(
|
||||
<Provider store={mockStore}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<AppTitleBar />
|
||||
</ThemeProvider>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
const getTitleBar = (container) => container.querySelector('.app-titlebar');
|
||||
|
||||
const mockInvokeWithFullscreen = (isFullScreen) => jest.fn((channel) => {
|
||||
if (channel === 'renderer:window-is-fullscreen') return Promise.resolve(isFullScreen);
|
||||
return Promise.resolve(false);
|
||||
});
|
||||
|
||||
describe('AppTitleBar — fullscreen state sync', () => {
|
||||
let ipcListeners;
|
||||
|
||||
beforeEach(() => {
|
||||
ipcListeners = {};
|
||||
window.ipcRenderer = {
|
||||
invoke: jest.fn().mockResolvedValue(false),
|
||||
send: jest.fn(),
|
||||
on: jest.fn((channel, cb) => {
|
||||
ipcListeners[channel] = cb;
|
||||
return jest.fn();
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.ipcRenderer;
|
||||
});
|
||||
|
||||
describe('initial state on mount', () => {
|
||||
it('should query the main process for current fullscreen state', async () => {
|
||||
renderWithProviders();
|
||||
await waitFor(() => {
|
||||
expect(window.ipcRenderer.invoke).toHaveBeenCalledWith('renderer:window-is-fullscreen');
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply fullscreen class when window is already fullscreen at mount', async () => {
|
||||
window.ipcRenderer.invoke = mockInvokeWithFullscreen(true);
|
||||
|
||||
const { container } = renderWithProviders();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getTitleBar(container)).toHaveClass('fullscreen');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not apply fullscreen class when window is windowed at mount', async () => {
|
||||
const { container } = renderWithProviders();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.ipcRenderer.invoke).toHaveBeenCalledWith('renderer:window-is-fullscreen');
|
||||
});
|
||||
expect(getTitleBar(container)).not.toHaveClass('fullscreen');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fullscreen transitions after mount', () => {
|
||||
it('should add fullscreen class on main:enter-full-screen event', async () => {
|
||||
const { container } = renderWithProviders();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.ipcRenderer.invoke).toHaveBeenCalledWith('renderer:window-is-fullscreen');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
ipcListeners['main:enter-full-screen']();
|
||||
});
|
||||
|
||||
expect(getTitleBar(container)).toHaveClass('fullscreen');
|
||||
});
|
||||
|
||||
it('should remove fullscreen class on main:leave-full-screen event', async () => {
|
||||
window.ipcRenderer.invoke = mockInvokeWithFullscreen(true);
|
||||
|
||||
const { container } = renderWithProviders();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getTitleBar(container)).toHaveClass('fullscreen');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
ipcListeners['main:leave-full-screen']();
|
||||
});
|
||||
|
||||
expect(getTitleBar(container)).not.toHaveClass('fullscreen');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@ const BulkEditor = ({ params, onChange, onToggle, onSave, onRun }) => {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex btn-action justify-between items-center mt-3">
|
||||
<button className="text-link select-none ml-auto" data-testid="key-value-edit-toggle" onClick={onToggle}>
|
||||
<button className="text-link select-none ml-auto" onClick={onToggle}>
|
||||
Key/Value Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# What's New in Bruno
|
||||
|
||||
- Various stability and performance improvements.
|
||||
|
||||
---
|
||||
|
||||
For the full release history, see the [Bruno releases page](https://github.com/usebruno/bruno/releases).
|
||||
@@ -1,31 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.changelog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid ${(props) => props.theme.requestTabs?.border || props.theme.sidebar?.border || 'transparent'};
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.header-version {
|
||||
font-size: ${(props) => props.theme.font?.size?.sm || '0.85em'};
|
||||
color: ${(props) => props.theme.colors?.text?.muted || props.theme.text};
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.changelog-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.5rem 2rem 1.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -1,23 +0,0 @@
|
||||
import React from 'react';
|
||||
import { IconConfetti } from '@tabler/icons';
|
||||
import Markdown from 'components/MarkDown';
|
||||
import { version } from '../../../package.json';
|
||||
import changelogContent from './CHANGELOG.md';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const ChangelogTab = () => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="changelog-header">
|
||||
<IconConfetti size={18} strokeWidth={1.5} />
|
||||
<span>What's New</span>
|
||||
<span className="header-version">v{version}</span>
|
||||
</div>
|
||||
<div className="changelog-body">
|
||||
<Markdown content={changelogContent} onDoubleClick={() => {}} />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangelogTab;
|
||||
@@ -151,46 +151,14 @@ const StyledWrapper = styled.div`
|
||||
|
||||
//matching bracket fix
|
||||
.CodeMirror-matchingbracket {
|
||||
background: ${(props) => props.theme.status.success.background} !important;
|
||||
text-decoration: unset;
|
||||
}
|
||||
|
||||
.CodeMirror-nonmatchingbracket {
|
||||
color: ${(props) => props.theme.colors.text.danger} !important;
|
||||
background: ${(props) => props.theme.status.danger.background} !important;
|
||||
text-decoration: unset;
|
||||
background: #5cc0b48c !important;
|
||||
text-decoration:unset;
|
||||
}
|
||||
|
||||
.cm-search-line-highlight {
|
||||
background: ${(props) => props.theme.codemirror.searchLineHighlightCurrent};
|
||||
}
|
||||
|
||||
@keyframes cm-error-line-flash {
|
||||
0%, 60% {
|
||||
background-color: ${(props) => props.theme.status.danger.background};
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.CodeMirror .cm-error-line-flash {
|
||||
background-color: transparent;
|
||||
animation: cm-error-line-flash 3s ease-in-out;
|
||||
}
|
||||
|
||||
.CodeMirror .cm-error-line-flash-gutter {
|
||||
color: ${(props) => props.theme.colors.text.danger} !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.CodeMirror .cm-error-line-flash {
|
||||
animation: none;
|
||||
background-color: ${(props) => props.theme.status.danger.background};
|
||||
}
|
||||
}
|
||||
|
||||
.cm-search-match {
|
||||
background: rgba(255, 193, 7, 0.25);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import React, { createRef } from 'react';
|
||||
import { debounce, isEqual } from 'lodash';
|
||||
import { isEqual, escapeRegExp } from 'lodash';
|
||||
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
|
||||
import { setupAutoComplete, showRootHints } from 'utils/codemirror/autocomplete';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -16,16 +16,7 @@ import stripJsonComments from 'strip-json-comments';
|
||||
import { getAllVariables } from 'utils/collections';
|
||||
import { setupLinkAware } from 'utils/codemirror/linkAware';
|
||||
import { setupLintErrorTooltip } from 'utils/codemirror/lint-errors';
|
||||
import { setupCodeMirrorResizeRefresh } from 'utils/codemirror/resize';
|
||||
import CodeMirrorSearch from 'components/CodeMirrorSearch/index';
|
||||
import {
|
||||
applyEditorState,
|
||||
captureEditorState,
|
||||
getDocKey,
|
||||
readPersistedEditorState,
|
||||
writePersistedEditorState
|
||||
} from './state-persistence';
|
||||
import { usePersistenceScope } from 'hooks/usePersistedState/PersistedScopeProvider';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
window.jsonlint = jsonlint;
|
||||
@@ -33,7 +24,7 @@ window.JSHINT = JSHINT;
|
||||
|
||||
const TAB_SIZE = 2;
|
||||
|
||||
class CodeEditor extends React.Component {
|
||||
export default class CodeEditor extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
@@ -57,24 +48,8 @@ class CodeEditor extends React.Component {
|
||||
};
|
||||
}
|
||||
|
||||
// Thin wrapper around the pure getDocKey helper from state-persistence.js.
|
||||
// Kept on the class so the rest of the lifecycle code reads naturally.
|
||||
_getDocKey() {
|
||||
return getDocKey(this.props);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const variables = getAllVariables(this.props.collection, this.props.item);
|
||||
/**
|
||||
* No-op. We claim Cmd-Enter / Ctrl-Enter here only to suppress CodeMirror's
|
||||
* sublime keymap default (insertLineAfter), which would otherwise insert a
|
||||
* newline. sendRequest dispatch is owned by Mousetrap — the editor input has
|
||||
* the `mousetrap` class (added below) so the global
|
||||
* useKeybinding('sendRequest', …) in RequestTabPanel handles it, and only
|
||||
* in request tabs. Falling through with CodeMirror.Pass when onRun is absent
|
||||
* would re-introduce the newline in collection/folder-level editors.
|
||||
*/
|
||||
const runShortcut = () => {};
|
||||
|
||||
const editor = (this.editor = CodeMirror(this._node, {
|
||||
value: this.props.value || '',
|
||||
@@ -99,6 +74,26 @@ class CodeEditor extends React.Component {
|
||||
scrollbarStyle: 'overlay',
|
||||
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
|
||||
extraKeys: {
|
||||
'Cmd-Enter': () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Ctrl-Enter': () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Cmd-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Ctrl-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Cmd-F': (cm) => {
|
||||
this.setState({ searchBarVisible: true }, () => {
|
||||
this.searchBarRef.current?.focus();
|
||||
@@ -109,10 +104,8 @@ class CodeEditor extends React.Component {
|
||||
this.searchBarRef.current?.focus();
|
||||
});
|
||||
},
|
||||
'Cmd-H': this.props.readOnly ? false : 'replace',
|
||||
'Ctrl-H': this.props.readOnly ? false : 'replace',
|
||||
'Cmd-Enter': runShortcut,
|
||||
'Ctrl-Enter': runShortcut,
|
||||
'Cmd-H': 'replace',
|
||||
'Ctrl-H': 'replace',
|
||||
'Tab': function (cm) {
|
||||
cm.getSelection().includes('\n') || editor.getLine(cm.getCursor().line) == cm.getSelection()
|
||||
? cm.execCommand('indentMore')
|
||||
@@ -202,49 +195,9 @@ class CodeEditor extends React.Component {
|
||||
});
|
||||
|
||||
if (editor) {
|
||||
// CM5 was constructed with props.value, so the editor already shows the
|
||||
// right content. Read this tab's previously persisted view state from
|
||||
// localStorage and apply it on top — restores folds, cursor, selection,
|
||||
// undo history, and scroll position.
|
||||
const docKey = getDocKey(this.props);
|
||||
this._currentDocKey = docKey;
|
||||
this.cachedValue = editor.getValue();
|
||||
applyEditorState(
|
||||
editor,
|
||||
readPersistedEditorState({ scope: this.props.persistenceScope, key: docKey }),
|
||||
this.cachedValue
|
||||
);
|
||||
|
||||
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
|
||||
editor.on('change', this._onEdit);
|
||||
|
||||
// Persist view state immediately when the user folds or unfolds — without
|
||||
// this, a fold only gets saved on the next tab switch / unmount. That
|
||||
// makes the persistence feel "delayed" or random, especially across
|
||||
// sub-tab switches that don't change the docKey or unmount the editor.
|
||||
// Debounced so rapid fold/unfold (e.g. Cmd-Y to fold all) doesn't write
|
||||
// to localStorage on every event.
|
||||
this._persistViewStateDebounced = debounce(() => {
|
||||
if (!this.editor || !this._currentDocKey) return;
|
||||
writePersistedEditorState({
|
||||
scope: this.props.persistenceScope,
|
||||
key: this._currentDocKey,
|
||||
state: captureEditorState(this.editor)
|
||||
});
|
||||
}, 250);
|
||||
editor.on('fold', this._persistViewStateDebounced);
|
||||
editor.on('unfold', this._persistViewStateDebounced);
|
||||
|
||||
editor.scrollTo(null, this.props.initialScroll);
|
||||
this._lastScrollTop = this.props.initialScroll || 0;
|
||||
editor.on('scroll', () => {
|
||||
const wrapper = editor.getWrapperElement();
|
||||
if (wrapper && wrapper.offsetParent === null) return;
|
||||
this._lastScrollTop = editor.getScrollInfo().top;
|
||||
if (this.props.onScroll && typeof this.props.onScroll === 'function') {
|
||||
this.props.onScroll(this._lastScrollTop);
|
||||
}
|
||||
});
|
||||
this.addOverlay();
|
||||
|
||||
const getAllVariablesHandler = () => getAllVariables(this.props.collection, this.props.item);
|
||||
@@ -264,14 +217,6 @@ class CodeEditor extends React.Component {
|
||||
|
||||
// Setup lint error tooltip on line number hover
|
||||
this.cleanupLintErrorTooltip = setupLintErrorTooltip(editor);
|
||||
|
||||
// Add mousetrap class so Mousetrap captures shortcuts even when CodeMirror is focused
|
||||
const cmInput = editor.getInputField();
|
||||
if (cmInput) {
|
||||
cmInput.classList.add('mousetrap');
|
||||
}
|
||||
|
||||
this.cleanupResizeRefresh = setupCodeMirrorResizeRefresh(editor, this._node);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,51 +232,18 @@ class CodeEditor extends React.Component {
|
||||
this.editor.options.jump.schema = this.props.schema;
|
||||
CodeMirror.signal(this.editor, 'change', this.editor);
|
||||
}
|
||||
if (this.editor) {
|
||||
// Two distinct update paths:
|
||||
// 1. Doc key changed → tab switch → snapshot outgoing state, load new content, restore incoming state
|
||||
// 2. Same doc, value changed → external content update → setValue (view state resets)
|
||||
const newDocKey = getDocKey(this.props);
|
||||
const docKeyChanged = newDocKey !== this._currentDocKey;
|
||||
|
||||
if (docKeyChanged) {
|
||||
// Path 1 — tab switch.
|
||||
// Snapshot the outgoing tab's view state to localStorage so a future
|
||||
// visit can restore it. Then setValue the incoming content and apply
|
||||
// any view state previously persisted for the incoming tab.
|
||||
if (this._currentDocKey) {
|
||||
writePersistedEditorState({
|
||||
scope: this.props.persistenceScope,
|
||||
key: this._currentDocKey,
|
||||
state: captureEditorState(this.editor)
|
||||
});
|
||||
}
|
||||
this.cachedValue = String(this?.props?.value ?? '');
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this._currentDocKey = newDocKey;
|
||||
applyEditorState(
|
||||
this.editor,
|
||||
readPersistedEditorState({ scope: this.props.persistenceScope, key: newDocKey }),
|
||||
this.cachedValue
|
||||
);
|
||||
// setValue resets the editor's mode-overlay state — re-apply the
|
||||
// brunovariables overlay and re-evaluate lint config for the new content.
|
||||
this.addOverlay();
|
||||
this.editor.setOption(
|
||||
'lint',
|
||||
this.props.mode && this.editor.getValue().trim().length > 0 ? this.lintOptions : false
|
||||
);
|
||||
} else if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue) {
|
||||
// Path 2 — same tab, new external value (e.g. a fresh response arrived
|
||||
// while this tab was active). Update content; view state resets because
|
||||
// line positions no longer correspond to anything. Invalidate the
|
||||
// persisted snapshot too, since the saved cursor/folds/history reflect
|
||||
// the prior content.
|
||||
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
|
||||
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
|
||||
const nextValue = this.props.value ?? '';
|
||||
const currentValue = this.editor.getValue();
|
||||
// Skip updating only when focused and editable; read-only editors (e.g. response viewer) must always show new value
|
||||
if (this.editor.hasFocus?.() && currentValue !== nextValue && !this.props.readOnly) {
|
||||
this.cachedValue = currentValue;
|
||||
} else {
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = String(this?.props?.value ?? '');
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.cachedValue = nextValue;
|
||||
this.editor.setValue(nextValue);
|
||||
this.editor.setCursor(cursor);
|
||||
writePersistedEditorState({ scope: this.props.persistenceScope, key: this._currentDocKey, state: null });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,34 +290,14 @@ class CodeEditor extends React.Component {
|
||||
componentWillUnmount() {
|
||||
if (this.editor) {
|
||||
if (this.props.onScroll) {
|
||||
this.props.onScroll(this._lastScrollTop);
|
||||
}
|
||||
|
||||
// Snapshot view state to localStorage before tearing down the editor so
|
||||
// the next mount of a CodeEditor with this docKey can restore folds,
|
||||
// cursor, selection, undo history, and scroll position.
|
||||
if (this._currentDocKey) {
|
||||
writePersistedEditorState({
|
||||
scope: this.props.persistenceScope,
|
||||
key: this._currentDocKey,
|
||||
state: captureEditorState(this.editor)
|
||||
});
|
||||
this.props.onScroll(this.editor);
|
||||
}
|
||||
|
||||
this.editor?._destroyLinkAware?.();
|
||||
this.editor.off('change', this._onEdit);
|
||||
|
||||
// Tear down the debounced fold-persistence listener. Cancel any pending
|
||||
// call so it can't fire after we've already snapshotted state above.
|
||||
if (this._persistViewStateDebounced) {
|
||||
this.editor.off('fold', this._persistViewStateDebounced);
|
||||
this.editor.off('unfold', this._persistViewStateDebounced);
|
||||
this._persistViewStateDebounced.cancel?.();
|
||||
}
|
||||
|
||||
// Clean up lint error tooltip
|
||||
this.cleanupLintErrorTooltip?.();
|
||||
this.cleanupResizeRefresh?.();
|
||||
|
||||
const wrapper = this.editor.getWrapperElement();
|
||||
wrapper?.parentNode?.removeChild(wrapper);
|
||||
@@ -467,12 +359,3 @@ class CodeEditor extends React.Component {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const CodeEditorWithPersistenceScope = React.forwardRef((props, ref) => {
|
||||
const persistenceScope = usePersistenceScope();
|
||||
return <CodeEditor {...props} persistenceScope={persistenceScope} ref={ref} />;
|
||||
});
|
||||
|
||||
CodeEditorWithPersistenceScope.displayName = 'CodeEditor';
|
||||
|
||||
export default CodeEditorWithPersistenceScope;
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
/*
|
||||
* CodeEditor view-state persistence — extracted for testability.
|
||||
*
|
||||
* Why this exists:
|
||||
* Every tab switch causes CodeMirror's setValue() to wipe folds, cursor,
|
||||
* selection, undo history, and scroll position. To preserve them, we serialize
|
||||
* the relevant pieces to localStorage under a stable key for each editor and
|
||||
* re-apply them on mount / tab switch. CodeMirror exposes a JSON-serializable
|
||||
* representation of its undo stack via getHistory()/setHistory(), which is what
|
||||
* makes Cmd-Z continue working across switches.
|
||||
*
|
||||
* Note: we deliberately do NOT persist the content itself — the canonical value
|
||||
* lives in Redux (props.value). We only persist the editor's "view" state on
|
||||
* top of that content. If content has drifted between save and restore, fold
|
||||
* positions are applied leniently (foldCode silently no-ops on invalid lines)
|
||||
* and history is skipped to avoid an inconsistent undo stack.
|
||||
*/
|
||||
|
||||
export const STORAGE_PREFIX = 'persisted::';
|
||||
export const DEFAULT_PERSISTENCE_SCOPE = 'global';
|
||||
export const STORAGE_SEGMENT = 'codeeditor';
|
||||
|
||||
export const getScopedStorageKey = (scope, key) => {
|
||||
const resolvedScope = scope || DEFAULT_PERSISTENCE_SCOPE;
|
||||
return `${STORAGE_PREFIX}${resolvedScope}::${STORAGE_SEGMENT}::${key}`;
|
||||
};
|
||||
|
||||
// Identifies which Doc state belongs to a given CodeEditor instance.
|
||||
//
|
||||
// Callers can pass an explicit `docKey` prop when the auto-derived key would
|
||||
// collide — e.g. Pre-Request vs Post-Response script editors share the same
|
||||
// item/mode/readOnly and need an extra disambiguator.
|
||||
//
|
||||
// Auto-derived parts:
|
||||
// id — distinguishes different tabs (requests or collections)
|
||||
// mode — distinguishes editors within the same tab (e.g. JSON body vs JS script)
|
||||
// readOnly — distinguishes response viewer (ro) from body editor (rw) when modes match
|
||||
export const getDocKey = (props) => {
|
||||
if (props.docKey) return props.docKey;
|
||||
const id = props.item?.uid || props.collection?.uid || 'default';
|
||||
const mode = props.mode || 'default';
|
||||
const readOnly = props.readOnly ? 'ro' : 'rw';
|
||||
return `${id}:${mode}:${readOnly}`;
|
||||
};
|
||||
|
||||
export const readPersistedEditorState = ({ scope, key }) => {
|
||||
try {
|
||||
const raw = localStorage.getItem(getScopedStorageKey(scope, key));
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const writePersistedEditorState = ({ scope, key, state }) => {
|
||||
try {
|
||||
const storageKey = getScopedStorageKey(scope, key);
|
||||
if (state == null) {
|
||||
localStorage.removeItem(storageKey);
|
||||
} else {
|
||||
localStorage.setItem(storageKey, JSON.stringify(state));
|
||||
}
|
||||
} catch {
|
||||
// localStorage may be unavailable or full (Chromium ~10 MB cap). Editor
|
||||
// state is non-critical — content lives in Redux — so silently ignore.
|
||||
}
|
||||
};
|
||||
|
||||
export const captureEditorState = (editor) => {
|
||||
if (!editor) return null;
|
||||
const doc = editor.getDoc();
|
||||
const folds = editor
|
||||
.getAllMarks()
|
||||
.filter((m) => m.__isFold)
|
||||
.map((m) => m.find())
|
||||
.filter(Boolean)
|
||||
.map((range) => range.from);
|
||||
return {
|
||||
contentLength: doc.getValue().length,
|
||||
cursor: doc.getCursor(),
|
||||
selections: doc.listSelections(),
|
||||
history: doc.getHistory(),
|
||||
folds,
|
||||
scrollY: editor.getScrollInfo().top
|
||||
};
|
||||
};
|
||||
|
||||
export const applyEditorState = (editor, state, currentContent) => {
|
||||
if (!editor || !state) return;
|
||||
const doc = editor.getDoc();
|
||||
const contentMatches = state.contentLength === (currentContent || '').length;
|
||||
|
||||
// History/cursor/selection only make sense if content didn't drift — applying
|
||||
// a stale undo stack to different content would let Cmd-Z replay edits that
|
||||
// no longer correspond to anything visible.
|
||||
if (contentMatches) {
|
||||
if (state.history) {
|
||||
try { doc.setHistory(state.history); } catch {}
|
||||
}
|
||||
if (state.cursor) {
|
||||
try { doc.setCursor(state.cursor); } catch {}
|
||||
}
|
||||
if (state.selections && state.selections.length) {
|
||||
try { doc.setSelections(state.selections); } catch {}
|
||||
}
|
||||
}
|
||||
// Folds are cheap and lenient — try them either way.
|
||||
// Sort innermost-first (line desc): when folds are nested, applying the
|
||||
// inner one before the outer one is safer because brace-fold's findRange
|
||||
// re-scans the line text. With outer-first, deeply nested arrays inside a
|
||||
// folded object can fail to refold (issue specific to JSON arrays where
|
||||
// the helper's lookback can land on the wrong opening character once the
|
||||
// outer block is collapsed).
|
||||
if (state.folds && state.folds.length) {
|
||||
const sorted = [...state.folds].sort(
|
||||
(a, b) => b.line - a.line || b.ch - a.ch
|
||||
);
|
||||
editor.operation(() => {
|
||||
sorted.forEach((from) => {
|
||||
try {
|
||||
editor.foldCode(from, null, 'fold');
|
||||
} catch {}
|
||||
});
|
||||
});
|
||||
}
|
||||
if (state.scrollY != null) {
|
||||
try { editor.scrollTo(null, state.scrollY); } catch {}
|
||||
}
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
import { rgba } from 'polished';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.code-snippet {
|
||||
font-family: monospace;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
line-height: 1.4;
|
||||
overflow-x: auto;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
background-color: ${(props) => props.theme.background.elevated};
|
||||
border: 1px solid ${(props) => props.theme.border.border2};
|
||||
}
|
||||
|
||||
.code-line {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.code-line.highlighted-error {
|
||||
background-color: ${(props) => rgba(props.theme.colors.text.danger, 0.1)};
|
||||
border-left: 3px solid ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
|
||||
.code-line.highlighted-warning {
|
||||
background-color: ${(props) => rgba(props.theme.colors.text.warning, 0.1)};
|
||||
border-left: 3px solid ${(props) => props.theme.colors.text.warning};
|
||||
}
|
||||
|
||||
.code-line:not(.highlighted-error):not(.highlighted-warning) {
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.code-line-number {
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
padding: 0 0.5rem;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.code-line-content {
|
||||
white-space: pre;
|
||||
padding: 0 0.5rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.code-line-separator {
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.separator-content {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
user-select: none;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -1,55 +0,0 @@
|
||||
import React from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const renderLine = (line, highlightClass, hunkIdx) => {
|
||||
const isHighlighted = line.isHighlighted || line.isError;
|
||||
const key = hunkIdx != null ? `${hunkIdx}-${line.lineNumber}` : line.lineNumber;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`code-line ${isHighlighted ? highlightClass : ''}`}
|
||||
data-testid={isHighlighted ? 'code-line-error' : 'code-line'}
|
||||
>
|
||||
<span className="code-line-number">{line.lineNumber}</span>
|
||||
<span className="code-line-content">
|
||||
{isHighlighted ? '> ' : ' '}{line.content}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CodeSnippet = ({ lines, hunks, variant = 'error' }) => {
|
||||
const highlightClass = variant === 'warning' ? 'highlighted-warning' : 'highlighted-error';
|
||||
|
||||
if (hunks?.length) {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="code-snippet" data-testid="code-snippet">
|
||||
{hunks.map((hunk, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
{hunk.hasSeparatorBefore && (
|
||||
<div className="code-line code-line-separator">
|
||||
<span className="code-line-number"></span>
|
||||
<span className="code-line-content separator-content">{'\u22EE'}</span>
|
||||
</div>
|
||||
)}
|
||||
{hunk.lines.map((line) => renderLine(line, highlightClass, idx))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (!lines?.length) return null;
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="code-snippet" data-testid="code-snippet">
|
||||
{lines.map((line) => renderLine(line, highlightClass))}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodeSnippet;
|
||||
@@ -1,140 +0,0 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import CodeSnippet from './index';
|
||||
|
||||
const theme = {
|
||||
font: { size: { xs: '0.75rem' } },
|
||||
background: { elevated: '#f5f5f5' },
|
||||
border: { border2: '#e0e0e0', radius: { base: '4px' } },
|
||||
colors: { text: { danger: '#ef4444', warning: '#f59e0b', muted: '#999' } }
|
||||
};
|
||||
|
||||
const renderWithTheme = (component) => {
|
||||
return render(
|
||||
<ThemeProvider theme={theme}>
|
||||
{component}
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const sampleLines = [
|
||||
{ lineNumber: 3, content: 'const a = 1;', isHighlighted: false },
|
||||
{ lineNumber: 4, content: 'undefinedVar.foo();', isHighlighted: true },
|
||||
{ lineNumber: 5, content: 'const b = 2;', isHighlighted: false }
|
||||
];
|
||||
|
||||
describe('CodeSnippet', () => {
|
||||
it('should render nothing when lines is empty', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet lines={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should render nothing when lines is null', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet lines={null} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should render all lines with line numbers', () => {
|
||||
renderWithTheme(<CodeSnippet lines={sampleLines} />);
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
expect(screen.getByText('4')).toBeInTheDocument();
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply error highlight class by default', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet lines={sampleLines} variant="error" />);
|
||||
const highlightedLine = container.querySelector('.highlighted-error');
|
||||
expect(highlightedLine).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply warning highlight class when variant is warning', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet lines={sampleLines} variant="warning" />);
|
||||
const highlightedLine = container.querySelector('.highlighted-warning');
|
||||
expect(highlightedLine).toBeInTheDocument();
|
||||
expect(container.querySelector('.highlighted-error')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show > prefix on highlighted line for accessibility', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet lines={sampleLines} />);
|
||||
const codeLineContents = container.querySelectorAll('.code-line-content');
|
||||
// The highlighted line (index 1) should start with "> "
|
||||
expect(codeLineContents[1].textContent).toContain('> ');
|
||||
// Non-highlighted lines should not have ">"
|
||||
expect(codeLineContents[0].textContent).not.toContain('>');
|
||||
});
|
||||
|
||||
it('should also support isError property for backward compatibility', () => {
|
||||
const linesWithIsError = [
|
||||
{ lineNumber: 1, content: 'line 1', isError: false },
|
||||
{ lineNumber: 2, content: 'error line', isError: true },
|
||||
{ lineNumber: 3, content: 'line 3', isError: false }
|
||||
];
|
||||
const { container } = renderWithTheme(<CodeSnippet lines={linesWithIsError} />);
|
||||
expect(container.querySelector('.highlighted-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('hunks prop', () => {
|
||||
const sampleHunks = [
|
||||
{
|
||||
hasSeparatorBefore: false,
|
||||
lines: [
|
||||
{ lineNumber: 1, content: 'const a = true;', isHighlighted: false },
|
||||
{ lineNumber: 2, content: 'pm.vault.get();', isHighlighted: true },
|
||||
{ lineNumber: 3, content: 'const b = false;', isHighlighted: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
hasSeparatorBefore: true,
|
||||
lines: [
|
||||
{ lineNumber: 10, content: 'const x = null;', isHighlighted: false },
|
||||
{ lineNumber: 11, content: 'pm.cookies.jar();', isHighlighted: true },
|
||||
{ lineNumber: 12, content: 'const y = undefined;', isHighlighted: false }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
it('should render all lines from all hunks', () => {
|
||||
renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
|
||||
// line numbers
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
expect(screen.getByText('10')).toBeInTheDocument();
|
||||
expect(screen.getByText('11')).toBeInTheDocument();
|
||||
// content
|
||||
expect(screen.getByText(/const a = true;/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/pm\.vault\.get\(\);/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/const x = null;/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/pm\.cookies\.jar\(\);/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render separator between hunks when hasSeparatorBefore is true', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
|
||||
const separators = container.querySelectorAll('.code-line-separator');
|
||||
expect(separators).toHaveLength(1);
|
||||
// separator should appear between the two hunks, not before the first
|
||||
const allRows = container.querySelectorAll('.code-line, .code-line-separator');
|
||||
const separatorIndex = Array.from(allRows).findIndex((el) => el.classList.contains('code-line-separator'));
|
||||
// first hunk has 3 lines (indices 0-2), separator should be at index 3
|
||||
expect(separatorIndex).toBe(3);
|
||||
});
|
||||
|
||||
it('should render the ellipsis character in separator', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
|
||||
const separator = container.querySelector('.separator-content');
|
||||
expect(separator.textContent).toBe('\u22EE');
|
||||
});
|
||||
|
||||
it('should apply warning highlights within hunks', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
|
||||
const highlighted = container.querySelectorAll('.highlighted-warning');
|
||||
expect(highlighted).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should render nothing when hunks is empty array', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet hunks={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,11 +51,6 @@ const AuthMode = ({ collection }) => {
|
||||
label: 'NTLM Auth',
|
||||
onClick: () => onModeChange('ntlm')
|
||||
},
|
||||
{
|
||||
id: 'oauth1',
|
||||
label: 'OAuth 1.0',
|
||||
onClick: () => onModeChange('oauth1')
|
||||
},
|
||||
{
|
||||
id: 'oauth2',
|
||||
label: 'OAuth 2.0',
|
||||
@@ -75,13 +70,13 @@ const AuthMode = ({ collection }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="inline-flex items-center cursor-pointer auth-mode-selector" data-testid="auth-mode-selector">
|
||||
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
|
||||
<MenuDropdown
|
||||
items={menuItems}
|
||||
placement="bottom-end"
|
||||
selectedItemId={authMode}
|
||||
>
|
||||
<div className="flex items-center justify-center auth-mode-label select-none" data-testid="auth-mode-label">
|
||||
<div className="flex items-center justify-center auth-mode-label select-none">
|
||||
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import React from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import OAuth1 from 'components/RequestPane/Auth/OAuth1';
|
||||
import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
|
||||
const CollectionOAuth1 = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const request = collection.draft?.root
|
||||
? get(collection, 'draft.root.request', {})
|
||||
: get(collection, 'root.request', {});
|
||||
|
||||
const save = () => dispatch(saveCollectionSettings(collection.uid));
|
||||
|
||||
return (
|
||||
<OAuth1
|
||||
collection={collection}
|
||||
request={request}
|
||||
save={save}
|
||||
updateAuth={updateCollectionAuth}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionOAuth1;
|
||||
@@ -12,7 +12,6 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import OAuth2 from './OAuth2';
|
||||
import NTLMAuth from './NTLMAuth';
|
||||
import OAuth1 from './Oauth1';
|
||||
import Button from 'ui/Button';
|
||||
|
||||
const Auth = ({ collection }) => {
|
||||
@@ -38,9 +37,6 @@ const Auth = ({ collection }) => {
|
||||
case 'ntlm': {
|
||||
return <NTLMAuth collection={collection} />;
|
||||
}
|
||||
case 'oauth1': {
|
||||
return <OAuth1 collection={collection} />;
|
||||
}
|
||||
case 'oauth2': {
|
||||
return <OAuth2 collection={collection} />;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,9 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
.editing-mode {
|
||||
cursor: pointer;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: ${(props) => props.theme.bg};
|
||||
padding: 6px 0;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
height: auto !important;
|
||||
overflow-y: visible !important;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import 'github-markdown-css/github-markdown.css';
|
||||
import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import { updateCollectionDocs, deleteCollectionDraft } from 'providers/ReduxStore/slices/collections';
|
||||
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useRef } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import Markdown from 'components/MarkDown';
|
||||
@@ -13,27 +11,16 @@ import StyledWrapper from './StyledWrapper';
|
||||
import { IconEdit, IconX, IconFileText } from '@tabler/icons';
|
||||
import Button from 'ui/Button/index';
|
||||
import ActionIcon from 'ui/ActionIcon/index';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
|
||||
const Docs = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { displayedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const isEditing = focusedTab?.docsEditing || false;
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const docs = collection.draft?.root ? get(collection, 'draft.root.docs', '') : get(collection, 'root.docs', '');
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
// StyledWrapper has overflow-y: auto — use null selector.
|
||||
// Preview mode: hook tracks wrapper scroll. Edit mode: CodeEditor's onScroll/initialScroll.
|
||||
const wrapperRef = useRef(null);
|
||||
const [scroll, setScroll] = usePersistedState({ key: `collection-docs-scroll-${collection.uid}`, default: 0 });
|
||||
useTrackScroll({ ref: wrapperRef, onChange: setScroll, enabled: !isEditing, initialValue: scroll });
|
||||
|
||||
const toggleViewMode = () => {
|
||||
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
|
||||
setIsEditing((prev) => !prev);
|
||||
};
|
||||
|
||||
const onEdit = (value) => {
|
||||
@@ -61,7 +48,7 @@ const Docs = ({ collection }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="h-full w-full relative flex flex-col" ref={wrapperRef}>
|
||||
<StyledWrapper className="h-full w-full relative flex flex-col">
|
||||
<div className="flex flex-row w-full justify-between items-center mb-4">
|
||||
<div className="text-lg font-medium flex items-center gap-2">
|
||||
<IconFileText size={20} strokeWidth={1.5} />
|
||||
@@ -94,11 +81,9 @@ const Docs = ({ collection }) => {
|
||||
mode="application/text"
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
initialScroll={scroll}
|
||||
onScroll={setScroll}
|
||||
/>
|
||||
) : (
|
||||
<div className="pl-1">
|
||||
<div className="h-full overflow-auto pl-1">
|
||||
<div className="h-[1px] min-h-[500px]">
|
||||
{
|
||||
docs?.length > 0
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { setCollectionHeaders } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -13,31 +12,16 @@ import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
|
||||
import BulkEditor from 'components/BulkEditor/index';
|
||||
import Button from 'ui/Button';
|
||||
import { headerNameRegex, headerValueRegex } from 'utils/common/regex';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
|
||||
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
|
||||
|
||||
const Headers = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const headers = collection.draft?.root
|
||||
? get(collection, 'draft.root.request.headers', [])
|
||||
: get(collection, 'root.request.headers', []);
|
||||
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
|
||||
const wrapperRef = useRef(null);
|
||||
const [scroll, setScroll] = usePersistedState({ key: `collection-headers-scroll-${collection.uid}`, default: 0 });
|
||||
useTrackScroll({ ref: wrapperRef, selector: '.collection-settings-content', onChange: setScroll, initialValue: scroll });
|
||||
|
||||
// Get column widths from Redux
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
const collectionHeadersWidths = focusedTab?.tableColumnWidths?.['collection-headers'] || {};
|
||||
|
||||
const handleColumnWidthsChange = (tableId, widths) => {
|
||||
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
|
||||
};
|
||||
|
||||
const toggleBulkEditMode = () => {
|
||||
setIsBulkEditMode(!isBulkEditMode);
|
||||
@@ -125,23 +109,19 @@ const Headers = ({ collection }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="h-full w-full" ref={wrapperRef}>
|
||||
<StyledWrapper className="h-full w-full">
|
||||
<div className="text-xs mb-4 text-muted">
|
||||
Add request headers that will be sent with every request in this collection.
|
||||
</div>
|
||||
<EditableTable
|
||||
tableId="collection-headers"
|
||||
columns={columns}
|
||||
rows={headers}
|
||||
onChange={handleHeadersChange}
|
||||
defaultRow={defaultRow}
|
||||
getRowError={getRowError}
|
||||
columnWidths={collectionHeadersWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('collection-headers', widths)}
|
||||
initialScroll={scroll}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button className="text-link select-none" data-testid="bulk-edit-toggle" onClick={toggleBulkEditMode}>
|
||||
<button className="text-link select-none" onClick={toggleBulkEditMode}>
|
||||
Bulk Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -8,12 +8,10 @@ const Overview = ({ collection }) => {
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div className="grid grid-cols-5 gap-5 h-full">
|
||||
<div className="col-span-2 overflow-clip text-ellipsis">
|
||||
<div className="flex gap-2 items-center min-w-0">
|
||||
<IconBox size={20} stroke={1.5} className="flex-shrink-0" />
|
||||
<span className="overflow-hidden text-lg font-medium whitespace-nowrap text-ellipsis">
|
||||
{collection?.name}
|
||||
</span>
|
||||
<div className="col-span-2">
|
||||
<div className="text-lg font-medium flex items-center gap-2">
|
||||
<IconBox size={20} stroke={1.5} />
|
||||
{collection?.name}
|
||||
</div>
|
||||
<Info collection={collection} />
|
||||
<RequestsNotLoaded collection={collection} />
|
||||
|
||||
@@ -5,11 +5,10 @@ import { updateCollectionPresets } from 'providers/ReduxStore/slices/collections
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { get } from 'lodash';
|
||||
import Button from 'ui/Button';
|
||||
import { DEFAULT_PRESET_REQUEST_TYPE, PRESET_REQUEST_TYPES } from 'utils/common/constants';
|
||||
|
||||
const PresetsSettings = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const initialPresets = { requestType: DEFAULT_PRESET_REQUEST_TYPE, requestUrl: '' };
|
||||
const initialPresets = { requestType: 'http', requestUrl: '' };
|
||||
|
||||
// Get presets from draft.brunoConfig if it exists, otherwise from brunoConfig
|
||||
const currentPresets = collection.draft?.brunoConfig
|
||||
@@ -48,13 +47,12 @@ const PresetsSettings = ({ collection }) => {
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="http"
|
||||
data-testid="presets-request-type-http"
|
||||
className="cursor-pointer"
|
||||
type="radio"
|
||||
name="requestType"
|
||||
onChange={handleRequestTypeChange}
|
||||
value={PRESET_REQUEST_TYPES.HTTP}
|
||||
checked={(currentPresets.requestType || DEFAULT_PRESET_REQUEST_TYPE) === PRESET_REQUEST_TYPES.HTTP}
|
||||
value="http"
|
||||
checked={(currentPresets.requestType || 'http') === 'http'}
|
||||
/>
|
||||
<label htmlFor="http" className="ml-1 cursor-pointer select-none">
|
||||
HTTP
|
||||
@@ -62,13 +60,12 @@ const PresetsSettings = ({ collection }) => {
|
||||
|
||||
<input
|
||||
id="graphql"
|
||||
data-testid="presets-request-type-graphql"
|
||||
className="ml-4 cursor-pointer"
|
||||
type="radio"
|
||||
name="requestType"
|
||||
onChange={handleRequestTypeChange}
|
||||
value={PRESET_REQUEST_TYPES.GRAPHQL}
|
||||
checked={(currentPresets.requestType || DEFAULT_PRESET_REQUEST_TYPE) === PRESET_REQUEST_TYPES.GRAPHQL}
|
||||
value="graphql"
|
||||
checked={(currentPresets.requestType || 'http') === 'graphql'}
|
||||
/>
|
||||
<label htmlFor="graphql" className="ml-1 cursor-pointer select-none">
|
||||
GraphQL
|
||||
@@ -76,13 +73,12 @@ const PresetsSettings = ({ collection }) => {
|
||||
|
||||
<input
|
||||
id="grpc"
|
||||
data-testid="presets-request-type-grpc"
|
||||
className="ml-4 cursor-pointer"
|
||||
type="radio"
|
||||
name="requestType"
|
||||
onChange={handleRequestTypeChange}
|
||||
value={PRESET_REQUEST_TYPES.GRPC}
|
||||
checked={(currentPresets.requestType || DEFAULT_PRESET_REQUEST_TYPE) === PRESET_REQUEST_TYPES.GRPC}
|
||||
value="grpc"
|
||||
checked={(currentPresets.requestType || 'http') === 'grpc'}
|
||||
/>
|
||||
<label htmlFor="grpc" className="ml-1 cursor-pointer select-none">
|
||||
gRPC
|
||||
@@ -90,13 +86,12 @@ const PresetsSettings = ({ collection }) => {
|
||||
|
||||
<input
|
||||
id="ws"
|
||||
data-testid="presets-request-type-ws"
|
||||
className="ml-4 cursor-pointer"
|
||||
type="radio"
|
||||
name="requestType"
|
||||
onChange={handleRequestTypeChange}
|
||||
value={PRESET_REQUEST_TYPES.WS}
|
||||
checked={(currentPresets.requestType || DEFAULT_PRESET_REQUEST_TYPE) === PRESET_REQUEST_TYPES.WS}
|
||||
value="ws"
|
||||
checked={(currentPresets.requestType || 'http') === 'ws'}
|
||||
/>
|
||||
<label htmlFor="ws" className="ml-1 cursor-pointer select-none">
|
||||
WebSocket
|
||||
@@ -111,7 +106,6 @@ const PresetsSettings = ({ collection }) => {
|
||||
<div className="flex items-center flex-grow input-container h-full">
|
||||
<input
|
||||
id="request-url"
|
||||
data-testid="presets-request-url"
|
||||
type="text"
|
||||
name="requestUrl"
|
||||
placeholder="Request URL"
|
||||
@@ -129,7 +123,7 @@ const PresetsSettings = ({ collection }) => {
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button type="button" size="sm" data-testid="presets-save-btn" onClick={handleSave}>
|
||||
<Button type="button" size="sm" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { updateCollectionRequestScript, updateCollectionResponseScript } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
import { flattenItems, isItemARequest } from 'utils/collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Button from 'ui/Button';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
|
||||
|
||||
const Script = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -23,58 +18,40 @@ const Script = ({ collection }) => {
|
||||
const requestScript = collection.draft?.root ? get(collection, 'draft.root.request.script.req', '') : get(collection, 'root.request.script.req', '');
|
||||
const responseScript = collection.draft?.root ? get(collection, 'draft.root.request.script.res', '') : get(collection, 'root.request.script.res', '');
|
||||
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const focusedTab = find(tabs, (t) => t.uid === collection.uid);
|
||||
const scriptPaneTab = focusedTab?.scriptPaneTab;
|
||||
|
||||
const getDefaultTab = () => {
|
||||
// Default to post-response if pre-request script is empty
|
||||
const getInitialTab = () => {
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
return hasPreRequestScript ? 'pre-request' : 'post-response';
|
||||
};
|
||||
|
||||
const activeTab = scriptPaneTab || getDefaultTab();
|
||||
|
||||
const setActiveTab = (tab) => {
|
||||
dispatch(updateScriptPaneTab({ uid: collection.uid, scriptPaneTab: tab }));
|
||||
};
|
||||
const [activeTab, setActiveTab] = useState(getInitialTab);
|
||||
const prevCollectionUidRef = useRef(collection.uid);
|
||||
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const [preReqScroll, setPreReqScroll] = usePersistedState({ key: `collection-pre-req-scroll-${collection.uid}`, default: 0 });
|
||||
const [postResScroll, setPostResScroll] = usePersistedState({ key: `collection-post-res-scroll-${collection.uid}`, default: 0 });
|
||||
// Update active tab only when switching to a different collection
|
||||
useEffect(() => {
|
||||
if (prevCollectionUidRef.current !== collection.uid) {
|
||||
prevCollectionUidRef.current = collection.uid;
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
setActiveTab(hasPreRequestScript ? 'pre-request' : 'post-response');
|
||||
}
|
||||
}, [collection.uid, requestScript]);
|
||||
|
||||
// Refresh CodeMirror when tab becomes visible and restore scroll position.
|
||||
// CodeMirror's scrollTo() is silently ignored when the editor is inside a display:none container
|
||||
// (TabsContent hides inactive tabs via display:none). After refresh() recalculates layout, we re-apply scrollTo().
|
||||
// Refresh CodeMirror when tab becomes visible
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (activeTab === 'pre-request' && preRequestEditorRef.current?.editor) {
|
||||
preRequestEditorRef.current.editor.refresh();
|
||||
preRequestEditorRef.current.editor.scrollTo(null, preReqScroll);
|
||||
} else if (activeTab === 'post-response' && postResponseEditorRef.current?.editor) {
|
||||
postResponseEditorRef.current.editor.refresh();
|
||||
postResponseEditorRef.current.editor.scrollTo(null, postResScroll);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [activeTab]);
|
||||
|
||||
useFocusErrorLine({
|
||||
uid: collection.uid,
|
||||
editorRef: preRequestEditorRef,
|
||||
scriptPhase: 'pre-request',
|
||||
isVisible: activeTab === 'pre-request'
|
||||
});
|
||||
|
||||
useFocusErrorLine({
|
||||
uid: collection.uid,
|
||||
editorRef: postResponseEditorRef,
|
||||
scriptPhase: 'post-response',
|
||||
isVisible: activeTab === 'post-response'
|
||||
});
|
||||
|
||||
const onRequestScriptEdit = (value) => {
|
||||
dispatch(
|
||||
updateCollectionRequestScript({
|
||||
@@ -123,54 +100,34 @@ const Script = ({ collection }) => {
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pre-request" className="mt-2" dataTestId="collection-pre-request-script-editor">
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
docKey="collection-script:pre-request"
|
||||
value={requestScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onRequestScriptEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'bru']}
|
||||
initialScroll={preReqScroll}
|
||||
onScroll={setPreReqScroll}
|
||||
/>
|
||||
<AIAssist
|
||||
scriptType="pre-request"
|
||||
currentScript={requestScript || ''}
|
||||
onApply={onRequestScriptEdit}
|
||||
/>
|
||||
</div>
|
||||
<TabsContent value="pre-request" className="mt-2">
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
value={requestScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onRequestScriptEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'bru']}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="post-response" className="mt-2" dataTestId="collection-post-response-script-editor">
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
docKey="collection-script:post-response"
|
||||
value={responseScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onResponseScriptEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={postResScroll}
|
||||
onScroll={setPostResScroll}
|
||||
/>
|
||||
<AIAssist
|
||||
scriptType="post-response"
|
||||
currentScript={responseScript || ''}
|
||||
onApply={onResponseScriptEdit}
|
||||
/>
|
||||
</div>
|
||||
<TabsContent value="post-response" className="mt-2">
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
value={responseScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onResponseScriptEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.markdown-body {
|
||||
height: auto !important;
|
||||
overflow-y: visible !important;
|
||||
}
|
||||
div.tabs {
|
||||
div.tab {
|
||||
padding: 6px 0px;
|
||||
@@ -28,8 +24,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-weight: ${(props) =>
|
||||
props.theme.tabs.active.fontWeight} !important;
|
||||
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
|
||||
color: ${(props) => props.theme.tabs.active.color} !important;
|
||||
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
|
||||
}
|
||||
@@ -50,7 +45,7 @@ const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
input[type="radio"] {
|
||||
input[type='radio'] {
|
||||
cursor: pointer;
|
||||
accent-color: ${(props) => props.theme.primary.solid};
|
||||
}
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { updateCollectionTests } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Button from 'ui/Button';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
|
||||
|
||||
const Tests = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const testsEditorRef = useRef(null);
|
||||
const tests = collection.draft?.root ? get(collection, 'draft.root.request.tests', '') : get(collection, 'root.request.tests', '');
|
||||
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const [testsScroll, setTestsScroll] = usePersistedState({ key: `collection-tests-scroll-${collection.uid}`, default: 0 });
|
||||
|
||||
const onEdit = (value) => {
|
||||
dispatch(
|
||||
@@ -31,33 +26,20 @@ const Tests = ({ collection }) => {
|
||||
|
||||
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
|
||||
|
||||
useFocusErrorLine({
|
||||
uid: collection.uid,
|
||||
editorRef: testsEditorRef,
|
||||
scriptPhase: 'test'
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col h-full">
|
||||
<div className="text-xs mb-4 text-muted">These tests will run any time a request in this collection is sent.</div>
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={testsEditorRef}
|
||||
collection={collection}
|
||||
docKey="collection-tests"
|
||||
value={tests || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={testsScroll}
|
||||
onScroll={setTestsScroll}
|
||||
/>
|
||||
<AIAssist scriptType="tests" currentScript={tests || ''} onApply={onEdit} />
|
||||
</div>
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
value={tests || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button type="submit" size="sm" onClick={handleSave}>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import InfoTip from 'components/InfoTip';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
@@ -11,19 +10,9 @@ import toast from 'react-hot-toast';
|
||||
import { variableNameRegex } from 'utils/common/regex';
|
||||
import { setCollectionVars } from 'providers/ReduxStore/slices/collections/index';
|
||||
|
||||
const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => {
|
||||
const VarsTable = ({ collection, vars, varType }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
|
||||
// Get column widths from Redux
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
const collectionVarsWidths = focusedTab?.tableColumnWidths?.['collection-vars'] || {};
|
||||
|
||||
const handleColumnWidthsChange = (tableId, widths) => {
|
||||
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
|
||||
};
|
||||
|
||||
const onSave = () => dispatch(saveCollectionSettings(collection.uid));
|
||||
|
||||
@@ -79,15 +68,11 @@ const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => {
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<EditableTable
|
||||
tableId="collection-vars"
|
||||
columns={columns}
|
||||
rows={vars}
|
||||
onChange={handleVarsChange}
|
||||
defaultRow={defaultRow}
|
||||
getRowError={getRowError}
|
||||
columnWidths={collectionVarsWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('collection-vars', widths)}
|
||||
initialScroll={initialScroll}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React from 'react';
|
||||
import get from 'lodash/get';
|
||||
import VarsTable from './VarsTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Button from 'ui/Button';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
|
||||
const Vars = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -14,19 +12,15 @@ const Vars = ({ collection }) => {
|
||||
const responseVars = collection.draft?.root ? get(collection, 'draft.root.request.vars.res', []) : get(collection, 'root.request.vars.res', []);
|
||||
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
|
||||
|
||||
const wrapperRef = useRef(null);
|
||||
const [scroll, setScroll] = usePersistedState({ key: `collection-vars-scroll-${collection.uid}`, default: 0 });
|
||||
useTrackScroll({ ref: wrapperRef, selector: '.collection-settings-content', onChange: setScroll, initialValue: scroll });
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col" ref={wrapperRef}>
|
||||
<StyledWrapper className="w-full flex flex-col">
|
||||
<div className="flex-1">
|
||||
<div className="mb-3 title text-xs">Pre Request</div>
|
||||
<VarsTable collection={collection} vars={requestVars} varType="request" initialScroll={scroll} />
|
||||
<VarsTable collection={collection} vars={requestVars} varType="request" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="mt-3 mb-3 title text-xs">Post Response</div>
|
||||
<VarsTable collection={collection} vars={responseVars} varType="response" initialScroll={scroll} />
|
||||
<VarsTable collection={collection} vars={responseVars} varType="response" />
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Button type="submit" size="sm" onClick={handleSave}>
|
||||
|
||||
@@ -15,7 +15,6 @@ import StyledWrapper from './StyledWrapper';
|
||||
import Vars from './Vars/index';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
import Overview from './Overview/index';
|
||||
import { DEFAULT_PRESET_REQUEST_TYPE } from 'utils/common/constants';
|
||||
|
||||
const CollectionSettings = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -61,7 +60,7 @@ const CollectionSettings = ({ collection }) => {
|
||||
? get(collection, 'draft.brunoConfig.protobuf', {})
|
||||
: get(collection, 'brunoConfig.protobuf', {});
|
||||
const presets = collection.draft?.brunoConfig ? get(collection, 'draft.brunoConfig.presets', {}) : get(collection, 'brunoConfig.presets', {});
|
||||
const hasPresets = presets && ((presets.requestType && presets.requestType !== DEFAULT_PRESET_REQUEST_TYPE) || (presets.requestUrl && presets.requestUrl !== ''));
|
||||
const hasPresets = presets && presets.requestUrl !== '';
|
||||
|
||||
const getTabPanel = (tab) => {
|
||||
switch (tab) {
|
||||
@@ -107,47 +106,47 @@ const CollectionSettings = ({ collection }) => {
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col h-full relative px-4 py-4 overflow-hidden">
|
||||
<div className="flex flex-wrap items-center tabs" role="tablist">
|
||||
<div className={getTabClassname('overview')} role="tab" data-testid="collection-settings-tab-overview" onClick={() => setTab('overview')}>
|
||||
<div className={getTabClassname('overview')} role="tab" onClick={() => setTab('overview')}>
|
||||
Overview
|
||||
</div>
|
||||
<div className={getTabClassname('headers')} role="tab" data-testid="collection-settings-tab-headers" onClick={() => setTab('headers')}>
|
||||
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
|
||||
Headers
|
||||
{activeHeadersCount > 0 && <sup className="ml-1 font-medium">{activeHeadersCount}</sup>}
|
||||
</div>
|
||||
<div className={getTabClassname('vars')} role="tab" data-testid="collection-settings-tab-vars" onClick={() => setTab('vars')}>
|
||||
<div className={getTabClassname('vars')} role="tab" onClick={() => setTab('vars')}>
|
||||
Vars
|
||||
{activeVarsCount > 0 && <sup className="ml-1 font-medium">{activeVarsCount}</sup>}
|
||||
</div>
|
||||
<div className={getTabClassname('auth')} role="tab" data-testid="collection-settings-tab-auth" onClick={() => setTab('auth')}>
|
||||
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
|
||||
Auth
|
||||
{authMode !== 'none' && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('script')} role="tab" data-testid="collection-settings-tab-script" onClick={() => setTab('script')}>
|
||||
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
|
||||
Script
|
||||
{hasScripts && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('tests')} role="tab" data-testid="collection-settings-tab-tests" onClick={() => setTab('tests')}>
|
||||
<div className={getTabClassname('tests')} role="tab" onClick={() => setTab('tests')}>
|
||||
Tests
|
||||
{hasTests && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('presets')} role="tab" data-testid="collection-settings-tab-presets" onClick={() => setTab('presets')}>
|
||||
<div className={getTabClassname('presets')} role="tab" onClick={() => setTab('presets')}>
|
||||
Presets
|
||||
{hasPresets && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('proxy')} role="tab" data-testid="collection-settings-tab-proxy" onClick={() => setTab('proxy')}>
|
||||
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
|
||||
Proxy
|
||||
{Object.keys(proxyConfig).length > 0 && proxyEnabled && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('clientCert')} role="tab" data-testid="collection-settings-tab-clientCert" onClick={() => setTab('clientCert')}>
|
||||
<div className={getTabClassname('clientCert')} role="tab" onClick={() => setTab('clientCert')}>
|
||||
Client Certificates
|
||||
{clientCertConfig.length > 0 && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('protobuf')} role="tab" data-testid="collection-settings-tab-protobuf" onClick={() => setTab('protobuf')}>
|
||||
<div className={getTabClassname('protobuf')} role="tab" onClick={() => setTab('protobuf')}>
|
||||
Protobuf
|
||||
{protobufConfig.protoFiles && protobufConfig.protoFiles.length > 0 && <StatusDot />}
|
||||
</div>
|
||||
</div>
|
||||
<section className="collection-settings-content mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
|
||||
<section className="mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -69,22 +69,13 @@ const StyledWrapper = styled.div`
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
min-height: 0; /* Important for proper flex behavior */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.col-separator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: ${(props) => props.theme.console.border};
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.requests-header {
|
||||
display: grid;
|
||||
padding: 0;
|
||||
grid-template-columns: 80px 80px 150px 1fr 100px 80px 80px;
|
||||
gap: 12px;
|
||||
padding: 4px 16px;
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
border-bottom: 1px solid ${(props) => props.theme.console.border};
|
||||
font-size: 10px;
|
||||
@@ -92,39 +83,6 @@ const StyledWrapper = styled.div`
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.header-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:first-child {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
}
|
||||
|
||||
span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.requests-list {
|
||||
@@ -136,7 +94,9 @@ const StyledWrapper = styled.div`
|
||||
|
||||
.request-row {
|
||||
display: grid;
|
||||
padding: 0;
|
||||
grid-template-columns: 80px 80px 150px 1fr 100px 80px 80px;
|
||||
gap: 12px;
|
||||
padding: 2px 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
@@ -147,19 +107,12 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
&.selected {
|
||||
padding-left: 13px;
|
||||
background: ${(props) => props.theme.console.logHoverBg};
|
||||
box-shadow: inset 3px 0 0 ${(props) => props.theme.console.checkboxColor};
|
||||
border-left: 3px solid ${(props) => props.theme.console.checkboxColor};
|
||||
}
|
||||
}
|
||||
|
||||
.request-method {
|
||||
padding: 2px 8px 2px 16px;
|
||||
}
|
||||
|
||||
.request-status {
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.method-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -175,7 +128,6 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.request-domain {
|
||||
padding: 2px 8px;
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -183,7 +135,6 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.request-path {
|
||||
padding: 2px 8px;
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -192,26 +143,19 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.request-time {
|
||||
padding: 2px 8px;
|
||||
color: ${(props) => props.theme.console.timestampColor};
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
}
|
||||
|
||||
.request-duration {
|
||||
padding: 2px 8px;
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.request-size {
|
||||
padding: 2px 8px;
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
|
||||
@@ -1,26 +1,12 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
IconNetwork,
|
||||
IconArrowUp,
|
||||
IconArrowDown
|
||||
IconNetwork
|
||||
} from '@tabler/icons';
|
||||
import {
|
||||
setSelectedRequest
|
||||
} from 'providers/ReduxStore/slices/logs';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { getGridTemplate, getSeparatorPositions, sortRequests } from './utils';
|
||||
|
||||
// TODO: Columns will be resizable in the future, so width can be null (for auto) or a number (for fixed width)
|
||||
const COLUMNS = [
|
||||
{ key: 'method', label: 'Method', width: 90, align: 'left' },
|
||||
{ key: 'status', label: 'Status', width: 80, align: 'left' },
|
||||
{ key: 'domain', label: 'Domain', width: 200, align: 'left' },
|
||||
{ key: 'path', label: 'Path', width: null, align: 'left' },
|
||||
{ key: 'time', label: 'Time', width: 100, align: 'left' },
|
||||
{ key: 'duration', label: 'Duration', width: 120, align: 'right' },
|
||||
{ key: 'size', label: 'Size', width: 80, align: 'right' }
|
||||
];
|
||||
|
||||
const MethodBadge = ({ method }) => {
|
||||
const methodLower = method?.toLowerCase() || 'get';
|
||||
@@ -42,7 +28,7 @@ const StatusBadge = ({ status, statusCode }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const RequestRow = ({ request, isSelected, onClick, gridTemplateColumns }) => {
|
||||
const RequestRow = ({ request, isSelected, onClick }) => {
|
||||
const { data } = request;
|
||||
const { request: req, response: res, timestamp } = data;
|
||||
|
||||
@@ -96,9 +82,6 @@ const RequestRow = ({ request, isSelected, onClick, gridTemplateColumns }) => {
|
||||
<div
|
||||
className={`request-row ${isSelected ? 'selected' : ''}`}
|
||||
onClick={onClick}
|
||||
style={{ gridTemplateColumns }}
|
||||
data-testid="network-request-row"
|
||||
|
||||
>
|
||||
<div className="request-method">
|
||||
<MethodBadge method={req?.method} />
|
||||
@@ -133,9 +116,6 @@ const RequestRow = ({ request, isSelected, onClick, gridTemplateColumns }) => {
|
||||
|
||||
const NetworkTab = () => {
|
||||
const dispatch = useDispatch();
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: null });
|
||||
const gridTemplateColumns = useMemo(() => getGridTemplate(COLUMNS), []);
|
||||
const separatorPositions = useMemo(() => getSeparatorPositions(COLUMNS), []);
|
||||
const { networkFilters, selectedRequest } = useSelector((state) => state.logs);
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
|
||||
@@ -170,21 +150,6 @@ const NetworkTab = () => {
|
||||
dispatch(setSelectedRequest(request));
|
||||
};
|
||||
|
||||
const handleHeaderClick = (key) => {
|
||||
setSortConfig((prev) => {
|
||||
// If clicking a different column, start with ascending sort
|
||||
if (prev.key !== key) return { key, direction: 'asc' };
|
||||
|
||||
if (prev.direction === 'asc') return { key, direction: 'desc' };
|
||||
return { key: null, direction: null };
|
||||
});
|
||||
};
|
||||
|
||||
const sortedRequests = useMemo(
|
||||
() => sortRequests(filteredRequests, sortConfig.key, sortConfig.direction),
|
||||
[filteredRequests, sortConfig]
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="network-content">
|
||||
@@ -196,45 +161,26 @@ const NetworkTab = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="requests-container">
|
||||
<div className="requests-header" style={{ gridTemplateColumns }}>
|
||||
{COLUMNS.map((col) => (
|
||||
<div
|
||||
key={col.key}
|
||||
className={`header-cell${col.align === 'right' ? ' text-right' : ''}`}
|
||||
onClick={() => handleHeaderClick(col.key)}
|
||||
data-testid={`network-header-${col.key}`}
|
||||
>
|
||||
<span title={col.label}>{col.label}</span>
|
||||
{sortConfig.key === col.key && (
|
||||
sortConfig.direction === 'asc'
|
||||
? <IconArrowUp size={14} strokeWidth={2} data-testid="sort-icon-asc" />
|
||||
: <IconArrowDown size={14} strokeWidth={2} data-testid="sort-icon-desc" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="requests-header">
|
||||
<div>Method</div>
|
||||
<div>Status</div>
|
||||
<div>Domain</div>
|
||||
<div>Path</div>
|
||||
<div>Time</div>
|
||||
<div className="text-right">Duration</div>
|
||||
<div className="text-right">Size</div>
|
||||
</div>
|
||||
|
||||
<div className="requests-list">
|
||||
{sortedRequests.map((request, index) => (
|
||||
{filteredRequests.map((request, index) => (
|
||||
<RequestRow
|
||||
key={`${request.collectionUid}-${request.itemUid}-${request.timestamp}-${index}`}
|
||||
request={request}
|
||||
isSelected={selectedRequest?.timestamp === request.timestamp && selectedRequest?.itemUid === request.itemUid}
|
||||
onClick={() => handleRequestClick(request)}
|
||||
gridTemplateColumns={gridTemplateColumns}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{separatorPositions.map((pos, i) =>
|
||||
pos ? (
|
||||
<div
|
||||
key={i}
|
||||
className="col-separator"
|
||||
style={'left' in pos ? { left: `${pos.left}px` } : { right: `${pos.right}px` }}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { ThemeProvider } from 'providers/Theme';
|
||||
import NetworkTab from './index';
|
||||
|
||||
const makeRequest = (overrides = {}) => ({
|
||||
type: 'request',
|
||||
timestamp: overrides.timestamp ?? 1000,
|
||||
collectionUid: overrides.collectionUid ?? 'col-1',
|
||||
itemUid: overrides.itemUid ?? 'item-1',
|
||||
collectionName: 'Test Collection',
|
||||
data: {
|
||||
request: {
|
||||
method: overrides.method ?? 'GET',
|
||||
url: overrides.url ?? 'https://example.com/api/users'
|
||||
},
|
||||
response: {
|
||||
status: overrides.status ?? 200,
|
||||
statusCode: overrides.statusCode ?? 200,
|
||||
// Use 'in' check so callers can explicitly pass undefined to test missing-value behaviour
|
||||
...('duration' in overrides ? { duration: overrides.duration } : { duration: 100 }),
|
||||
...('size' in overrides ? { size: overrides.size } : { size: 512 })
|
||||
},
|
||||
timestamp: overrides.timestamp ?? 1000
|
||||
}
|
||||
});
|
||||
|
||||
const ALL_FILTERS = { GET: true, POST: true, PUT: true, DELETE: true, PATCH: true, HEAD: true, OPTIONS: true };
|
||||
|
||||
const renderNetworkTab = (requests = []) => {
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
collections: (state = {
|
||||
collections: [{
|
||||
uid: 'col-1',
|
||||
name: 'Test Collection',
|
||||
timeline: requests
|
||||
}]
|
||||
}) => state,
|
||||
logs: (state = {
|
||||
networkFilters: ALL_FILTERS,
|
||||
selectedRequest: null
|
||||
}) => state
|
||||
}
|
||||
});
|
||||
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<ThemeProvider>
|
||||
<NetworkTab />
|
||||
</ThemeProvider>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('sort state cycle', () => {
|
||||
const requests = [
|
||||
makeRequest({ itemUid: 'a', method: 'GET' }),
|
||||
makeRequest({ itemUid: 'b', method: 'POST' })
|
||||
];
|
||||
|
||||
it('shows no sort icon by default', () => {
|
||||
renderNetworkTab(requests);
|
||||
expect(screen.queryByTestId('sort-icon-asc')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('sort-icon-desc')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('first click on a column shows ascending icon', () => {
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
expect(screen.getByTestId('sort-icon-asc')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('sort-icon-desc')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('second click on same column shows descending icon', () => {
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
expect(screen.getByTestId('sort-icon-desc')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('sort-icon-asc')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('third click on same column clears sort', () => {
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
expect(screen.queryByTestId('sort-icon-asc')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('sort-icon-desc')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking a different column resets to ascending on the new column', () => {
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
fireEvent.click(screen.getByTestId('network-header-method')); // now desc
|
||||
fireEvent.click(screen.getByTestId('network-header-status')); // switch column
|
||||
// Should show asc on status, not desc
|
||||
expect(screen.getByTestId('sort-icon-asc')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('sort-icon-desc')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sort icon only appears on the active column', () => {
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-duration'));
|
||||
// Only one icon total
|
||||
expect(screen.getAllByTestId('sort-icon-asc')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sort results', () => {
|
||||
const getRowMethods = () =>
|
||||
screen.getAllByTestId('network-request-row').map((row) =>
|
||||
row.querySelector('.method-badge')?.textContent
|
||||
);
|
||||
|
||||
it('sorts by method ascending (A → Z)', () => {
|
||||
const requests = [
|
||||
makeRequest({ itemUid: '1', method: 'POST' }),
|
||||
makeRequest({ itemUid: '2', method: 'GET' }),
|
||||
makeRequest({ itemUid: '3', method: 'DELETE' })
|
||||
];
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
expect(getRowMethods()).toEqual(['DELETE', 'GET', 'POST']);
|
||||
});
|
||||
|
||||
it('sorts by method descending (Z → A)', () => {
|
||||
const requests = [
|
||||
makeRequest({ itemUid: '1', method: 'POST' }),
|
||||
makeRequest({ itemUid: '2', method: 'GET' }),
|
||||
makeRequest({ itemUid: '3', method: 'DELETE' })
|
||||
];
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
expect(getRowMethods()).toEqual(['POST', 'GET', 'DELETE']);
|
||||
});
|
||||
|
||||
it('sorts by status ascending', () => {
|
||||
const requests = [
|
||||
makeRequest({ itemUid: '1', statusCode: 500 }),
|
||||
makeRequest({ itemUid: '2', statusCode: 200 }),
|
||||
makeRequest({ itemUid: '3', statusCode: 404 })
|
||||
];
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-status'));
|
||||
const rows = screen.getAllByTestId('network-request-row');
|
||||
const statuses = rows.map((r) => r.querySelector('.status-badge')?.textContent);
|
||||
expect(statuses).toEqual(['200', '404', '500']);
|
||||
});
|
||||
|
||||
it('sorts mixed-case methods case-insensitively', () => {
|
||||
const requests = [
|
||||
makeRequest({ itemUid: '1', method: 'post' }),
|
||||
makeRequest({ itemUid: '2', method: 'GET' }),
|
||||
makeRequest({ itemUid: '3', method: 'delete' })
|
||||
];
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
// MethodBadge always renders uppercase; sort order should treat 'post' == 'POST'
|
||||
expect(getRowMethods()).toEqual(['DELETE', 'GET', 'POST']);
|
||||
});
|
||||
|
||||
it('preserves insertion order when sort is cleared', () => {
|
||||
const requests = [
|
||||
makeRequest({ itemUid: '1', method: 'POST' }),
|
||||
makeRequest({ itemUid: '2', method: 'GET' }),
|
||||
makeRequest({ itemUid: '3', method: 'DELETE' })
|
||||
];
|
||||
renderNetworkTab(requests);
|
||||
// Sort then clear
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
expect(getRowMethods()).toEqual(['POST', 'GET', 'DELETE']);
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
export const getGridTemplate = (columns) =>
|
||||
columns.map((c) => (c.width ? `${c.width}px` : '1fr')).join(' ');
|
||||
|
||||
export const getSeparatorPositions = (columns) => {
|
||||
const n = columns.length;
|
||||
const positions = new Array(n - 1).fill(null);
|
||||
|
||||
let leftOffset = 0;
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
if (columns[i].width === null) break;
|
||||
leftOffset += columns[i].width;
|
||||
positions[i] = { left: leftOffset };
|
||||
}
|
||||
|
||||
let rightOffset = 0;
|
||||
for (let i = n - 1; i > 0; i--) {
|
||||
if (columns[i].width === null) break;
|
||||
rightOffset += columns[i].width;
|
||||
if (positions[i - 1] === null) {
|
||||
positions[i - 1] = { right: rightOffset };
|
||||
}
|
||||
}
|
||||
|
||||
return positions;
|
||||
};
|
||||
|
||||
export const getSortValue = (request, key) => {
|
||||
const { request: req, response: res, timestamp } = request.data;
|
||||
switch (key) {
|
||||
case 'method': return req?.method?.toUpperCase() ?? '';
|
||||
case 'status': return res?.statusCode || res?.status || 0;
|
||||
case 'domain': {
|
||||
try { return new URL(req?.url || '').hostname; } catch { return req?.url || ''; }
|
||||
}
|
||||
case 'path': {
|
||||
try {
|
||||
const u = new URL(req?.url || '');
|
||||
return u.pathname + u.search;
|
||||
} catch { return req?.url || ''; }
|
||||
}
|
||||
case 'time': return timestamp || 0;
|
||||
case 'duration': return res?.duration || 0;
|
||||
case 'size': return res?.size || 0;
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const sortRequests = (requests, key, direction) => {
|
||||
if (!key || !direction) return requests;
|
||||
return [...requests].sort((a, b) => {
|
||||
const valueA = getSortValue(a, key);
|
||||
const valueB = getSortValue(b, key);
|
||||
if (valueA < valueB) return direction === 'asc' ? -1 : 1;
|
||||
if (valueA > valueB) return direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
@@ -4,8 +4,11 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: ${(props) => props.theme.console.contentBg};
|
||||
border-left: 1px solid ${(props) => props.theme.console.border};
|
||||
min-width: 400px;
|
||||
max-width: 600px;
|
||||
width: 40%;
|
||||
overflow: hidden;
|
||||
|
||||
.panel-header {
|
||||
|
||||
@@ -144,41 +144,6 @@ const StyledWrapper = styled.div`
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.details-panel-wrapper {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
div.details-drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
background-color: transparent;
|
||||
width: 6px;
|
||||
position: absolute;
|
||||
left: -3px;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
div.drag-request-border {
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
border-left: solid 1px ${(props) => props.theme.sidebar.dragbar.border};
|
||||
}
|
||||
|
||||
&:hover div.drag-request-border {
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
border-left: solid 1px ${(props) => props.theme.sidebar.dragbar.activeBorder};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.action-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -113,7 +113,7 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
|
||||
|
||||
return (
|
||||
<StyledSessionList>
|
||||
{sessions.map((session, idx) => {
|
||||
{sessions.map((session) => {
|
||||
const { name } = getSessionDisplayInfo(session);
|
||||
return (
|
||||
<ToolHint
|
||||
@@ -125,7 +125,6 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
|
||||
>
|
||||
<div
|
||||
className={`session-list-item ${activeSessionId === session.sessionId ? 'active' : ''}`}
|
||||
data-testid={`session-list-${idx}`}
|
||||
onClick={() => onSelectSession(session.sessionId)}
|
||||
>
|
||||
<div className="session-name">
|
||||
@@ -134,7 +133,6 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
|
||||
</div>
|
||||
<div
|
||||
className="session-close-btn"
|
||||
data-testid={`session-close-${idx}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCloseSession(session.sessionId);
|
||||
|
||||
@@ -23,8 +23,7 @@ import {
|
||||
setActiveTab,
|
||||
clearDebugErrors,
|
||||
updateNetworkFilter,
|
||||
toggleAllNetworkFilters,
|
||||
updateRequestDetailsPanelWidth
|
||||
toggleAllNetworkFilters
|
||||
} from 'providers/ReduxStore/slices/logs';
|
||||
|
||||
import NetworkTab from './NetworkTab';
|
||||
@@ -34,10 +33,6 @@ import RequestDetailsPanel from './RequestDetailsPanel';
|
||||
import ErrorDetailsPanel from './ErrorDetailsPanel';
|
||||
import Performance from '../Performance';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { useResizablePanel } from 'hooks/useResizablePanel';
|
||||
|
||||
const MIN_DETAILS_PANEL_WIDTH = 280;
|
||||
const MAX_DETAILS_PANEL_WIDTH = 800;
|
||||
|
||||
const LogIcon = ({ type }) => {
|
||||
const iconProps = { size: 16, strokeWidth: 1.5 };
|
||||
@@ -386,17 +381,8 @@ const Console = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { logs, filters, activeTab, selectedRequest, selectedError, networkFilters, debugErrors } = useSelector((state) => state.logs);
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
const savedDetailsPanelWidth = useSelector((state) => state.logs.requestDetailsPanelWidth);
|
||||
const consoleRef = useRef(null);
|
||||
|
||||
const { width: detailsPanelWidth, handleDragStart: handleDetailsPanelDragStart } = useResizablePanel({
|
||||
initialWidth: savedDetailsPanelWidth,
|
||||
minWidth: MIN_DETAILS_PANEL_WIDTH,
|
||||
maxWidth: MAX_DETAILS_PANEL_WIDTH,
|
||||
direction: 'right',
|
||||
onResizeEnd: (newWidth) => dispatch(updateRequestDetailsPanelWidth({ requestDetailsPanelWidth: newWidth }))
|
||||
});
|
||||
|
||||
const logCounts = logs.reduce((counts, log) => {
|
||||
counts[log.type] = (counts[log.type] || 0) + 1;
|
||||
return counts;
|
||||
@@ -628,16 +614,7 @@ const Console = () => {
|
||||
<div className="network-main">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
<div className="details-panel-wrapper" style={{ width: detailsPanelWidth }}>
|
||||
<div
|
||||
className="details-drag-handle"
|
||||
onMouseDown={handleDetailsPanelDragStart}
|
||||
data-testid="details-panel-drag-handle"
|
||||
>
|
||||
<div className="drag-request-border" />
|
||||
</div>
|
||||
<RequestDetailsPanel />
|
||||
</div>
|
||||
<RequestDetailsPanel />
|
||||
</div>
|
||||
) : activeTab === 'debug' && selectedError ? (
|
||||
<div className="debug-with-details">
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
.editing-mode {
|
||||
cursor: pointer;
|
||||
position: sticky;
|
||||
z-index: 10;
|
||||
top: 0;
|
||||
background: ${(props) => props.theme.bg};
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
.markdown-body {
|
||||
height: auto !important;
|
||||
overflow-y: visible !important;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,34 +1,23 @@
|
||||
import 'github-markdown-css/github-markdown.css';
|
||||
import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import { updateRequestDocs } from 'providers/ReduxStore/slices/collections';
|
||||
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useRef } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import Markdown from 'components/MarkDown';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
|
||||
const Documentation = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { displayedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const isEditing = focusedTab?.docsEditing || false;
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const docs = item.draft ? get(item, 'draft.request.docs') : get(item, 'request.docs');
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const wrapperRef = useRef(null);
|
||||
const [scroll, setScroll] = usePersistedState({ key: `request-docs-scroll-${item.uid}`, default: 0 });
|
||||
useTrackScroll({ ref: wrapperRef, onChange: setScroll, enabled: !isEditing, initialValue: scroll });
|
||||
|
||||
const toggleViewMode = () => {
|
||||
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
|
||||
setIsEditing((prev) => !prev);
|
||||
};
|
||||
|
||||
const onEdit = (value) => {
|
||||
@@ -48,7 +37,7 @@ const Documentation = ({ item, collection }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col gap-y-1 h-full w-full relative" ref={wrapperRef}>
|
||||
<StyledWrapper className="flex flex-col gap-y-1 h-full w-full relative">
|
||||
<div className="editing-mode" role="tab" onClick={toggleViewMode}>
|
||||
{isEditing ? 'Preview' : 'Edit'}
|
||||
</div>
|
||||
@@ -63,8 +52,6 @@ const Documentation = ({ item, collection }) => {
|
||||
onEdit={onEdit}
|
||||
onSave={onSave}
|
||||
mode="application/text"
|
||||
initialScroll={scroll}
|
||||
onScroll={setScroll}
|
||||
/>
|
||||
) : (
|
||||
<Markdown collectionPath={collection.pathname} onDoubleClick={toggleViewMode} content={docs} />
|
||||
|
||||
@@ -179,17 +179,6 @@ const Wrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb-collapsed-dropdown {
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.breadcrumb-collapsed-item {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dropdown-separator {
|
||||
height: 1px;
|
||||
background-color: ${(props) => props.theme.dropdown.separator};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: block;
|
||||
width: 100%;
|
||||
isolation: isolate;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
&.is-resizing {
|
||||
cursor: col-resize !important;
|
||||
@@ -11,9 +12,9 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow: auto;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
border: solid 1px ${(props) => props.theme.border.border0};
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
table {
|
||||
@@ -62,7 +63,7 @@ const StyledWrapper = styled.div`
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
z-index: 10;
|
||||
z-index: 100;
|
||||
|
||||
&:hover,
|
||||
&.resizing {
|
||||
@@ -79,8 +80,6 @@ const StyledWrapper = styled.div`
|
||||
|
||||
tbody {
|
||||
tr {
|
||||
height: 35px;
|
||||
max-height: 35px;
|
||||
transition: background 0.1s ease;
|
||||
|
||||
&:last-child td {
|
||||
@@ -88,8 +87,6 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
td {
|
||||
height: 35px;
|
||||
max-height: 35px;
|
||||
padding: 1px 10px !important;
|
||||
border-top: none !important;
|
||||
border-left: none !important;
|
||||
@@ -99,23 +96,17 @@ const StyledWrapper = styled.div`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
box-sizing: border-box;
|
||||
|
||||
> div:not(.drag-handle) {
|
||||
height: 33px;
|
||||
max-height: 33px;
|
||||
overflow: hidden;
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* Handle CodeMirror editors overflow */
|
||||
.cm-editor {
|
||||
max-width: 100%;
|
||||
height: 33px !important;
|
||||
max-height: 33px !important;
|
||||
|
||||
.cm-scroller {
|
||||
overflow: hidden !important;
|
||||
max-height: 33px;
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
@@ -194,23 +185,12 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.icon-grip,
|
||||
.icon-minus {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr:hover .drag-handle,
|
||||
tbody tr.drag-over .drag-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
select {
|
||||
background-color: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
@@ -1,52 +1,12 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { TableVirtuoso } from 'react-virtuoso';
|
||||
import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react';
|
||||
import { IconTrash, IconAlertCircle, IconGripVertical, IconMinusVertical } from '@tabler/icons';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { uuid } from 'utils/common';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const MIN_COLUMN_WIDTH = 80;
|
||||
const ROW_HEIGHT = 35;
|
||||
|
||||
const findScrollParent = (element) => {
|
||||
let parent = element?.parentElement;
|
||||
while (parent) {
|
||||
const { overflowY } = getComputedStyle(parent);
|
||||
if (overflowY === 'auto' || overflowY === 'scroll') return parent;
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const TableRow = React.memo(
|
||||
({ children, item, context, ...rest }) => {
|
||||
const rowIndex = Number(rest['data-item-index']);
|
||||
const { reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, onDragStart, onDragOver, onDrop, onDragEnd, onDragLeave } = context;
|
||||
const isEmpty = isLastEmptyRow(item, rowIndex);
|
||||
const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount;
|
||||
const isDragOver = canDrag && dragOverRow === rowIndex;
|
||||
const existingClass = rest.className || '';
|
||||
const className = isDragOver ? `${existingClass} drag-over`.trim() : existingClass;
|
||||
|
||||
return (
|
||||
<tr
|
||||
{...rest}
|
||||
className={className}
|
||||
draggable={canDrag}
|
||||
onDragStart={canDrag ? (e) => onDragStart(e, rowIndex) : undefined}
|
||||
onDragOver={canDrag ? (e) => onDragOver(e, rowIndex) : undefined}
|
||||
onDragLeave={canDrag ? (e) => onDragLeave(e, rowIndex) : undefined}
|
||||
onDrop={canDrag ? (e) => onDrop(e, rowIndex) : undefined}
|
||||
onDragEnd={canDrag ? onDragEnd : undefined}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const EditableTable = ({
|
||||
tableId, // Not being used kept to maintain uniqueness & pass similar in onColumnWidthsChange
|
||||
columns,
|
||||
rows,
|
||||
onChange,
|
||||
@@ -60,32 +20,20 @@ const EditableTable = ({
|
||||
reorderable = false,
|
||||
onReorder,
|
||||
showAddRow = true,
|
||||
testId = 'editable-table',
|
||||
columnWidths,
|
||||
initialScroll = 0,
|
||||
onColumnWidthsChange
|
||||
testId = 'editable-table'
|
||||
}) => {
|
||||
const wrapperRef = useRef(null);
|
||||
const virtuosoRef = useRef(null);
|
||||
const tableRef = useRef(null);
|
||||
const emptyRowUidRef = useRef(null);
|
||||
const prevRowCountRef = useRef(0);
|
||||
const [hoveredRow, setHoveredRow] = useState(null);
|
||||
const [resizing, setResizing] = useState(null);
|
||||
const [tableHeight, setTableHeight] = useState(0);
|
||||
const [scrollParent, setScrollParent] = useState(null);
|
||||
const [dragOverRow, setDragOverRow] = useState(null);
|
||||
const widths = columnWidths || {};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setScrollParent(findScrollParent(wrapperRef.current));
|
||||
}, []);
|
||||
|
||||
const handleTotalHeightChanged = useCallback((h) => {
|
||||
setTableHeight(h);
|
||||
}, []);
|
||||
|
||||
const handleColumnWidthsChange = useCallback((newWidths) => {
|
||||
onColumnWidthsChange?.(newWidths);
|
||||
}, [onColumnWidthsChange]);
|
||||
const [columnWidths, setColumnWidths] = useState(() => {
|
||||
const initialWidths = {};
|
||||
columns.forEach((col) => {
|
||||
initialWidths[col.key] = col.width || 'auto';
|
||||
});
|
||||
return initialWidths;
|
||||
});
|
||||
|
||||
const handleResizeStart = useCallback((e, columnKey) => {
|
||||
e.preventDefault();
|
||||
@@ -111,18 +59,16 @@ const EditableTable = ({
|
||||
const maxShrink = startWidth - MIN_COLUMN_WIDTH;
|
||||
const clampedDiff = Math.max(-maxShrink, Math.min(maxGrow, diff));
|
||||
|
||||
const newWidths = {
|
||||
...widths,
|
||||
setColumnWidths((prev) => ({
|
||||
...prev,
|
||||
[columnKey]: `${startWidth + clampedDiff}px`,
|
||||
[nextColumnKey]: `${nextColumnStartWidth - clampedDiff}px`
|
||||
};
|
||||
|
||||
handleColumnWidthsChange(newWidths);
|
||||
}));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
// Convert pixel widths to percentages for responsive scaling
|
||||
const table = wrapperRef.current?.querySelector('table');
|
||||
const table = tableRef.current?.querySelector('table');
|
||||
if (table) {
|
||||
const tableWidth = table.offsetWidth;
|
||||
const headerCells = table.querySelectorAll('thead td');
|
||||
@@ -142,7 +88,7 @@ const EditableTable = ({
|
||||
});
|
||||
|
||||
if (Object.keys(newWidths).length > 0) {
|
||||
handleColumnWidthsChange({ ...widths, ...newWidths });
|
||||
setColumnWidths((prev) => ({ ...prev, ...newWidths }));
|
||||
}
|
||||
}
|
||||
setResizing(null);
|
||||
@@ -152,11 +98,28 @@ const EditableTable = ({
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}, [columns, showCheckbox, widths, handleColumnWidthsChange]);
|
||||
}, [columns, showCheckbox]);
|
||||
|
||||
// Track table height for resize handles
|
||||
useEffect(() => {
|
||||
const table = tableRef.current?.querySelector('table');
|
||||
if (!table) return;
|
||||
|
||||
const updateHeight = () => {
|
||||
setTableHeight(table.offsetHeight);
|
||||
};
|
||||
|
||||
updateHeight();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateHeight);
|
||||
resizeObserver.observe(table);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [rows.length]);
|
||||
|
||||
const getColumnWidth = useCallback((column) => {
|
||||
return widths[column.key] || column.width || 'auto';
|
||||
}, [widths]);
|
||||
return columnWidths[column.key] || column.width || 'auto';
|
||||
}, [columnWidths]);
|
||||
|
||||
const createEmptyRow = useCallback(() => {
|
||||
const newUid = uuid();
|
||||
@@ -168,17 +131,6 @@ const EditableTable = ({
|
||||
};
|
||||
}, [defaultRow, checkboxKey]);
|
||||
|
||||
const hasAnyValue = useCallback((row) => {
|
||||
for (const col of columns) {
|
||||
const val = col.getValue ? col.getValue(row) : row[col.key];
|
||||
const defaultVal = defaultRow[col.key];
|
||||
if (val && val !== defaultVal && (typeof val !== 'string' || val.trim() !== '')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [columns, defaultRow]);
|
||||
|
||||
const rowsWithEmpty = useMemo(() => {
|
||||
if (!showAddRow) {
|
||||
return rows;
|
||||
@@ -188,11 +140,16 @@ const EditableTable = ({
|
||||
return [createEmptyRow()];
|
||||
}
|
||||
|
||||
// If the last row is already empty (e.g. a stray empty row loaded from a
|
||||
// pre-existing file), don't append another one — otherwise the table would
|
||||
// render two empty rows at the bottom on the initial render.
|
||||
if (!hasAnyValue(rows[rows.length - 1])) {
|
||||
return rows;
|
||||
const lastRow = rows[rows.length - 1];
|
||||
const keyColumn = columns.find((col) => col.isKeyField);
|
||||
|
||||
if (keyColumn) {
|
||||
const lastRowKeyValue = keyColumn.getValue ? keyColumn.getValue(lastRow) : lastRow[keyColumn.key];
|
||||
const isLastRowEmpty = !lastRowKeyValue || (typeof lastRowKeyValue === 'string' && lastRowKeyValue.trim() === '');
|
||||
|
||||
if (isLastRowEmpty) {
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
if (!emptyRowUidRef.current || rows.some((r) => r.uid === emptyRowUidRef.current)) {
|
||||
@@ -204,45 +161,69 @@ const EditableTable = ({
|
||||
[checkboxKey]: true,
|
||||
...defaultRow
|
||||
}];
|
||||
}, [rows, columns, defaultRow, checkboxKey, createEmptyRow, hasAnyValue, showAddRow]);
|
||||
}, [rows, columns, defaultRow, checkboxKey, createEmptyRow, showAddRow]);
|
||||
|
||||
// A row is empty when none of its columns hold a value — the single source of
|
||||
// truth used everywhere (memo guard, persistence filter, last-row rendering).
|
||||
const isEmptyRow = useCallback((row) => !hasAnyValue(row), [hasAnyValue]);
|
||||
const isEmptyRow = useCallback((row) => {
|
||||
const keyColumn = columns.find((col) => col.isKeyField);
|
||||
if (!keyColumn) return false;
|
||||
|
||||
const value = keyColumn.getValue ? keyColumn.getValue(row) : row[keyColumn.key];
|
||||
return !value || (typeof value === 'string' && value.trim() === '');
|
||||
}, [columns]);
|
||||
|
||||
const isLastEmptyRow = useCallback((row, index) => {
|
||||
if (!showAddRow) return false;
|
||||
return index === rowsWithEmpty.length - 1 && isEmptyRow(row);
|
||||
}, [rowsWithEmpty.length, isEmptyRow, showAddRow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rowsWithEmpty.length > prevRowCountRef.current && prevRowCountRef.current > 0) {
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: rowsWithEmpty.length - 1,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
prevRowCountRef.current = rowsWithEmpty.length;
|
||||
}, [rowsWithEmpty.length]);
|
||||
|
||||
const handleValueChange = useCallback((rowUid, key, value) => {
|
||||
const rowIndex = rowsWithEmpty.findIndex((r) => r.uid === rowUid);
|
||||
if (rowIndex === -1) return;
|
||||
|
||||
const updatedRows = rowsWithEmpty.map((row) => {
|
||||
const currentRow = rowsWithEmpty[rowIndex];
|
||||
const isLast = rowIndex === rowsWithEmpty.length - 1;
|
||||
const wasEmpty = isEmptyRow(currentRow);
|
||||
|
||||
const keyColumn = columns.find((col) => col.isKeyField);
|
||||
const isKeyFieldChange = keyColumn && keyColumn.key === key;
|
||||
|
||||
let updatedRows = rowsWithEmpty.map((row) => {
|
||||
if (row.uid === rowUid) {
|
||||
return { ...row, [key]: value };
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
// Remove any fully-empty rows from the persisted data. The trailing empty
|
||||
// "add row" is re-added by the rowsWithEmpty memo, so there's always
|
||||
// exactly one empty row at the bottom and never a stray empty row above it.
|
||||
const result = showAddRow ? updatedRows.filter(hasAnyValue) : updatedRows;
|
||||
// Only add a new empty row when the key field is filled
|
||||
if (showAddRow && isLast && wasEmpty && isKeyFieldChange && value && value.trim() !== '') {
|
||||
emptyRowUidRef.current = uuid();
|
||||
updatedRows.push({
|
||||
uid: emptyRowUidRef.current,
|
||||
[checkboxKey]: true,
|
||||
...defaultRow
|
||||
});
|
||||
}
|
||||
|
||||
const hasAnyValue = (row) => {
|
||||
for (const col of columns) {
|
||||
const val = col.getValue ? col.getValue(row) : row[col.key];
|
||||
const defaultVal = defaultRow[col.key];
|
||||
if (val && val !== defaultVal && (typeof val !== 'string' || val.trim() !== '')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const result = updatedRows.filter((row, i) => {
|
||||
if (showAddRow && i === updatedRows.length - 1) {
|
||||
return hasAnyValue(row);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
onChange(result);
|
||||
}, [rowsWithEmpty, hasAnyValue, onChange, showAddRow]);
|
||||
}, [rowsWithEmpty, columns, onChange, checkboxKey, defaultRow, isEmptyRow, showAddRow]);
|
||||
|
||||
const handleCheckboxChange = useCallback((rowUid, checked) => {
|
||||
handleValueChange(rowUid, checkboxKey, checked);
|
||||
@@ -261,31 +242,28 @@ const EditableTable = ({
|
||||
const handleDragOver = useCallback((e, index) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDragOverRow((prev) => (prev === index ? prev : index));
|
||||
setHoveredRow(index);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e, index) => {
|
||||
if (e.currentTarget.contains(e.relatedTarget)) return;
|
||||
setDragOverRow((prev) => (prev === index ? null : prev));
|
||||
}, []);
|
||||
|
||||
const reorderableRowCount = showAddRow ? rowsWithEmpty.length - 1 : rowsWithEmpty.length;
|
||||
|
||||
const handleDrop = useCallback((e, toIndex) => {
|
||||
e.preventDefault();
|
||||
setDragOverRow(null);
|
||||
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10);
|
||||
if (fromIndex === toIndex || !onReorder) return;
|
||||
const reorderableRows = showAddRow ? rowsWithEmpty.slice(0, -1) : rowsWithEmpty;
|
||||
const updatedOrder = [...reorderableRows];
|
||||
const [movedRow] = updatedOrder.splice(fromIndex, 1);
|
||||
if (!movedRow) return;
|
||||
updatedOrder.splice(toIndex, 0, movedRow);
|
||||
onReorder({ updateReorderedItem: updatedOrder.map((row) => row.uid) });
|
||||
if (fromIndex !== toIndex && onReorder) {
|
||||
const reorderableRows = showAddRow ? rowsWithEmpty.slice(0, -1) : rowsWithEmpty;
|
||||
const updatedOrder = [...reorderableRows];
|
||||
const [movedRow] = updatedOrder.splice(fromIndex, 1);
|
||||
if (!movedRow) {
|
||||
setHoveredRow(null);
|
||||
return;
|
||||
}
|
||||
updatedOrder.splice(toIndex, 0, movedRow);
|
||||
onReorder({ updateReorderedItem: updatedOrder.map((row) => row.uid) });
|
||||
}
|
||||
setHoveredRow(null);
|
||||
}, [onReorder, rowsWithEmpty, showAddRow]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragOverRow(null);
|
||||
setHoveredRow(null);
|
||||
}, []);
|
||||
|
||||
const renderCell = useCallback((column, row, rowIndex) => {
|
||||
@@ -342,124 +320,109 @@ const EditableTable = ({
|
||||
);
|
||||
}, [isLastEmptyRow, getRowError, handleValueChange]);
|
||||
|
||||
const virtuosoContext = useMemo(() => ({
|
||||
reorderable,
|
||||
reorderableRowCount,
|
||||
isLastEmptyRow,
|
||||
dragOverRow,
|
||||
onDragStart: handleDragStart,
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
onDragEnd: handleDragEnd
|
||||
}), [reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, handleDragStart, handleDragOver, handleDragLeave, handleDrop, handleDragEnd]);
|
||||
|
||||
const fixedHeaderContent = useCallback(() => (
|
||||
<tr>
|
||||
{showCheckbox && (
|
||||
<td className="text-center">{checkboxLabel}</td>
|
||||
)}
|
||||
{columns.map((column, colIndex) => (
|
||||
<td
|
||||
key={column.key}
|
||||
style={{ width: getColumnWidth(column) }}
|
||||
>
|
||||
<span className="column-name">{column.name}</span>
|
||||
{colIndex < columns.length - 1 && (
|
||||
<div
|
||||
className={`resize-handle ${resizing === column.key ? 'resizing' : ''}`}
|
||||
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
|
||||
onMouseDown={(e) => handleResizeStart(e, column.key)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
{showDelete && (
|
||||
<td style={{ width: '60px' }}></td>
|
||||
)}
|
||||
</tr>
|
||||
), [showCheckbox, checkboxLabel, columns, getColumnWidth, resizing, tableHeight, handleResizeStart, showDelete]);
|
||||
|
||||
const itemContent = useCallback((rowIndex, row) => {
|
||||
const isEmpty = isLastEmptyRow(row, rowIndex);
|
||||
const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showCheckbox && (
|
||||
<td className="text-center relative">
|
||||
{reorderable && canDrag && (
|
||||
<div
|
||||
draggable
|
||||
className="drag-handle group absolute z-10 left-[-8px] top-1/2 -translate-y-1/2 p-1 cursor-grab"
|
||||
>
|
||||
<IconGripVertical
|
||||
size={14}
|
||||
className="icon-grip hidden group-hover:block"
|
||||
/>
|
||||
<IconMinusVertical
|
||||
size={14}
|
||||
className="icon-minus block group-hover:hidden"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mousetrap"
|
||||
data-testid="column-checkbox"
|
||||
checked={row[checkboxKey] ?? true}
|
||||
disabled={disableCheckbox}
|
||||
onChange={(e) => handleCheckboxChange(row.uid, e.target.checked)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} data-testid={`column-${column.key}`}>
|
||||
{renderCell(column, row, rowIndex)}
|
||||
</td>
|
||||
))}
|
||||
{showDelete && (
|
||||
<td>
|
||||
{!isEmpty && (
|
||||
<button
|
||||
data-testid="column-delete"
|
||||
onClick={() => handleRemoveRow(row.uid)}
|
||||
>
|
||||
<IconTrash strokeWidth={1.5} size={18} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}, [showCheckbox, reorderable, reorderableRowCount, isLastEmptyRow, checkboxKey, disableCheckbox, handleCheckboxChange, columns, renderCell, showDelete, handleRemoveRow]);
|
||||
|
||||
const initialTopMostItemIndex = useRef(Math.max(0, Math.floor(initialScroll / ROW_HEIGHT))).current;
|
||||
const reorderableRowCount = showAddRow ? rowsWithEmpty.length - 1 : rowsWithEmpty.length;
|
||||
|
||||
return (
|
||||
<StyledWrapper
|
||||
ref={wrapperRef}
|
||||
data-testid={testId}
|
||||
className={`${showCheckbox ? 'has-checkbox' : 'no-checkbox'} ${resizing ? 'is-resizing' : ''}`}
|
||||
>
|
||||
{scrollParent && (
|
||||
<TableVirtuoso
|
||||
ref={virtuosoRef}
|
||||
className="table-container"
|
||||
customScrollParent={scrollParent}
|
||||
data={rowsWithEmpty}
|
||||
components={{ TableRow }}
|
||||
context={virtuosoContext}
|
||||
defaultItemHeight={ROW_HEIGHT}
|
||||
initialTopMostItemIndex={initialTopMostItemIndex}
|
||||
totalListHeightChanged={handleTotalHeightChanged}
|
||||
computeItemKey={(_, item) => item.uid}
|
||||
fixedHeaderContent={fixedHeaderContent}
|
||||
itemContent={itemContent}
|
||||
/>
|
||||
)}
|
||||
<StyledWrapper className={`${showCheckbox ? 'has-checkbox' : 'no-checkbox'} ${resizing ? 'is-resizing' : ''}`}>
|
||||
<div className="table-container" ref={tableRef} data-testid={testId}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{showCheckbox && (
|
||||
<td className="text-center">{checkboxLabel}</td>
|
||||
)}
|
||||
{columns.map((column, colIndex) => (
|
||||
<td
|
||||
key={column.key}
|
||||
style={{ width: getColumnWidth(column) }}
|
||||
>
|
||||
<span className="column-name">{column.name}</span>
|
||||
{colIndex < columns.length - 1 && (
|
||||
<div
|
||||
className={`resize-handle ${resizing === column.key ? 'resizing' : ''}`}
|
||||
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
|
||||
onMouseDown={(e) => handleResizeStart(e, column.key)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
{showDelete && (
|
||||
<td style={{ width: '60px' }}></td>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rowsWithEmpty.map((row, rowIndex) => {
|
||||
const isEmpty = isLastEmptyRow(row, rowIndex);
|
||||
const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={row.uid}
|
||||
draggable={canDrag}
|
||||
onDragStart={canDrag ? (e) => handleDragStart(e, rowIndex) : undefined}
|
||||
onDragOver={canDrag ? (e) => handleDragOver(e, rowIndex) : undefined}
|
||||
onDrop={canDrag ? (e) => handleDrop(e, rowIndex) : undefined}
|
||||
onDragEnd={canDrag ? handleDragEnd : undefined}
|
||||
onMouseEnter={() => setHoveredRow(rowIndex)}
|
||||
onMouseLeave={() => setHoveredRow(null)}
|
||||
>
|
||||
{showCheckbox && (
|
||||
<td className="text-center relative">
|
||||
{reorderable && canDrag && (
|
||||
<div
|
||||
draggable
|
||||
className="drag-handle group absolute z-10 left-[-8px] top-1/2 -translate-y-1/2 p-1 cursor-grab"
|
||||
>
|
||||
{hoveredRow === rowIndex && (
|
||||
<>
|
||||
<IconGripVertical
|
||||
size={14}
|
||||
className="icon-grip hidden group-hover:block"
|
||||
/>
|
||||
<IconMinusVertical
|
||||
size={14}
|
||||
className="icon-minus block group-hover:hidden"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mousetrap"
|
||||
data-testid="column-checkbox"
|
||||
checked={row[checkboxKey] ?? true}
|
||||
disabled={disableCheckbox}
|
||||
onChange={(e) => handleCheckboxChange(row.uid, e.target.checked)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} data-testid={`column-${column.key}`}>
|
||||
{renderCell(column, row, rowIndex)}
|
||||
</td>
|
||||
))}
|
||||
{showDelete && (
|
||||
<td>
|
||||
{!isEmpty && (
|
||||
<button
|
||||
data-testid="column-delete"
|
||||
onClick={() => handleRemoveRow(row.uid)}
|
||||
>
|
||||
<IconTrash strokeWidth={1.5} size={18} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,7 +15,6 @@ const Wrapper = styled.div`
|
||||
overflow-y: auto;
|
||||
border-radius: 8px;
|
||||
border: solid 1px ${(props) => props.theme.border.border0};
|
||||
transition: height 75ms cubic-bezier(0,1.12,.84,.64);
|
||||
}
|
||||
|
||||
table {
|
||||
|
||||
@@ -3,8 +3,7 @@ import { TableVirtuoso } from 'react-virtuoso';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useSelector } from 'react-redux';
|
||||
import MultiLineEditor from 'components/MultiLineEditor/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { uuid } from 'utils/common';
|
||||
@@ -15,16 +14,13 @@ import toast from 'react-hot-toast';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { getGlobalEnvironmentVariables } from 'utils/collections';
|
||||
import { stripEnvVarUid } from 'utils/environments';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
|
||||
const MIN_H = 35 * 2;
|
||||
const MIN_COLUMN_WIDTH = 80;
|
||||
const MIN_ROW_HEIGHT = 35;
|
||||
|
||||
const TableRow = React.memo(
|
||||
({ children, item, style, ...rest }) => (
|
||||
<tr key={item.uid} style={style} {...rest} data-testid={`env-var-row-${item?.name}`}>
|
||||
({ children, item }) => (
|
||||
<tr key={item.uid} data-testid={`env-var-row-${item.name}`}>
|
||||
{children}
|
||||
</tr>
|
||||
),
|
||||
@@ -48,54 +44,14 @@ const EnvironmentVariablesTable = ({
|
||||
}) => {
|
||||
const { storedTheme } = useTheme();
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
|
||||
const activeWorkspace = useSelector((state) => {
|
||||
const uid = state.workspaces?.activeWorkspaceUid;
|
||||
return state.workspaces?.workspaces?.find((w) => w.uid === uid);
|
||||
});
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
|
||||
const hasDraftForThisEnv = draft?.environmentUid === environment.uid;
|
||||
|
||||
const rowCount = (environment.variables?.length || 0) + 1;
|
||||
const [tableHeight, setTableHeight] = useState(rowCount * MIN_ROW_HEIGHT);
|
||||
|
||||
// We need to add <EditableTable/> component for env table
|
||||
const [scroll, setScroll] = usePersistedState({
|
||||
key: `persisted::${activeTabUid}::collection-envs-scroll-${environment.uid}`,
|
||||
default: 0
|
||||
});
|
||||
const scrollerRef = useRef(null);
|
||||
const [scrollerEl, setScrollerEl] = useState(null);
|
||||
scrollerRef.current = scrollerEl;
|
||||
const initialTopMostItemIndex = useRef(Math.max(0, Math.floor(scroll / MIN_ROW_HEIGHT))).current;
|
||||
useTrackScroll({ ref: scrollerRef, onChange: setScroll, initialValue: scroll, enabled: !!scrollerEl });
|
||||
|
||||
// Use environment UID as part of tableId so each environment has its own column widths
|
||||
const tableId = `env-vars-table-${environment.uid}`;
|
||||
|
||||
// Get column widths from Redux - derived value (not state)
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
const storedColumnWidths = focusedTab?.tableColumnWidths?.[tableId];
|
||||
|
||||
// Local state initialized from Redux (computed once on mount/environment change via key)
|
||||
const [columnWidths, setColumnWidths] = useState(() => {
|
||||
return storedColumnWidths || { name: '30%', value: 'auto' };
|
||||
});
|
||||
|
||||
const [tableHeight, setTableHeight] = useState(MIN_H);
|
||||
const [columnWidths, setColumnWidths] = useState({ name: '30%', value: 'auto' });
|
||||
const [resizing, setResizing] = useState(null);
|
||||
const [pinnedData, setPinnedData] = useState({ query: '', uids: new Set() });
|
||||
|
||||
const handleColumnWidthsChange = (id, widths) => {
|
||||
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId: id, widths }));
|
||||
};
|
||||
|
||||
// Store column widths in ref for access in event handlers
|
||||
const columnWidthsRef = useRef(columnWidths);
|
||||
columnWidthsRef.current = columnWidths;
|
||||
|
||||
const handleResizeStart = useCallback((e, columnKey) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -117,24 +73,21 @@ const EnvironmentVariablesTable = ({
|
||||
const maxShrink = startWidth - MIN_COLUMN_WIDTH;
|
||||
const clampedDiff = Math.max(-maxShrink, Math.min(maxGrow, diff));
|
||||
|
||||
const newWidths = {
|
||||
setColumnWidths({
|
||||
[columnKey]: `${startWidth + clampedDiff}px`,
|
||||
[nextColumnKey]: `${nextColumnStartWidth - clampedDiff}px`
|
||||
};
|
||||
setColumnWidths(newWidths);
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setResizing(null);
|
||||
// Save to Redux after resize ends using ref for latest values
|
||||
handleColumnWidthsChange(tableId, columnWidthsRef.current);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}, [handleColumnWidthsChange]);
|
||||
}, []);
|
||||
|
||||
const handleTotalHeightChanged = useCallback((h) => {
|
||||
setTableHeight(h);
|
||||
@@ -151,21 +104,11 @@ const EnvironmentVariablesTable = ({
|
||||
const prevEnvVariablesRef = useRef(environment.variables);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
let _collection = collection ? cloneDeep(collection) : {};
|
||||
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
|
||||
const workspaceProcessEnvVariables = activeWorkspace?.processEnvVariables;
|
||||
// `_collection` flows into every row's MultiLineEditor as the variable-resolution
|
||||
// context. Without memoization, `cloneDeep(collection)` runs on every render —
|
||||
// and Formik triggers a re-render on every keystroke, so a single env edit
|
||||
// session can deep-clone the entire collection 100+ times. That's the
|
||||
// dominant cost behind the test-budget flake.
|
||||
const _collection = useMemo(() => {
|
||||
const c = collection ? cloneDeep(collection) : {};
|
||||
c.globalEnvironmentVariables = globalEnvironmentVariables;
|
||||
if (!collection && workspaceProcessEnvVariables) {
|
||||
c.workspaceProcessEnvVariables = workspaceProcessEnvVariables;
|
||||
}
|
||||
return c;
|
||||
}, [collection, globalEnvironmentVariables, workspaceProcessEnvVariables]);
|
||||
if (_collection) {
|
||||
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
|
||||
}
|
||||
|
||||
const initialValues = useMemo(() => {
|
||||
const vars = environment.variables || [];
|
||||
@@ -502,9 +445,6 @@ const EnvironmentVariablesTable = ({
|
||||
<TableVirtuoso
|
||||
className="table-container"
|
||||
style={{ height: tableHeight }}
|
||||
scrollerRef={setScrollerEl}
|
||||
initialTopMostItemIndex={initialTopMostItemIndex}
|
||||
overscan={Math.min(30, filteredVariables.length)}
|
||||
components={{ TableRow }}
|
||||
data={filteredVariables}
|
||||
totalListHeightChanged={handleTotalHeightChanged}
|
||||
@@ -524,6 +464,7 @@ const EnvironmentVariablesTable = ({
|
||||
<td></td>
|
||||
</tr>
|
||||
)}
|
||||
fixedItemHeight={35}
|
||||
computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`}
|
||||
itemContent={(virtualIndex, { variable, index: actualIndex }) => {
|
||||
const isLastRow = actualIndex === formik.values.length - 1;
|
||||
@@ -556,7 +497,7 @@ const EnvironmentVariablesTable = ({
|
||||
id={`${actualIndex}.name`}
|
||||
name={`${actualIndex}.name`}
|
||||
value={variable.name}
|
||||
placeholder={!variable.name || (typeof variable.name === 'string' && variable.name.trim() === '') ? 'Name' : ''}
|
||||
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
|
||||
onChange={(e) => handleNameChange(actualIndex, e)}
|
||||
onFocus={() => handleRowFocus(variable.uid)}
|
||||
onBlur={() => {
|
||||
@@ -581,7 +522,7 @@ const EnvironmentVariablesTable = ({
|
||||
collection={_collection}
|
||||
name={`${actualIndex}.value`}
|
||||
value={variable.value}
|
||||
placeholder={variable.value == null || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Value' : ''}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
isSecret={variable.secret}
|
||||
readOnly={typeof variable.value !== 'string'}
|
||||
onChange={(newValue) => {
|
||||
@@ -591,19 +532,6 @@ const EnvironmentVariablesTable = ({
|
||||
formik.setFieldValue(`${actualIndex}.ephemeral`, undefined, false);
|
||||
formik.setFieldValue(`${actualIndex}.persistedValue`, undefined, false);
|
||||
}
|
||||
// Append a new empty row when editing value on the last row
|
||||
if (isLastRow) {
|
||||
setTimeout(() => {
|
||||
formik.setFieldValue(formik.values.length, {
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}, false);
|
||||
}, 0);
|
||||
}
|
||||
}}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
@@ -644,8 +572,6 @@ const EnvironmentVariablesTable = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* We should re-think of these buttons placement in component as we use TableVirtuoso which because of
|
||||
these buttons renders at some transition: height 0.1s ease` */}
|
||||
<div className="button-container">
|
||||
<div className="flex items-center">
|
||||
<button type="button" className="submit" onClick={handleSave} data-testid="save-env">
|
||||
|
||||
@@ -8,12 +8,11 @@ const CollapsibleSection = ({
|
||||
onToggle,
|
||||
badge,
|
||||
actions,
|
||||
children,
|
||||
testId
|
||||
children
|
||||
}) => {
|
||||
return (
|
||||
<StyledWrapper className={expanded ? 'expanded' : 'collapsed'}>
|
||||
<div className="section-header" onClick={onToggle} data-testid={testId}>
|
||||
<div className="section-header" onClick={onToggle}>
|
||||
<div className="section-title-wrapper">
|
||||
<IconChevronRight
|
||||
size={14}
|
||||
|
||||
@@ -44,7 +44,6 @@ const DotEnvFileDetails = ({
|
||||
className={`toggle-btn ${viewMode === 'raw' ? 'active' : ''}`}
|
||||
onClick={() => onViewModeChange?.('raw')}
|
||||
aria-pressed={viewMode === 'raw'}
|
||||
data-testid="dotenv-view-raw"
|
||||
>
|
||||
Raw
|
||||
</button>
|
||||
|
||||
@@ -13,7 +13,7 @@ const DotEnvRawView = ({
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="raw-editor-container" data-testid="dotenv-raw-editor">
|
||||
<div className="raw-editor-container">
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
item={item}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { uuid } from 'utils/common';
|
||||
import { utils } from '@usebruno/common';
|
||||
|
||||
export const variablesToRaw = (variables) => {
|
||||
return utils.jsonToDotenv(variables);
|
||||
return variables
|
||||
.filter((v) => v.name && v.name.trim() !== '')
|
||||
.map((v) => {
|
||||
const value = v.value || '';
|
||||
if (value.includes('\n') || value.includes('"') || value.includes('\'')) {
|
||||
const escapedValue = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
||||
return `${v.name}="${escapedValue}"`;
|
||||
}
|
||||
return `${v.name}=${value}`;
|
||||
})
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
export const rawToVariables = (rawContent) => {
|
||||
@@ -28,16 +37,9 @@ export const rawToVariables = (rawContent) => {
|
||||
const name = trimmedLine.substring(0, equalIndex).trim();
|
||||
let value = trimmedLine.substring(equalIndex + 1);
|
||||
|
||||
if (value.startsWith('\'') && value.endsWith('\'')) {
|
||||
// Single-quoted values are fully literal in dotenv — no unescaping
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
|
||||
value = value.slice(1, -1);
|
||||
} else if (value.startsWith('`') && value.endsWith('`')) {
|
||||
// Backtick-quoted values are fully literal in dotenv — no unescaping
|
||||
value = value.slice(1, -1);
|
||||
} else if (value.startsWith('"') && value.endsWith('"')) {
|
||||
// Double-quoted values: unescape \", \n, and \r (the escapes we produce)
|
||||
value = value.slice(1, -1);
|
||||
value = value.replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\r/g, '\r');
|
||||
value = value.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
||||
}
|
||||
|
||||
if (name) {
|
||||
|
||||
@@ -17,15 +17,11 @@ const EnvironmentListContent = ({
|
||||
{environments && environments.length > 0 ? (
|
||||
<>
|
||||
<div className="environment-list">
|
||||
<div
|
||||
className={`dropdown-item no-environment ${!activeEnvironmentUid ? 'dropdown-item-active' : ''}`}
|
||||
onClick={() => onEnvironmentSelect(null)}
|
||||
>
|
||||
<span className="w-2 shrink-0" />
|
||||
<div className="dropdown-item no-environment" onClick={() => onEnvironmentSelect(null)}>
|
||||
<span>No Environment</span>
|
||||
</div>
|
||||
<ToolHint
|
||||
tooltipId="environment-name-tooltip"
|
||||
anchorSelect="[data-tooltip-content]"
|
||||
place="right"
|
||||
positionStrategy="fixed"
|
||||
tooltipStyle={{
|
||||
@@ -40,7 +36,6 @@ const EnvironmentListContent = ({
|
||||
key={env.uid}
|
||||
className={`dropdown-item ${env.uid === activeEnvironmentUid ? 'dropdown-item-active' : ''}`}
|
||||
onClick={() => onEnvironmentSelect(env)}
|
||||
data-tooltip-id="environment-name-tooltip"
|
||||
data-tooltip-content={env.name}
|
||||
data-tooltip-hidden={env.name?.length < 90}
|
||||
>
|
||||
@@ -51,7 +46,7 @@ const EnvironmentListContent = ({
|
||||
</div>
|
||||
</ToolHint>
|
||||
<div className="dropdown-item configure-button">
|
||||
<button onClick={onSettingsClick} id="configure-env" data-testid="configure-env">
|
||||
<button onClick={onSettingsClick} id="configure-env">
|
||||
<IconSettings size={16} strokeWidth={1.5} />
|
||||
<span>Configure</span>
|
||||
</button>
|
||||
|
||||
@@ -117,10 +117,6 @@ const Wrapper = styled.div`
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.no-environment {
|
||||
color: ${(props) => props.theme.colors.text.subtext0};
|
||||
}
|
||||
|
||||
.environment-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -103,7 +103,6 @@ const EnvironmentVariables = ({ environment, setIsModified, collection, searchQu
|
||||
|
||||
return (
|
||||
<EnvironmentVariablesTable
|
||||
key={environment?.uid}
|
||||
environment={environment}
|
||||
collection={collection}
|
||||
onSave={handleSave}
|
||||
|
||||
@@ -736,7 +736,6 @@ const EnvironmentList = ({
|
||||
|
||||
<CollapsibleSection
|
||||
title=".env Files"
|
||||
testId="dotenv-files-section"
|
||||
expanded={dotEnvExpanded}
|
||||
onToggle={() => setDotEnvExpanded(!dotEnvExpanded)}
|
||||
badge={dotEnvFiles.length}
|
||||
@@ -745,7 +744,6 @@ const EnvironmentList = ({
|
||||
className="btn-action"
|
||||
onClick={handleCreateDotEnvInlineClick}
|
||||
title="Create .env file"
|
||||
data-testid="create-dotenv-file"
|
||||
>
|
||||
<IconPlus size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
@@ -770,7 +768,6 @@ const EnvironmentList = ({
|
||||
ref={dotEnvInputRef}
|
||||
type="text"
|
||||
className="environment-name-input"
|
||||
data-testid="dotenv-name-input"
|
||||
value={newDotEnvName}
|
||||
onChange={handleDotEnvNameChange}
|
||||
onKeyDown={handleDotEnvNameKeyDown}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
|
||||
pre {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -1,34 +0,0 @@
|
||||
import React from 'react';
|
||||
import Portal from 'components/Portal';
|
||||
import Modal from 'components/Modal';
|
||||
import { useState } from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const SaveFileErrorModal = ({ error }) => {
|
||||
const [showModal, setShowModal] = useState(true);
|
||||
return (
|
||||
<>
|
||||
{showModal ? (
|
||||
<Portal>
|
||||
<StyledWrapper>
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Save File Error"
|
||||
hideFooter={true}
|
||||
hideCancel={true}
|
||||
handleCancel={() => {
|
||||
setShowModal(false);
|
||||
}}
|
||||
disableCloseOnOutsideClick={true}
|
||||
disableEscapeKey={true}
|
||||
>
|
||||
<pre className="w-full flex flex-wrap whitespace-pre-wrap">{error}</pre>
|
||||
</Modal>
|
||||
</StyledWrapper>
|
||||
</Portal>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SaveFileErrorModal;
|
||||
@@ -1,55 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
div.CodeMirror {
|
||||
height: 100%;
|
||||
background: ${(props) => props.theme.codemirror.bg};
|
||||
border: solid 1px ${(props) => props.theme.codemirror.border};
|
||||
font-family: ${(props) => (props.font ? props.font : 'default')};
|
||||
line-break: anywhere;
|
||||
}
|
||||
|
||||
.CodeMirror-overlayscroll-horizontal div,
|
||||
.CodeMirror-overlayscroll-vertical div {
|
||||
background: #d2d7db;
|
||||
}
|
||||
|
||||
textarea.cm-editor {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// Todo: dark mode temporary fix
|
||||
// Clean this
|
||||
.CodeMirror.cm-s-monokai {
|
||||
.CodeMirror-overlayscroll-horizontal div,
|
||||
.CodeMirror-overlayscroll-vertical div {
|
||||
background: #444444;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-property,
|
||||
.cm-s-monokai span.cm-attribute {
|
||||
color: #9cdcfe !important;
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-string {
|
||||
color: #ce9178 !important;
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-number {
|
||||
color: #b5cea8 !important;
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-atom {
|
||||
color: #569cd6 !important;
|
||||
}
|
||||
|
||||
.cm-variable-valid {
|
||||
color: green;
|
||||
}
|
||||
.cm-variable-invalid {
|
||||
color: red;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user