mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 01:18:32 +00:00
Compare commits
85 Commits
v3.4.0
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6dd60e2cc | ||
|
|
59b4a16b79 | ||
|
|
377cdb488c | ||
|
|
79504ed729 | ||
|
|
6791e0a674 | ||
|
|
ed5f5c21cf | ||
|
|
280b856869 | ||
|
|
216d8e7151 | ||
|
|
13a48a256f | ||
|
|
240826ebc1 | ||
|
|
6f47218a81 | ||
|
|
95c75c90c1 | ||
|
|
366d85b141 | ||
|
|
b9ee1ee523 | ||
|
|
2d4d4e4037 | ||
|
|
b9d8bdf2ec | ||
|
|
913214e96b | ||
|
|
f629c3dd20 | ||
|
|
b70bfb26d4 | ||
|
|
a8b938fe4c | ||
|
|
dadd69b02d | ||
|
|
8f80230708 | ||
|
|
026dbfb108 | ||
|
|
462a39308d | ||
|
|
f23e406ef8 | ||
|
|
db91dbf192 | ||
|
|
7413465bb4 | ||
|
|
18761ee156 | ||
|
|
d8b6701bb5 | ||
|
|
472241b51c | ||
|
|
244f528277 | ||
|
|
49088e98c8 | ||
|
|
b43a5e6e0a | ||
|
|
4ee9a75465 | ||
|
|
413697cbe7 | ||
|
|
6b7e5f3813 | ||
|
|
d9c13e74ac | ||
|
|
809f951a47 | ||
|
|
2e0094fc46 | ||
|
|
39308bc03c | ||
|
|
a3e3199490 | ||
|
|
87d97ba0ef | ||
|
|
b20893eee1 | ||
|
|
9b0911926c | ||
|
|
8cd7c26648 | ||
|
|
611724a744 | ||
|
|
71b53ee0bc | ||
|
|
113e28dc3c | ||
|
|
2d25b2cfb0 | ||
|
|
f916b19a6f | ||
|
|
c5528a75a6 | ||
|
|
4b214693c4 | ||
|
|
023630338b | ||
|
|
e0de7d5557 | ||
|
|
e86a036fd6 | ||
|
|
454b43942c | ||
|
|
8cc3a670c6 | ||
|
|
cea883eda2 | ||
|
|
e8468ac9a5 | ||
|
|
10da27dde8 | ||
|
|
55774a8258 | ||
|
|
736c050dae | ||
|
|
b79349b052 | ||
|
|
da779883a6 | ||
|
|
48c88df3a8 | ||
|
|
bdc5d1e017 | ||
|
|
a2ec2c6d56 | ||
|
|
df06d1558b | ||
|
|
bd2b1ecb70 | ||
|
|
1ab2368f0f | ||
|
|
351b294c3f | ||
|
|
c282a955f8 | ||
|
|
612b99460b | ||
|
|
d79aabb9f5 | ||
|
|
9190de53ad | ||
|
|
31dedc3c95 | ||
|
|
57d2fc7899 | ||
|
|
4fa882c67c | ||
|
|
2c9dc9dcf8 | ||
|
|
9df06e152a | ||
|
|
9f75959452 | ||
|
|
dd922c7163 | ||
|
|
d8fb7c7e7e | ||
|
|
4ad51186a1 | ||
|
|
975c638f39 |
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Force LF line endings for all text files
|
||||
* text=auto eol=lf
|
||||
@@ -5,6 +5,10 @@ inputs:
|
||||
description: 'Skip building libraries'
|
||||
required: false
|
||||
default: 'false'
|
||||
shell:
|
||||
description: 'Shell to use (bash, pwsh)'
|
||||
required: false
|
||||
default: 'bash'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
@@ -16,12 +20,12 @@ runs:
|
||||
cache-dependency-path: './package-lock.json'
|
||||
|
||||
- name: Install node dependencies
|
||||
shell: bash
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Build libraries
|
||||
if: inputs.skip-build != 'true'
|
||||
shell: bash
|
||||
shell: ${{ inputs.shell }}
|
||||
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
|
||||
name: playwright-report-linux-ssl
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
@@ -12,6 +12,6 @@ runs:
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-macos
|
||||
name: playwright-report-macos-ssl
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
@@ -12,6 +12,6 @@ runs:
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-windows
|
||||
name: playwright-report-windows-ssl
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
38
.github/actions/tests/run-benchmark-tests/action.yml
vendored
Normal file
38
.github/actions/tests/run-benchmark-tests/action.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
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
|
||||
41
.github/actions/tests/run-cli-tests/action.yml
vendored
41
.github/actions/tests/run-cli-tests/action.yml
vendored
@@ -1,20 +1,41 @@
|
||||
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: Run Local Testbench
|
||||
shell: bash
|
||||
- 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 }}
|
||||
run: |
|
||||
npm start --workspace=packages/bruno-tests &
|
||||
sleep 5
|
||||
|
||||
- name: Install Test Collection Dependencies
|
||||
shell: bash
|
||||
run: npm ci --prefix packages/bruno-tests/collection
|
||||
|
||||
- name: Run CLI Tests
|
||||
shell: bash
|
||||
run: |
|
||||
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
|
||||
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,19 +4,23 @@ 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: bash
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm ci --prefix packages/bruno-tests/collection
|
||||
|
||||
- name: Run Playwright Tests (Ubuntu)
|
||||
if: inputs.os == 'ubuntu'
|
||||
shell: bash
|
||||
run: xvfb-run npm run test:e2e
|
||||
run: xvfb-run dbus-run-session -- npm run test:e2e
|
||||
|
||||
- name: Run Playwright Tests
|
||||
if: inputs.os != 'ubuntu'
|
||||
shell: bash
|
||||
shell: ${{ inputs.shell }}
|
||||
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,48 +1,53 @@
|
||||
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: bash
|
||||
run: npm run test --workspace=packages/bruno-js
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test:ci --workspace=packages/bruno-js
|
||||
|
||||
- name: Test Package bruno-cli
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-cli
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test:ci --workspace=packages/bruno-cli
|
||||
|
||||
- name: Test Package bruno-query
|
||||
shell: bash
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test --workspace=packages/bruno-query
|
||||
|
||||
- name: Test Package bruno-lang
|
||||
shell: bash
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test --workspace=packages/bruno-lang
|
||||
|
||||
- name: Test Package bruno-schema
|
||||
shell: bash
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test --workspace=packages/bruno-schema
|
||||
|
||||
- name: Test Package bruno-app
|
||||
shell: bash
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test --workspace=packages/bruno-app
|
||||
|
||||
- name: Test Package bruno-common
|
||||
shell: bash
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test --workspace=packages/bruno-common
|
||||
|
||||
- name: Test Package bruno-converters
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-converters
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test:ci --workspace=packages/bruno-converters
|
||||
|
||||
- name: Test Package bruno-electron
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-electron
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test:ci --workspace=packages/bruno-electron
|
||||
|
||||
- name: Test Package bruno-requests
|
||||
shell: bash
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test --workspace=packages/bruno-requests
|
||||
|
||||
- name: Test Package bruno-filestore
|
||||
shell: bash
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test --workspace=packages/bruno-filestore
|
||||
|
||||
79
.github/workflows/auth-tests.yml
vendored
79
.github/workflows/auth-tests.yml
vendored
@@ -1,79 +0,0 @@
|
||||
name: Auth Tests
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
oauth1-tests-for-linux:
|
||||
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
|
||||
|
||||
oauth1-tests-for-macos:
|
||||
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
|
||||
|
||||
oauth1-tests-for-windows:
|
||||
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
|
||||
88
.github/workflows/benchmarks.yml
vendored
Normal file
88
.github/workflows/benchmarks.yml
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
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 }})'
|
||||
});
|
||||
91
.github/workflows/ssl-tests.yml
vendored
91
.github/workflows/ssl-tests.yml
vendored
@@ -1,91 +0,0 @@
|
||||
name: SSL Tests
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
tests-for-linux:
|
||||
name: SSL Tests - Linux
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@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
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Tests
|
||||
name: Linux Tests
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
unit-test:
|
||||
name: Unit Tests
|
||||
name: Unit Tests (Linux)
|
||||
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
|
||||
name: CLI Tests (Linux)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
checks: write
|
||||
@@ -42,13 +42,14 @@ jobs:
|
||||
uses: EnricoMi/publish-unit-test-result-action@v2
|
||||
if: always()
|
||||
with:
|
||||
check_name: CLI Test Results
|
||||
check_name: CLI Test Results (Linux)
|
||||
files: packages/bruno-tests/collection/junit.xml
|
||||
comment_mode: always
|
||||
check_run: false
|
||||
|
||||
e2e-test:
|
||||
name: Playwright E2E Tests
|
||||
timeout-minutes: 60
|
||||
name: Playwright E2E Tests (Linux)
|
||||
timeout-minutes: 120
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -58,7 +59,8 @@ 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
|
||||
xvfb \
|
||||
gsettings-desktop-schemas dbus-x11
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
@@ -77,6 +79,61 @@ jobs:
|
||||
uses: actions/upload-artifact@v6
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report
|
||||
name: playwright-report-linux
|
||||
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
|
||||
123
.github/workflows/tests-macos.yml
vendored
Normal file
123
.github/workflows/tests-macos.yml
vendored
Normal file
@@ -0,0 +1,123 @@
|
||||
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: 150
|
||||
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
Normal file
134
.github/workflows/tests-windows.yml
vendored
Normal file
@@ -0,0 +1,134 @@
|
||||
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: 120
|
||||
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
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -58,6 +58,10 @@ skills-lock.json
|
||||
# Playwright
|
||||
/blob-report/
|
||||
|
||||
# Benchmark results (generated at runtime)
|
||||
tests/benchmarks/results/
|
||||
/benchmark-report/
|
||||
|
||||
# Development plan files
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
|
||||
@@ -59,6 +59,47 @@ 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
|
||||
|
||||
|
||||
688
package-lock.json
generated
688
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,7 @@
|
||||
"@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",
|
||||
@@ -80,9 +81,10 @@
|
||||
"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",
|
||||
"test:e2e": "playwright test --project=default --project=system-pac",
|
||||
"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"
|
||||
@@ -93,9 +95,9 @@
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"axios":"1.13.6",
|
||||
"axios": "1.13.6",
|
||||
"rollup": "3.30.0",
|
||||
"pbkdf2":"3.1.5",
|
||||
"pbkdf2": "3.1.5",
|
||||
"electron-store": {
|
||||
"conf": {
|
||||
"json-schema-typed": "8.0.1"
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"postcss": "8.4.47",
|
||||
"style-loader": "^3.3.1",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"webpack": "^5.64.4",
|
||||
"webpack": "^5.107.2",
|
||||
"webpack-cli": "^4.9.1"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
298
packages/bruno-app/src/components/AIAssist/StyledWrapper.js
Normal file
298
packages/bruno-app/src/components/AIAssist/StyledWrapper.js
Normal file
@@ -0,0 +1,298 @@
|
||||
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;
|
||||
232
packages/bruno-app/src/components/AIAssist/index.js
Normal file
232
packages/bruno-app/src/components/AIAssist/index.js
Normal file
@@ -0,0 +1,232 @@
|
||||
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;
|
||||
@@ -1,12 +1,72 @@
|
||||
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 }) => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="swagger-root w-full">
|
||||
<SwaggerUI spec={spec} onComplete={onComplete} />
|
||||
<SwaggerUI
|
||||
spec={spec}
|
||||
onComplete={onComplete}
|
||||
requestInterceptor={requestInterceptor}
|
||||
/>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,14 @@ 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);
|
||||
});
|
||||
@@ -144,8 +152,10 @@ const AppTitleBar = () => {
|
||||
|
||||
const handleOpenWorkspace = async () => {
|
||||
try {
|
||||
await dispatch(openWorkspaceDialog());
|
||||
toast.success('Workspace opened successfully');
|
||||
const result = await dispatch(openWorkspaceDialog());
|
||||
if (result) {
|
||||
toast.success('Workspace opened successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error.message || 'Failed to open workspace');
|
||||
}
|
||||
|
||||
128
packages/bruno-app/src/components/AppTitleBar/index.spec.js
Normal file
128
packages/bruno-app/src/components/AppTitleBar/index.spec.js
Normal file
@@ -0,0 +1,128 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,6 +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,
|
||||
@@ -64,13 +65,16 @@ class CodeEditor extends React.Component {
|
||||
|
||||
componentDidMount() {
|
||||
const variables = getAllVariables(this.props.collection, this.props.item);
|
||||
const runShortcut = () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
return;
|
||||
}
|
||||
return CodeMirror.Pass;
|
||||
};
|
||||
/**
|
||||
* 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 || '',
|
||||
@@ -266,6 +270,8 @@ class CodeEditor extends React.Component {
|
||||
if (cmInput) {
|
||||
cmInput.classList.add('mousetrap');
|
||||
}
|
||||
|
||||
this.cleanupResizeRefresh = setupCodeMirrorResizeRefresh(editor, this._node);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,6 +405,7 @@ class CodeEditor extends React.Component {
|
||||
|
||||
// Clean up lint error tooltip
|
||||
this.cleanupLintErrorTooltip?.();
|
||||
this.cleanupResizeRefresh?.();
|
||||
|
||||
const wrapper = this.editor.getWrapperElement();
|
||||
wrapper?.parentNode?.removeChild(wrapper);
|
||||
|
||||
@@ -75,13 +75,13 @@ const AuthMode = ({ collection }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
|
||||
<div className="inline-flex items-center cursor-pointer auth-mode-selector" data-testid="auth-mode-selector">
|
||||
<MenuDropdown
|
||||
items={menuItems}
|
||||
placement="bottom-end"
|
||||
selectedItemId={authMode}
|
||||
>
|
||||
<div className="flex items-center justify-center auth-mode-label select-none">
|
||||
<div className="flex items-center justify-center auth-mode-label select-none" data-testid="auth-mode-label">
|
||||
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
|
||||
@@ -5,10 +5,11 @@ 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: 'http', requestUrl: '' };
|
||||
const initialPresets = { requestType: DEFAULT_PRESET_REQUEST_TYPE, requestUrl: '' };
|
||||
|
||||
// Get presets from draft.brunoConfig if it exists, otherwise from brunoConfig
|
||||
const currentPresets = collection.draft?.brunoConfig
|
||||
@@ -47,12 +48,13 @@ 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="http"
|
||||
checked={(currentPresets.requestType || 'http') === 'http'}
|
||||
value={PRESET_REQUEST_TYPES.HTTP}
|
||||
checked={(currentPresets.requestType || DEFAULT_PRESET_REQUEST_TYPE) === PRESET_REQUEST_TYPES.HTTP}
|
||||
/>
|
||||
<label htmlFor="http" className="ml-1 cursor-pointer select-none">
|
||||
HTTP
|
||||
@@ -60,12 +62,13 @@ const PresetsSettings = ({ collection }) => {
|
||||
|
||||
<input
|
||||
id="graphql"
|
||||
data-testid="presets-request-type-graphql"
|
||||
className="ml-4 cursor-pointer"
|
||||
type="radio"
|
||||
name="requestType"
|
||||
onChange={handleRequestTypeChange}
|
||||
value="graphql"
|
||||
checked={(currentPresets.requestType || 'http') === 'graphql'}
|
||||
value={PRESET_REQUEST_TYPES.GRAPHQL}
|
||||
checked={(currentPresets.requestType || DEFAULT_PRESET_REQUEST_TYPE) === PRESET_REQUEST_TYPES.GRAPHQL}
|
||||
/>
|
||||
<label htmlFor="graphql" className="ml-1 cursor-pointer select-none">
|
||||
GraphQL
|
||||
@@ -73,12 +76,13 @@ const PresetsSettings = ({ collection }) => {
|
||||
|
||||
<input
|
||||
id="grpc"
|
||||
data-testid="presets-request-type-grpc"
|
||||
className="ml-4 cursor-pointer"
|
||||
type="radio"
|
||||
name="requestType"
|
||||
onChange={handleRequestTypeChange}
|
||||
value="grpc"
|
||||
checked={(currentPresets.requestType || 'http') === 'grpc'}
|
||||
value={PRESET_REQUEST_TYPES.GRPC}
|
||||
checked={(currentPresets.requestType || DEFAULT_PRESET_REQUEST_TYPE) === PRESET_REQUEST_TYPES.GRPC}
|
||||
/>
|
||||
<label htmlFor="grpc" className="ml-1 cursor-pointer select-none">
|
||||
gRPC
|
||||
@@ -86,12 +90,13 @@ const PresetsSettings = ({ collection }) => {
|
||||
|
||||
<input
|
||||
id="ws"
|
||||
data-testid="presets-request-type-ws"
|
||||
className="ml-4 cursor-pointer"
|
||||
type="radio"
|
||||
name="requestType"
|
||||
onChange={handleRequestTypeChange}
|
||||
value="ws"
|
||||
checked={(currentPresets.requestType || 'http') === 'ws'}
|
||||
value={PRESET_REQUEST_TYPES.WS}
|
||||
checked={(currentPresets.requestType || DEFAULT_PRESET_REQUEST_TYPE) === PRESET_REQUEST_TYPES.WS}
|
||||
/>
|
||||
<label htmlFor="ws" className="ml-1 cursor-pointer select-none">
|
||||
WebSocket
|
||||
@@ -106,6 +111,7 @@ 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"
|
||||
@@ -123,7 +129,7 @@ const PresetsSettings = ({ collection }) => {
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button type="button" size="sm" onClick={handleSave}>
|
||||
<Button type="button" size="sm" data-testid="presets-save-btn" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -108,39 +109,53 @@ const Script = ({ collection }) => {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pre-request" className="mt-2" dataTestId="collection-pre-request-script-editor">
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<TabsContent value="post-response" className="mt-2" dataTestId="collection-post-response-script-editor">
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useRef } 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';
|
||||
@@ -32,21 +33,24 @@ const Tests = ({ collection }) => {
|
||||
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>
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button type="submit" size="sm" onClick={handleSave}>
|
||||
|
||||
@@ -15,6 +15,7 @@ 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();
|
||||
@@ -60,7 +61,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.requestUrl !== '';
|
||||
const hasPresets = presets && ((presets.requestType && presets.requestType !== DEFAULT_PRESET_REQUEST_TYPE) || (presets.requestUrl && presets.requestUrl !== ''));
|
||||
|
||||
const getTabPanel = (tab) => {
|
||||
switch (tab) {
|
||||
|
||||
@@ -168,6 +168,17 @@ 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;
|
||||
@@ -177,16 +188,11 @@ const EditableTable = ({
|
||||
return [createEmptyRow()];
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
if (!emptyRowUidRef.current || rows.some((r) => r.uid === emptyRowUidRef.current)) {
|
||||
@@ -198,15 +204,11 @@ const EditableTable = ({
|
||||
[checkboxKey]: true,
|
||||
...defaultRow
|
||||
}];
|
||||
}, [rows, columns, defaultRow, checkboxKey, createEmptyRow, showAddRow]);
|
||||
}, [rows, columns, defaultRow, checkboxKey, createEmptyRow, hasAnyValue, showAddRow]);
|
||||
|
||||
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]);
|
||||
// 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 isLastEmptyRow = useCallback((row, index) => {
|
||||
if (!showAddRow) return false;
|
||||
@@ -227,50 +229,20 @@ const EditableTable = ({
|
||||
const rowIndex = rowsWithEmpty.findIndex((r) => r.uid === rowUid);
|
||||
if (rowIndex === -1) return;
|
||||
|
||||
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) => {
|
||||
const updatedRows = rowsWithEmpty.map((row) => {
|
||||
if (row.uid === rowUid) {
|
||||
return { ...row, [key]: value };
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
// 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;
|
||||
});
|
||||
// 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;
|
||||
|
||||
onChange(result);
|
||||
}, [rowsWithEmpty, columns, onChange, checkboxKey, defaultRow, isEmptyRow, showAddRow]);
|
||||
}, [rowsWithEmpty, hasAnyValue, onChange, showAddRow]);
|
||||
|
||||
const handleCheckboxChange = useCallback((rowUid, checked) => {
|
||||
handleValueChange(rowUid, checkboxKey, checked);
|
||||
|
||||
@@ -151,17 +151,21 @@ const EnvironmentVariablesTable = ({
|
||||
const prevEnvVariablesRef = useRef(environment.variables);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
let _collection = collection ? cloneDeep(collection) : {};
|
||||
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
|
||||
if (_collection) {
|
||||
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
|
||||
}
|
||||
|
||||
// When collection is null (global/workspace environments), populate process env
|
||||
// variables from the active workspace so that {{process.env.X}} can resolve
|
||||
if (!collection && activeWorkspace?.processEnvVariables) {
|
||||
_collection.workspaceProcessEnvVariables = activeWorkspace.processEnvVariables;
|
||||
}
|
||||
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]);
|
||||
|
||||
const initialValues = useMemo(() => {
|
||||
const vars = environment.variables || [];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import path from 'utils/common/path';
|
||||
import { getRelativePathWithinBasePath } from 'utils/common/path';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { IconX, IconUpload, IconFile } from '@tabler/icons';
|
||||
@@ -48,13 +48,7 @@ const FilePickerEditor = ({
|
||||
// If file is in the collection's directory, then we use relative path
|
||||
// Otherwise, we use the absolute path
|
||||
filePaths = filePaths.map((filePath) => {
|
||||
const collectionDir = collection.pathname;
|
||||
|
||||
if (filePath.startsWith(collectionDir)) {
|
||||
return path.relative(collectionDir, filePath);
|
||||
}
|
||||
|
||||
return filePath;
|
||||
return getRelativePathWithinBasePath(collection.pathname, filePath);
|
||||
});
|
||||
|
||||
onChange(isSingleFilePicker ? filePaths[0] : filePaths);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
@@ -18,8 +18,9 @@ import OAuth1 from 'components/RequestPane/Auth/OAuth1';
|
||||
import WsseAuth from 'components/RequestPane/Auth/WsseAuth';
|
||||
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
|
||||
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
|
||||
import { humanizeRequestAuthMode, getTreePathFromCollectionToItem } from 'utils/collections/index';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections/index';
|
||||
import Button from 'ui/Button';
|
||||
import { getEffectiveAuthSource } from 'utils/auth';
|
||||
|
||||
const GrantTypeComponentMap = ({ collection, folder, updateFolderAuth }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -52,41 +53,6 @@ const Auth = ({ collection, folder }) => {
|
||||
let request = get(folderRoot, 'request', {});
|
||||
const authMode = get(folderRoot, 'request.auth.mode');
|
||||
|
||||
const getEffectiveAuthSource = () => {
|
||||
if (authMode !== 'inherit') return null;
|
||||
|
||||
const collectionRoot = collection?.draft?.root || collection?.root || {};
|
||||
const collectionAuth = get(collectionRoot, 'request.auth');
|
||||
let effectiveSource = {
|
||||
type: 'collection',
|
||||
name: 'Collection',
|
||||
auth: collectionAuth
|
||||
};
|
||||
|
||||
// Get path from collection to current folder
|
||||
const folderTreePath = getTreePathFromCollectionToItem(collection, folder);
|
||||
|
||||
// Check parent folders to find closest auth configuration
|
||||
// Skip the last item which is the current folder
|
||||
for (let i = 0; i < folderTreePath.length - 1; i++) {
|
||||
const parentFolder = folderTreePath[i];
|
||||
if (parentFolder.type === 'folder') {
|
||||
const parentFolderRoot = parentFolder?.draft || parentFolder?.root;
|
||||
const folderAuth = get(parentFolderRoot, 'request.auth');
|
||||
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'inherit') {
|
||||
effectiveSource = {
|
||||
type: 'folder',
|
||||
name: parentFolder.name,
|
||||
auth: folderAuth
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return effectiveSource;
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
dispatch(saveFolderRoot(collection.uid, folder.uid));
|
||||
};
|
||||
@@ -98,6 +64,11 @@ const Auth = ({ collection, folder }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const inheritedSource = useMemo(
|
||||
() => (authMode === 'inherit' ? getEffectiveAuthSource(collection, folder) : null),
|
||||
[authMode, folder, collection]
|
||||
);
|
||||
|
||||
const getAuthView = () => {
|
||||
switch (authMode) {
|
||||
case 'basic': {
|
||||
@@ -202,12 +173,11 @@ const Auth = ({ collection, folder }) => {
|
||||
);
|
||||
}
|
||||
case 'inherit': {
|
||||
const source = getEffectiveAuthSource();
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row w-full mt-2 gap-2">
|
||||
<div>Auth inherited from {source.name}: </div>
|
||||
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
|
||||
<div>Auth inherited from {inheritedSource.name}: </div>
|
||||
<div className="inherit-mode-text" data-testid="inherited-auth-mode">{humanizeRequestAuthMode(inheritedSource.auth?.mode)}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -81,14 +81,15 @@ const AuthMode = ({ collection, folder }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
|
||||
<div className="inline-flex items-center cursor-pointer auth-mode-selector" data-testid="auth-mode-selector">
|
||||
<MenuDropdown
|
||||
items={menuItems}
|
||||
placement="bottom-end"
|
||||
selectedItemId={authMode}
|
||||
showTickMark={true}
|
||||
data-testid="auth-mode-dropdown"
|
||||
>
|
||||
<div className="flex items-center justify-center auth-mode-label select-none">
|
||||
<div className="flex items-center justify-center auth-mode-label select-none" data-testid="auth-mode-label">
|
||||
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { updateFolderRequestScript, updateFolderResponseScript } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
@@ -111,39 +112,53 @@ const Script = ({ collection, folder }) => {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pre-request" className="mt-2" dataTestId="folder-pre-request-script-editor">
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
docKey="folder-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}
|
||||
/>
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
docKey="folder-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>
|
||||
|
||||
<TabsContent value="post-response" className="mt-2" dataTestId="folder-post-response-script-editor">
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
docKey="folder-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}
|
||||
/>
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
docKey="folder-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>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { updateFolderTests } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
@@ -33,21 +34,24 @@ const Tests = ({ collection, folder }) => {
|
||||
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>
|
||||
<CodeEditor
|
||||
ref={testsEditorRef}
|
||||
collection={collection}
|
||||
docKey="folder-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}
|
||||
/>
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={testsEditorRef}
|
||||
collection={collection}
|
||||
docKey="folder-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>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button type="submit" size="sm" onClick={handleSave}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { updatedFolderSettingsSelectedTab } from 'providers/ReduxStore/slices/collections';
|
||||
import { useDispatch } from 'react-redux';
|
||||
@@ -10,7 +10,7 @@ import Vars from './Vars';
|
||||
import Documentation from './Documentation';
|
||||
import Auth from './Auth';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
import get from 'lodash/get';
|
||||
import { hasEffectiveAuth } from 'utils/auth';
|
||||
|
||||
const FolderSettings = ({ collection, folder }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -31,8 +31,11 @@ const FolderSettings = ({ collection, folder }) => {
|
||||
const responseVars = folderRoot?.request?.vars?.res || [];
|
||||
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
|
||||
|
||||
const auth = get(folderRoot, 'request.auth.mode');
|
||||
const hasAuth = auth && auth !== 'none';
|
||||
const folderAuthMode = folder?.draft?.request?.auth?.mode ?? folder?.root?.request?.auth?.mode;
|
||||
const hasAuth = useMemo(
|
||||
() => hasEffectiveAuth(collection, folder),
|
||||
[folder, folderAuthMode, collection]
|
||||
);
|
||||
|
||||
const setTab = (tab) => {
|
||||
dispatch(
|
||||
@@ -95,7 +98,7 @@ const FolderSettings = ({ collection, folder }) => {
|
||||
</div>
|
||||
<div className={getTabClassname('auth')} role="tab" data-testid="folder-settings-tab-auth" onClick={() => setTab('auth')}>
|
||||
Auth
|
||||
{hasAuth && <StatusDot />}
|
||||
{hasAuth && <StatusDot dataTestId="auth" />}
|
||||
</div>
|
||||
<div className={getTabClassname('docs')} role="tab" data-testid="folder-settings-tab-docs" onClick={() => setTab('docs')}>
|
||||
Docs
|
||||
|
||||
@@ -16,6 +16,7 @@ import StyledWrapper from './StyledWrapper';
|
||||
import MenuDropdown from 'ui/MenuDropdown/index';
|
||||
import Button from 'ui/Button';
|
||||
import { getRevealInFolderLabel } from 'utils/common/platform';
|
||||
import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal';
|
||||
|
||||
const ManageWorkspace = () => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -157,6 +158,7 @@ const ManageWorkspace = () => {
|
||||
<MenuDropdown
|
||||
placement="bottom-end"
|
||||
items={[
|
||||
{ id: 'open-in-terminal', label: 'Open in Terminal', onClick: () => openDevtoolsAndSwitchToTerminal(dispatch, workspace.pathname) },
|
||||
{ id: 'rename', label: 'Rename', onClick: () => handleRenameClick(workspace) },
|
||||
{ id: 'remove', label: 'Remove', onClick: () => handleCloseClick(workspace) }
|
||||
]}
|
||||
|
||||
@@ -28,7 +28,9 @@ const ModalFooter = ({
|
||||
confirmDisabled,
|
||||
hideCancel,
|
||||
hideFooter,
|
||||
confirmButtonColor = 'primary'
|
||||
footerLeft,
|
||||
confirmButtonColor = 'primary',
|
||||
dataTestId = 'modal'
|
||||
}) => {
|
||||
confirmText = confirmText || 'Save';
|
||||
cancelText = cancelText || 'Cancel';
|
||||
@@ -38,23 +40,27 @@ const ModalFooter = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-end p-4 bruno-modal-footer">
|
||||
<span className={hideCancel ? 'hidden' : 'mr-2'}>
|
||||
<Button type="button" color="secondary" variant="ghost" onClick={handleCancel}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
</span>
|
||||
<span>
|
||||
<Button
|
||||
type="submit"
|
||||
color={confirmButtonColor}
|
||||
disabled={confirmDisabled}
|
||||
onClick={handleSubmit}
|
||||
className="submit"
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</span>
|
||||
<div className="flex justify-between items-center p-4 bruno-modal-footer">
|
||||
<div>{footerLeft}</div>
|
||||
<div className="flex justify-end">
|
||||
<span className={hideCancel ? 'hidden' : 'mr-2'}>
|
||||
<Button type="button" color="secondary" variant="ghost" onClick={handleCancel}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
</span>
|
||||
<span>
|
||||
<Button
|
||||
type="submit"
|
||||
color={confirmButtonColor}
|
||||
disabled={confirmDisabled}
|
||||
onClick={handleSubmit}
|
||||
className="submit"
|
||||
data-testid={`${dataTestId}-submit-btn`}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -72,6 +78,7 @@ const Modal = ({
|
||||
hideCancel,
|
||||
hideFooter,
|
||||
hideClose,
|
||||
footerLeft,
|
||||
disableCloseOnOutsideClick,
|
||||
disableEscapeKey,
|
||||
onClick,
|
||||
@@ -150,7 +157,9 @@ const Modal = ({
|
||||
confirmDisabled={confirmDisabled}
|
||||
hideCancel={hideCancel}
|
||||
hideFooter={hideFooter}
|
||||
footerLeft={footerLeft}
|
||||
confirmButtonColor={confirmButtonColor}
|
||||
dataTestId={dataTestId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -30,13 +30,16 @@ class MultiLineEditor extends Component {
|
||||
// Initialize CodeMirror as a single line editor
|
||||
/** @type {import("codemirror").Editor} */
|
||||
const variables = getAllVariables(this.props.collection, this.props.item);
|
||||
const runShortcut = () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
return;
|
||||
}
|
||||
return CodeMirror.Pass;
|
||||
};
|
||||
/**
|
||||
* 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 = () => {};
|
||||
|
||||
this.editor = CodeMirror(this.editorRef.current, {
|
||||
lineWrapping: false,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
|
||||
.file-chips-row {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: ${(props) => props.theme.text};
|
||||
max-width: 140px;
|
||||
min-width: 75px;
|
||||
flex: 0 1 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-chip-icon {
|
||||
flex: 0 0 auto;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.file-chip-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
}
|
||||
|
||||
.file-more-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.file-summary-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: pointer;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
|
||||
> span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
> svg {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:hover > span {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: ${(props) => props.theme.colors.text.muted};
|
||||
background: ${(props) => props.theme.requestTabs.icon.hoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s ease;
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const OverflowList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
min-width: 220px;
|
||||
max-width: 360px;
|
||||
|
||||
.overflow-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.requestTabs.icon.hoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
.overflow-row-icon {
|
||||
flex: 0 0 auto;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.overflow-row-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overflow-row-remove {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
@@ -0,0 +1,197 @@
|
||||
import React, { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { IconUpload, IconX, IconFile, IconChevronDown } from '@tabler/icons';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
import path, { normalizePath } from 'utils/common/path';
|
||||
import Wrapper, { OverflowList } from './StyledWrapper';
|
||||
|
||||
const basename = (filePath) => (filePath ? path.basename(normalizePath(String(filePath))) : '');
|
||||
|
||||
const FileEntry = ({ filePath, toolhintId, editMode, onRemove, variant }) => {
|
||||
const [overRemove, setOverRemove] = useState(false);
|
||||
const isChip = variant === 'chip';
|
||||
|
||||
return (
|
||||
<ToolHint
|
||||
text={overRemove ? 'Remove file' : filePath}
|
||||
toolhintId={toolhintId}
|
||||
place={overRemove ? 'bottom-end' : 'bottom-start'}
|
||||
positionStrategy="fixed"
|
||||
tooltipStyle={{ maxWidth: '320px', whiteSpace: 'normal', wordBreak: 'break-all' }}
|
||||
delayShow={overRemove ? 200 : 1000}
|
||||
className={isChip ? 'file-chip' : 'overflow-row'}
|
||||
dataTestId={isChip ? 'multipart-file-chip' : 'multipart-file-overflow-row'}
|
||||
>
|
||||
<IconFile size={14} stroke={1.5} className={isChip ? 'file-chip-icon' : 'overflow-row-icon'} />
|
||||
<span className={isChip ? 'file-chip-name' : 'overflow-row-name'}>
|
||||
{basename(filePath)}
|
||||
</span>
|
||||
{editMode && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={isChip ? 'multipart-file-chip-remove' : 'multipart-file-overflow-remove'}
|
||||
className={isChip ? 'file-chip-remove' : 'overflow-row-remove'}
|
||||
onMouseEnter={() => setOverRemove(true)}
|
||||
onMouseLeave={() => setOverRemove(false)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(filePath);
|
||||
}}
|
||||
>
|
||||
<IconX size={13} stroke={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</ToolHint>
|
||||
);
|
||||
};
|
||||
|
||||
// Keep in sync with the corresponding CSS values in StyledWrapper.js:
|
||||
// MIN_CHIP_W ↔ .file-chip { min-width: 75px }
|
||||
// CHIP_GAP ↔ .file-chips-row { gap: 4px }
|
||||
const MIN_CHIP_W = 75;
|
||||
const CHIP_GAP = 4;
|
||||
const UPLOAD_RESERVE = 28;
|
||||
const MORE_CHIP_RESERVE = 56;
|
||||
|
||||
const MultipartFileChipsCell = ({ files, onRemove, onAdd, editMode = true }) => {
|
||||
const containerRef = useRef(null);
|
||||
const tooltipPrefix = useRef(`mp-tip-${Math.random().toString(36).slice(2, 10)}`).current;
|
||||
const [visibleCount, setVisibleCount] = useState(files.length);
|
||||
const [summaryOpen, setSummaryOpen] = useState(false);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
// Measure the td (column-width, stable) rather than the content-sized cell,
|
||||
// which would feed back on visibleCount.
|
||||
const td = container.closest('td') || container.parentElement;
|
||||
if (!td) return;
|
||||
|
||||
const compute = () => {
|
||||
const tdStyle = window.getComputedStyle(td);
|
||||
const padX = parseFloat(tdStyle.paddingLeft) + parseFloat(tdStyle.paddingRight);
|
||||
const total = td.clientWidth - padX;
|
||||
if (files.length === 0) {
|
||||
setVisibleCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const allAtMin = files.length * MIN_CHIP_W + Math.max(0, files.length - 1) * CHIP_GAP;
|
||||
if (allAtMin + UPLOAD_RESERVE <= total) {
|
||||
setVisibleCount(files.length);
|
||||
return;
|
||||
}
|
||||
|
||||
const available = total - UPLOAD_RESERVE - MORE_CHIP_RESERVE;
|
||||
const n = Math.max(0, Math.floor((available + CHIP_GAP) / (MIN_CHIP_W + CHIP_GAP)));
|
||||
setVisibleCount(n);
|
||||
};
|
||||
|
||||
compute();
|
||||
const ro = new ResizeObserver(compute);
|
||||
ro.observe(td);
|
||||
return () => ro.disconnect();
|
||||
}, [files]);
|
||||
|
||||
const visible = files.slice(0, visibleCount);
|
||||
const overflow = files.slice(visibleCount);
|
||||
const collapsed = visibleCount === 0 && files.length > 0;
|
||||
|
||||
const renderChip = (filePath, idx) => (
|
||||
<FileEntry
|
||||
key={`${filePath}-${idx}`}
|
||||
variant="chip"
|
||||
filePath={filePath}
|
||||
toolhintId={`${tooltipPrefix}-chip-${idx}`}
|
||||
editMode={editMode}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderOverflowList = (list) => (
|
||||
<OverflowList>
|
||||
{list.map((p, i) => (
|
||||
<FileEntry
|
||||
key={`o-${p}-${i}`}
|
||||
variant="overflow"
|
||||
filePath={p}
|
||||
toolhintId={`${tooltipPrefix}-overflow-${i}`}
|
||||
editMode={editMode}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
))}
|
||||
</OverflowList>
|
||||
);
|
||||
|
||||
return (
|
||||
<Wrapper className="file-value-cell" ref={containerRef}>
|
||||
{collapsed ? (
|
||||
<>
|
||||
<Dropdown
|
||||
placement="bottom-start"
|
||||
appendTo={() => document.body}
|
||||
onMount={() => setSummaryOpen(true)}
|
||||
onHidden={() => setSummaryOpen(false)}
|
||||
icon={(
|
||||
<button
|
||||
type="button"
|
||||
data-testid="multipart-file-summary"
|
||||
className="file-summary-chip"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={`${files.length} file${files.length > 1 ? 's' : ''}`}
|
||||
>
|
||||
<IconFile size={14} stroke={1.5} className="file-chip-icon" />
|
||||
<span>{files.length} file{files.length > 1 ? 's' : ''}</span>
|
||||
<IconChevronDown size={14} stroke={1.5} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{summaryOpen ? renderOverflowList(files) : null}
|
||||
</Dropdown>
|
||||
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="file-chips-row">
|
||||
{visible.map((p, i) => renderChip(p, i))}
|
||||
</div>
|
||||
{overflow.length > 0 && (
|
||||
<Dropdown
|
||||
placement="bottom-end"
|
||||
appendTo={() => document.body}
|
||||
onMount={() => setMoreOpen(true)}
|
||||
onHidden={() => setMoreOpen(false)}
|
||||
icon={(
|
||||
<button
|
||||
type="button"
|
||||
data-testid="multipart-file-more"
|
||||
className="file-more-chip"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={`${overflow.length} more file${overflow.length > 1 ? 's' : ''}`}
|
||||
>
|
||||
+{overflow.length} more
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{moreOpen ? renderOverflowList(overflow) : null}
|
||||
</Dropdown>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{editMode && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="multipart-file-upload"
|
||||
className="upload-btn ml-1"
|
||||
onClick={onAdd}
|
||||
title="Add files"
|
||||
>
|
||||
<IconUpload size={16} />
|
||||
</button>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultipartFileChipsCell;
|
||||
@@ -96,7 +96,7 @@ const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, error, setError,
|
||||
className="url-input file-pick-btn"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{sourceUrl ? sourceUrl.split(/[\\/]/).pop() : 'Choose file...'}
|
||||
{sourceUrl ? sourceUrl.split(/[\\/]/).pop() : 'Select File'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -101,7 +101,7 @@ const ConnectionSettingsModal = ({ collection, sourceUrl, onSave, onDisconnect,
|
||||
className="settings-input file-pick-btn"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{filePath ? filePath.split(/[\\/]/).pop() : 'Choose file...'}
|
||||
{filePath ? filePath.split(/[\\/]/).pop() : 'Select File'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -687,7 +687,7 @@ const StyledWrapper = styled.div`
|
||||
background: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&.active {
|
||||
background: ${(props) => props.theme.colors.text.green};
|
||||
background: ${(props) => props.theme.button2.color.primary.bg};
|
||||
}
|
||||
|
||||
.toggle-knob {
|
||||
@@ -724,9 +724,9 @@ const StyledWrapper = styled.div`
|
||||
transition: all 0.15s;
|
||||
|
||||
&.active {
|
||||
border-color: ${(props) => props.theme.button2.color.primary.border};
|
||||
background: ${(props) => props.theme.button2.color.primary.bg};
|
||||
color: ${(props) => props.theme.button2.color.primary.text};
|
||||
border-color: ${(props) => props.theme.accents.primary};
|
||||
background: ${(props) => rgba(props.theme.accents.primary, 0.07)};
|
||||
color: ${(props) => props.theme.accents.primary};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ const useOpenAPISync = (collection) => {
|
||||
uid: itemUid,
|
||||
collectionUid: collection.uid,
|
||||
requestPaneTab: item ? getDefaultRequestPaneTab(item) : undefined,
|
||||
type: 'request'
|
||||
type: item?.type ?? 'request'
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
334
packages/bruno-app/src/components/Preferences/AI/ProviderCard.js
Normal file
334
packages/bruno-app/src/components/Preferences/AI/ProviderCard.js
Normal file
@@ -0,0 +1,334 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconBolt,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconLoader2,
|
||||
IconPencil,
|
||||
IconTrash,
|
||||
IconX
|
||||
} from '@tabler/icons';
|
||||
import toast from 'react-hot-toast';
|
||||
import { clearAiApiKey, getAiApiKey, setAiApiKey, testAiProvider } from 'utils/ai';
|
||||
|
||||
const OpenAiLogo = (props) => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.8956zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AnthropicLogo = (props) => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
<path d="M17.304 3.541h-3.672l6.696 16.918h3.672l-6.696-16.918Zm-10.608 0L0 20.459h3.744l1.368-3.584h6.624l1.368 3.584h3.744L10.152 3.54H6.696Zm.432 10.418 2.208-5.784 2.208 5.784H7.128Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PROVIDER_LOGOS = {
|
||||
openai: OpenAiLogo,
|
||||
anthropic: AnthropicLogo
|
||||
};
|
||||
|
||||
const stopBubble = (e) => e.stopPropagation();
|
||||
|
||||
const ProviderCard = ({
|
||||
provider,
|
||||
providerEnabled,
|
||||
providerToggle,
|
||||
models,
|
||||
isModelEnabled,
|
||||
onToggleModel,
|
||||
onStatusChange
|
||||
}) => {
|
||||
const Logo = PROVIDER_LOGOS[provider.id];
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [keyDraft, setKeyDraft] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [feedback, setFeedback] = useState(null);
|
||||
|
||||
const prev = useRef({ enabled: providerEnabled });
|
||||
useEffect(() => {
|
||||
const was = prev.current;
|
||||
if (!was.enabled && providerEnabled) {
|
||||
setExpanded(true);
|
||||
} else if (was.enabled && !providerEnabled) {
|
||||
setExpanded(false);
|
||||
}
|
||||
prev.current = { enabled: providerEnabled };
|
||||
}, [providerEnabled]);
|
||||
|
||||
const isEditing = editing || !provider.configured;
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmed = keyDraft.trim();
|
||||
if (!trimmed) return;
|
||||
setSaving(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
const status = await setAiApiKey({ providerId: provider.id, apiKey: trimmed });
|
||||
onStatusChange?.(status);
|
||||
setKeyDraft('');
|
||||
setShowKey(false);
|
||||
setEditing(false);
|
||||
setFeedback({ type: 'success', message: 'API key saved' });
|
||||
} catch (err) {
|
||||
setFeedback({ type: 'error', message: err.message || 'Failed to save API key' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
setFeedback(null);
|
||||
try {
|
||||
const status = await clearAiApiKey({ providerId: provider.id });
|
||||
onStatusChange?.(status);
|
||||
setEditing(false);
|
||||
setKeyDraft('');
|
||||
toast.success(`${provider.label} API key removed`);
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to clear API key');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
setTesting(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
const result = await testAiProvider({ providerId: provider.id });
|
||||
if (result.ok) {
|
||||
setFeedback({ type: 'success', message: 'Connection successful' });
|
||||
} else {
|
||||
setFeedback({ type: 'error', message: result.error || 'Connection failed' });
|
||||
}
|
||||
} catch (err) {
|
||||
setFeedback({ type: 'error', message: err.message || 'Connection failed' });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditing(false);
|
||||
setKeyDraft('');
|
||||
setShowKey(false);
|
||||
setFeedback(null);
|
||||
};
|
||||
|
||||
const handleStartEdit = async () => {
|
||||
setEditing(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
const current = await getAiApiKey({ providerId: provider.id });
|
||||
setKeyDraft(current || '');
|
||||
} catch (err) {
|
||||
// If we can't fetch it (decrypt failure etc.), leave the field empty.
|
||||
setKeyDraft('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (keyDraft.trim() && !saving) handleSave();
|
||||
} else if (e.key === 'Escape' && provider.configured) {
|
||||
e.preventDefault();
|
||||
handleCancelEdit();
|
||||
}
|
||||
};
|
||||
|
||||
const enabledModelsCount = models.filter((m) => isModelEnabled(m.id)).length;
|
||||
|
||||
return (
|
||||
<div className={`provider-row ${expanded ? 'expanded' : ''}`} data-testid={`ai-provider-${provider.id}`}>
|
||||
<div
|
||||
className="provider-header flex items-center justify-between gap-3 px-3 py-2.5 cursor-pointer select-none"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setExpanded(!expanded);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
{Logo ? <Logo className="provider-logo w-[18px] h-[18px] flex-shrink-0" /> : null}
|
||||
<span className="font-semibold text-[12.5px]">{provider.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 flex-shrink-0">
|
||||
<span className={`provider-status inline-flex items-center gap-1.5 text-[11px] ${provider.configured ? 'configured' : ''}`}>
|
||||
<span className={`status-dot w-[7px] h-[7px] rounded-full ${provider.configured ? 'configured' : ''}`} />
|
||||
{provider.configured
|
||||
? `${enabledModelsCount}/${models.length} models`
|
||||
: 'Not configured'}
|
||||
</span>
|
||||
<span className="flex items-center" onClick={stopBubble}>
|
||||
{providerToggle}
|
||||
</span>
|
||||
<span className={`chevron flex items-center ${expanded ? 'expanded' : ''}`}>
|
||||
<IconChevronDown size={16} strokeWidth={1.5} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`provider-body-wrapper ${expanded ? 'open' : ''}`}>
|
||||
<div className="provider-body-inner">
|
||||
<div className="provider-body flex flex-col gap-3.5 px-3 pt-3 pb-3">
|
||||
{/* API key */}
|
||||
<div>
|
||||
<div className="key-section-label flex items-center justify-between gap-2 text-[11px] mb-1">
|
||||
<span>API Key</span>
|
||||
</div>
|
||||
|
||||
{!isEditing ? (
|
||||
<div
|
||||
className="key-display-row flex items-center justify-between gap-2 h-8 box-border pl-2.5 pr-0.5"
|
||||
onClick={stopBubble}
|
||||
>
|
||||
<span className="key-display-mask text-xs">••••••••••••••••</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
|
||||
onClick={handleTest}
|
||||
disabled={testing || !providerEnabled}
|
||||
title="Test connection"
|
||||
aria-label="Test connection"
|
||||
>
|
||||
{testing ? <IconLoader2 size={15} className="spin" /> : <IconBolt size={15} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
|
||||
onClick={handleStartEdit}
|
||||
title="Replace key"
|
||||
aria-label="Replace key"
|
||||
>
|
||||
<IconPencil size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon danger w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
|
||||
onClick={handleClear}
|
||||
title="Remove key"
|
||||
aria-label="Remove key"
|
||||
>
|
||||
<IconTrash size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5" onClick={stopBubble}>
|
||||
<div className="relative flex-1 flex items-center">
|
||||
<input
|
||||
id={`api-key-${provider.id}`}
|
||||
type={showKey ? 'text' : 'password'}
|
||||
className="key-input w-full h-8 box-border text-xs leading-none pl-2.5 pr-8"
|
||||
placeholder={provider.apiKeyPlaceholder}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
value={keyDraft}
|
||||
onChange={(e) => setKeyDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={stopBubble}
|
||||
autoFocus
|
||||
data-testid={`ai-provider-${provider.id}-key-input`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="key-eye-btn absolute right-1 p-1 inline-flex items-center cursor-pointer"
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
tabIndex={-1}
|
||||
aria-label={showKey ? 'Hide API key' : 'Show API key'}
|
||||
>
|
||||
{showKey ? <IconEyeOff size={14} /> : <IconEye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary h-8 box-border px-3 text-xs font-medium inline-flex items-center justify-center gap-1 cursor-pointer"
|
||||
disabled={saving || !keyDraft.trim()}
|
||||
onClick={handleSave}
|
||||
data-testid={`ai-provider-${provider.id}-save`}
|
||||
>
|
||||
{saving ? <IconLoader2 size={13} className="spin" /> : <IconCheck size={13} />}
|
||||
Save
|
||||
</button>
|
||||
{provider.configured && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
|
||||
onClick={handleCancelEdit}
|
||||
title="Cancel"
|
||||
>
|
||||
<IconX size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feedback && (
|
||||
<div
|
||||
className={`feedback flex items-center gap-1.5 text-[11px] px-2 py-1 mt-1.5 ${feedback.type}`}
|
||||
role="status"
|
||||
>
|
||||
{feedback.type === 'success' ? <IconCheck size={12} /> : <IconAlertCircle size={12} />}
|
||||
{feedback.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Models */}
|
||||
{models.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="models-label-row flex items-center justify-between text-[11px]">
|
||||
<span>Models</span>
|
||||
{!provider.configured && (
|
||||
<span className="keyless-hint flex items-center gap-1.5 text-[11px] py-1">
|
||||
<IconAlertCircle size={12} />
|
||||
Add an API key to enable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{models.map((model) => {
|
||||
const enabled = isModelEnabled(model.id);
|
||||
const disabled = !provider.configured || !providerEnabled;
|
||||
return (
|
||||
<label
|
||||
key={model.id}
|
||||
className={`model-chip flex items-center gap-2 px-2.5 py-1.5 cursor-pointer select-none ${enabled && !disabled ? 'selected' : ''} ${disabled ? 'disabled' : ''}`}
|
||||
onClick={stopBubble}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="cursor-pointer m-0"
|
||||
checked={enabled}
|
||||
disabled={disabled}
|
||||
onChange={() => onToggleModel(model.id, !enabled)}
|
||||
/>
|
||||
<span className="text-xs">{model.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProviderCard;
|
||||
@@ -0,0 +1,243 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.ai-master {
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.md};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
}
|
||||
|
||||
.ai-master-icon {
|
||||
color: ${(props) => props.theme.colors.accent};
|
||||
}
|
||||
|
||||
.ai-master-summary {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.ai-section-header {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.ai-empty-notice {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
border: 1px dashed ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.md};
|
||||
}
|
||||
|
||||
.provider-row {
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.md};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&.expanded {
|
||||
border-color: ${(props) => props.theme.colors.accent}80;
|
||||
}
|
||||
}
|
||||
|
||||
.provider-header {
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.colors.accent}08;
|
||||
}
|
||||
}
|
||||
|
||||
.provider-logo {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.provider-status {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&.configured {
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
background: ${(props) => props.theme.input.border};
|
||||
|
||||
&.configured {
|
||||
background: ${(props) => props.theme.colors.text.green};
|
||||
box-shadow: 0 0 0 2px ${(props) => props.theme.colors.text.green}25;
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth expand/collapse using grid-template-rows trick */
|
||||
.provider-body-wrapper {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.2s ease;
|
||||
|
||||
&.open {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.provider-body-inner {
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.provider-body {
|
||||
border-top: 1px solid ${(props) => props.theme.input.border};
|
||||
}
|
||||
|
||||
.key-section-label {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.key-input {
|
||||
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
}
|
||||
}
|
||||
|
||||
.key-eye-btn {
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.colors.accent}10;
|
||||
}
|
||||
}
|
||||
|
||||
.key-display-row {
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
}
|
||||
|
||||
.key-display-mask {
|
||||
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid ${(props) => props.theme.colors.accent};
|
||||
background: ${(props) => props.theme.colors.accent};
|
||||
color: white;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${(props) => props.theme.colors.accent}10;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&.danger:hover:not(:disabled) {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
background: ${(props) => props.theme.colors.bg.danger}15;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.feedback {
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
|
||||
&.success {
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
background: ${(props) => props.theme.colors.text.green}10;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
background: ${(props) => props.theme.colors.bg.danger}15;
|
||||
}
|
||||
}
|
||||
|
||||
.models-label-row {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.model-chip {
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid transparent;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
background: ${(props) => props.theme.colors.accent}08;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: ${(props) => props.theme.input.border};
|
||||
background: ${(props) => props.theme.colors.accent}06;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
|
||||
input,
|
||||
label {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.keyless-hint {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
202
packages/bruno-app/src/components/Preferences/AI/index.js
Normal file
202
packages/bruno-app/src/components/Preferences/AI/index.js
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useFormik } from 'formik';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import * as Yup from 'yup';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IconStars } from '@tabler/icons';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import ToggleSwitch from 'components/ToggleSwitch';
|
||||
import { getAiStatus } from 'utils/ai';
|
||||
import ProviderCard from './ProviderCard';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const aiPreferencesSchema = Yup.object().shape({
|
||||
enabled: Yup.boolean(),
|
||||
providers: Yup.object(),
|
||||
models: Yup.object(),
|
||||
defaultModel: Yup.string().max(200).nullable()
|
||||
});
|
||||
|
||||
const AI = () => {
|
||||
const dispatch = useDispatch();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const [status, setStatus] = useState(null);
|
||||
const [statusError, setStatusError] = useState(null);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
try {
|
||||
const next = await getAiStatus();
|
||||
setStatus(next);
|
||||
setStatusError(null);
|
||||
} catch (err) {
|
||||
setStatusError(err.message || 'Failed to load AI status');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshStatus();
|
||||
}, [refreshStatus]);
|
||||
|
||||
const providerIds = status ? Object.keys(status.providers) : [];
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
enabled: get(preferences, 'ai.enabled', false),
|
||||
providers: providerIds.reduce((acc, id) => {
|
||||
acc[id] = { enabled: get(preferences, `ai.providers.${id}.enabled`, false) };
|
||||
return acc;
|
||||
}, {}),
|
||||
models: get(preferences, 'ai.models', {}),
|
||||
defaultModel: get(preferences, 'ai.defaultModel', '')
|
||||
},
|
||||
validationSchema: aiPreferencesSchema,
|
||||
onSubmit: () => {}
|
||||
});
|
||||
|
||||
const handleSave = useCallback(
|
||||
(values) => {
|
||||
dispatch(
|
||||
savePreferences({
|
||||
...preferences,
|
||||
ai: {
|
||||
enabled: values.enabled,
|
||||
providers: values.providers,
|
||||
models: values.models,
|
||||
defaultModel: values.defaultModel || ''
|
||||
}
|
||||
})
|
||||
).catch((err) => {
|
||||
console.error('Failed to save AI preferences:', err);
|
||||
toast.error('Failed to save AI preferences');
|
||||
});
|
||||
},
|
||||
[dispatch, preferences]
|
||||
);
|
||||
|
||||
const handleSaveRef = useRef(handleSave);
|
||||
handleSaveRef.current = handleSave;
|
||||
|
||||
const debouncedSave = useCallback(
|
||||
debounce((values) => {
|
||||
aiPreferencesSchema
|
||||
.validate(values, { abortEarly: true })
|
||||
.then((validated) => handleSaveRef.current(validated))
|
||||
.catch(() => {});
|
||||
}, 400),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (formik.dirty && formik.isValid) {
|
||||
debouncedSave(formik.values);
|
||||
}
|
||||
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
|
||||
|
||||
useEffect(() => () => debouncedSave.flush(), [debouncedSave]);
|
||||
|
||||
const modelsByProvider = useMemo(() => {
|
||||
const grouped = {};
|
||||
(status?.models || []).forEach((model) => {
|
||||
if (!grouped[model.provider]) grouped[model.provider] = [];
|
||||
grouped[model.provider].push(model);
|
||||
});
|
||||
return grouped;
|
||||
}, [status]);
|
||||
|
||||
const isModelEnabled = (modelId) => get(formik.values, `models.${modelId}.enabled`, true);
|
||||
|
||||
const handleToggleModel = (modelId, next) => {
|
||||
formik.setFieldValue(`models.${modelId}.enabled`, next);
|
||||
};
|
||||
|
||||
const summary = useMemo(() => {
|
||||
if (!status || !formik.values.enabled) return 'Turn on to configure providers and models';
|
||||
const usableProviders = Object.values(status.providers).filter(
|
||||
(p) => p.configured && formik.values.providers?.[p.id]?.enabled
|
||||
);
|
||||
if (usableProviders.length === 0) return 'Add a provider to get started';
|
||||
// Count models live from formik + current key status, not the electron-side
|
||||
// snapshot which lags behind toggle changes during the save debounce window.
|
||||
const totalEnabledModels = (status.models || []).filter((m) => {
|
||||
if (!formik.values.providers?.[m.provider]?.enabled) return false;
|
||||
if (!status.providers?.[m.provider]?.configured) return false;
|
||||
return isModelEnabled(m.id);
|
||||
}).length;
|
||||
const plural = (n, s) => `${n} ${s}${n === 1 ? '' : 's'}`;
|
||||
return `${plural(usableProviders.length, 'provider')} · ${plural(totalEnabledModels, 'model')} ready`;
|
||||
}, [status, formik.values.enabled, formik.values.providers, formik.values.models]);
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col text-xs min-h-0 max-h-[calc(100%-30px)]">
|
||||
<div className="section-header">AI</div>
|
||||
|
||||
<div className="ai-master flex items-center justify-between gap-4 px-3.5 py-3 mb-4">
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<div className="flex items-center gap-2 text-[13px] font-semibold">
|
||||
<IconStars size={15} strokeWidth={1.75} className="ai-master-icon" />
|
||||
<span>AI Features</span>
|
||||
</div>
|
||||
<span className="ai-master-summary text-[11px]">{summary}</span>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
size="m"
|
||||
isOn={formik.values.enabled}
|
||||
handleToggle={() => formik.setFieldValue('enabled', !formik.values.enabled)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{statusError && (
|
||||
<div className="ai-empty-notice px-3.5 py-3 text-xs" role="alert">
|
||||
{statusError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!formik.values.enabled && !statusError && (
|
||||
<div className="ai-empty-notice px-3.5 py-3 text-xs">
|
||||
Bring your own API key. Bruno talks to providers directly, your keys never leave your machine.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formik.values.enabled && status && (
|
||||
<>
|
||||
<div className="ai-section-header text-[11px] font-medium uppercase tracking-wider mt-[18px] mb-2">
|
||||
Providers
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{providerIds.map((id) => {
|
||||
const provider = status.providers[id];
|
||||
const providerEnabled = get(formik.values, `providers.${id}.enabled`, false);
|
||||
|
||||
const providerToggle = (
|
||||
<ToggleSwitch
|
||||
size="s"
|
||||
isOn={providerEnabled}
|
||||
handleToggle={() =>
|
||||
formik.setFieldValue(`providers.${id}.enabled`, !providerEnabled)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ProviderCard
|
||||
key={id}
|
||||
provider={provider}
|
||||
providerEnabled={providerEnabled}
|
||||
providerToggle={providerToggle}
|
||||
models={modelsByProvider[id] || []}
|
||||
isModelEnabled={isModelEnabled}
|
||||
onToggleModel={handleToggleModel}
|
||||
onStatusChange={(next) => setStatus(next)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default AI;
|
||||
@@ -233,7 +233,7 @@ const General = () => {
|
||||
disabled={formik.values.customCaCertificate.enabled ? false : true}
|
||||
onClick={() => inputFileCaCertificateRef.current.click()}
|
||||
>
|
||||
select file
|
||||
Select File
|
||||
<input
|
||||
id="caCertFilePath"
|
||||
type="file"
|
||||
|
||||
@@ -7,7 +7,7 @@ import StyledWrapper from '../StyledWrapper';
|
||||
const SystemProxy = () => {
|
||||
const dispatch = useDispatch();
|
||||
const systemProxyVariables = useSelector((state) => state.app.systemProxyVariables);
|
||||
const { source, http_proxy, https_proxy, no_proxy } = systemProxyVariables || {};
|
||||
const { source, http_proxy, https_proxy, no_proxy, pac_url } = systemProxyVariables || {};
|
||||
const [isFetching, setIsFetching] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
@@ -85,6 +85,12 @@ const SystemProxy = () => {
|
||||
</label>
|
||||
<div className="system-proxy-value">{no_proxy || '-'}</div>
|
||||
</div>
|
||||
<div className="mb-1 flex items-center">
|
||||
<label className="settings-label">
|
||||
pac_url
|
||||
</label>
|
||||
<div className="system-proxy-value">{pac_url || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="text-link cursor-pointer hover:underline default-collection-location-browse flex flex-row items-center"
|
||||
|
||||
@@ -3,11 +3,11 @@ import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import debounce from 'lodash/debounce';
|
||||
import toast from 'react-hot-toast';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import { savePreferences, refreshPacCache } from 'providers/ReduxStore/slices/app';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { IconEye, IconEyeOff } from '@tabler/icons';
|
||||
import { IconEye, IconEyeOff, IconRefresh } from '@tabler/icons';
|
||||
import { useState } from 'react';
|
||||
import SystemProxy from './SystemProxy';
|
||||
|
||||
@@ -103,6 +103,12 @@ const ProxySettings = ({ close }) => {
|
||||
[]
|
||||
);
|
||||
|
||||
const handleRefreshPac = () => {
|
||||
dispatch(refreshPacCache())
|
||||
.then(() => toast.success('PAC cache refreshed'))
|
||||
.catch(() => toast.error('Failed to refresh PAC cache'));
|
||||
};
|
||||
|
||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||
const [proxyMode, setProxyMode] = useState(() => {
|
||||
if (preferences.proxy.disabled) return 'off';
|
||||
@@ -439,7 +445,7 @@ const ProxySettings = ({ close }) => {
|
||||
>
|
||||
{formik.values.pac.source
|
||||
? decodeURIComponent(formik.values.pac.source.split('/').pop())
|
||||
: 'Choose file...'}
|
||||
: 'Select File'}
|
||||
</button>
|
||||
)}
|
||||
{formik.touched.pac?.source && formik.errors.pac?.source ? (
|
||||
@@ -451,6 +457,15 @@ const ProxySettings = ({ close }) => {
|
||||
? 'Enter the URL to your PAC file'
|
||||
: 'Supports .pac files for automatic proxy configuration'}
|
||||
</p>
|
||||
{formik.values.pac.source ? (
|
||||
<span
|
||||
className="text-link cursor-pointer hover:underline flex flex-row items-center w-fit mt-2"
|
||||
onClick={handleRefreshPac}
|
||||
>
|
||||
<IconRefresh size={14} strokeWidth={1.5} className="mr-1" />
|
||||
Refetch
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
IconKeyboard,
|
||||
IconZoomQuestion,
|
||||
IconSquareLetterB,
|
||||
IconDatabase
|
||||
IconDatabase,
|
||||
IconStars
|
||||
} from '@tabler/icons';
|
||||
|
||||
import Support from './Support';
|
||||
@@ -20,6 +21,7 @@ import Proxy from './ProxySettings';
|
||||
import Display from './Display';
|
||||
import Keybindings from './Keybindings';
|
||||
import Beta from './Beta';
|
||||
import AI from './AI';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Cache from './Cache/index';
|
||||
@@ -64,6 +66,10 @@ const Preferences = () => {
|
||||
return <Beta />;
|
||||
}
|
||||
|
||||
case 'ai': {
|
||||
return <AI />;
|
||||
}
|
||||
|
||||
case 'support': {
|
||||
return <Support />;
|
||||
}
|
||||
@@ -98,6 +104,10 @@ const Preferences = () => {
|
||||
<IconKeyboard size={16} strokeWidth={1.5} />
|
||||
Keybindings
|
||||
</div>
|
||||
<div className={getTabClassname('ai')} role="tab" onClick={() => setTab('ai')}>
|
||||
<IconStars size={16} strokeWidth={1.5} />
|
||||
AI
|
||||
</div>
|
||||
<div className={getTabClassname('cache')} role="tab" onClick={() => setTab('cache')}>
|
||||
<IconDatabase size={16} strokeWidth={1.5} />
|
||||
Cache
|
||||
|
||||
@@ -25,7 +25,7 @@ const ApiKeyAuth = ({ item, collection, updateAuth, request, save }) => {
|
||||
|
||||
const Icon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="flex items-center justify-end auth-type-label select-none">
|
||||
<div ref={ref} data-testid="auth-placement-label" className="flex items-center justify-end auth-type-label select-none">
|
||||
{humanizeRequestAPIKeyPlacement(apikeyAuth?.placement)}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
@@ -89,7 +89,7 @@ const ApiKeyAuth = ({ item, collection, updateAuth, request, save }) => {
|
||||
</div>
|
||||
|
||||
<label className="block mb-1">Add To</label>
|
||||
<div className="inline-flex items-center cursor-pointer auth-placement-selector w-fit">
|
||||
<div data-testid="auth-placement-selector" className="inline-flex items-center cursor-pointer auth-placement-selector w-fit">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
|
||||
@@ -81,14 +81,15 @@ const AuthMode = ({ item, collection }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
|
||||
<div className="inline-flex items-center cursor-pointer auth-mode-selector" data-testid="auth-mode-selector">
|
||||
<MenuDropdown
|
||||
items={menuItems}
|
||||
placement="bottom-end"
|
||||
selectedItemId={authMode}
|
||||
showTickMark={true}
|
||||
data-testid="auth-mode-dropdown"
|
||||
>
|
||||
<div className="flex items-center justify-center auth-mode-label select-none">
|
||||
<div className="flex items-center justify-center auth-mode-label select-none" data-testid="auth-mode-label">
|
||||
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
|
||||
@@ -256,7 +256,7 @@ const OAuth1 = ({ item = {}, collection, request, save, updateAuth }) => {
|
||||
<button
|
||||
className="flex items-center gap-1 oauth1-icon cursor-pointer text-link"
|
||||
onClick={handleBrowse}
|
||||
title="Select file"
|
||||
title="Select File"
|
||||
type="button"
|
||||
>
|
||||
<IconUpload size={14} />
|
||||
|
||||
@@ -80,6 +80,7 @@ const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
|
||||
{ id: 'implicit', label: 'Implicit', onClick: () => onGrantTypeChange('implicit') },
|
||||
{ id: 'client_credentials', label: 'Client Credentials', onClick: () => onGrantTypeChange('client_credentials') }
|
||||
]}
|
||||
data-testid="grant-type-dropdown"
|
||||
selectedItemId={oAuth?.grantType}
|
||||
placement="bottom-end"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import AwsV4Auth from './AwsV4Auth';
|
||||
import BearerAuth from './BearerAuth';
|
||||
@@ -15,22 +15,11 @@ import ApiKeyAuth from './ApiKeyAuth';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections';
|
||||
import OAuth2 from './OAuth2/index';
|
||||
import { findItemInCollection, findParentItemInCollection } from 'utils/collections/index';
|
||||
|
||||
const getTreePathFromCollectionToItem = (collection, _item) => {
|
||||
let path = [];
|
||||
let item = findItemInCollection(collection, _item?.uid);
|
||||
while (item) {
|
||||
path.unshift(item);
|
||||
item = findParentItemInCollection(collection, item?.uid);
|
||||
}
|
||||
return path;
|
||||
};
|
||||
import { getEffectiveAuthSource } from 'utils/auth';
|
||||
|
||||
const Auth = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
|
||||
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
|
||||
|
||||
// Create a request object to pass to the auth components
|
||||
const request = item.draft
|
||||
@@ -42,34 +31,10 @@ const Auth = ({ item, collection }) => {
|
||||
return dispatch(saveRequest(item.uid, collection.uid));
|
||||
};
|
||||
|
||||
const getEffectiveAuthSource = () => {
|
||||
if (authMode !== 'inherit') return null;
|
||||
|
||||
const collectionRoot = collection?.draft?.root || collection?.root || {};
|
||||
const collectionAuth = get(collectionRoot, 'request.auth');
|
||||
let effectiveSource = {
|
||||
type: 'collection',
|
||||
name: 'Collection',
|
||||
auth: collectionAuth
|
||||
};
|
||||
|
||||
// Check folders in reverse to find the closest auth configuration
|
||||
for (let i of [...requestTreePath].reverse()) {
|
||||
if (i.type === 'folder') {
|
||||
const folderAuth = get(i, 'root.request.auth');
|
||||
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'inherit') {
|
||||
effectiveSource = {
|
||||
type: 'folder',
|
||||
name: i.name,
|
||||
auth: folderAuth
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return effectiveSource;
|
||||
};
|
||||
const inheritedSource = useMemo(
|
||||
() => (authMode === 'inherit' ? getEffectiveAuthSource(collection, item) : null),
|
||||
[authMode, item, collection]
|
||||
);
|
||||
|
||||
const getAuthView = () => {
|
||||
switch (authMode) {
|
||||
@@ -104,12 +69,11 @@ const Auth = ({ item, collection }) => {
|
||||
return <ApiKeyAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
|
||||
}
|
||||
case 'inherit': {
|
||||
const source = getEffectiveAuthSource();
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row w-full gap-2">
|
||||
<div>Auth inherited from {source.name}: </div>
|
||||
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
|
||||
<div>Auth inherited from {inheritedSource.name}: </div>
|
||||
<div className="inherit-mode-text" data-testid="inherited-auth-mode">{humanizeRequestAuthMode(inheritedSource.auth?.mode)}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -24,10 +24,12 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
|
||||
import Documentation from 'components/Documentation/index';
|
||||
import useGraphqlSchema from '../GraphQLSchemaActions/useGraphqlSchema';
|
||||
import { findEnvironmentInCollection } from 'utils/collections';
|
||||
import { hasEffectiveAuth } from 'utils/auth';
|
||||
import HeightBoundContainer from 'ui/HeightBoundContainer';
|
||||
import Settings from 'components/RequestPane/Settings';
|
||||
import ResponsiveTabs from 'ui/ResponsiveTabs';
|
||||
import AuthMode from '../Auth/AuthMode/index';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
|
||||
const TAB_CONFIG = [
|
||||
{ key: 'query', label: 'Query' },
|
||||
@@ -172,7 +174,20 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
|
||||
[dispatch, item.uid]
|
||||
);
|
||||
|
||||
const allTabs = useMemo(() => TAB_CONFIG.map(({ key, label }) => ({ key, label })), []);
|
||||
const itemAuthMode = item.draft?.request?.auth?.mode ?? item.request?.auth?.mode ?? item.root?.request?.auth?.mode;
|
||||
const hasAuth = useMemo(
|
||||
() => hasEffectiveAuth(collection, item),
|
||||
[item, itemAuthMode, collection]
|
||||
);
|
||||
|
||||
const allTabs = useMemo(
|
||||
() => TAB_CONFIG.map(({ key, label }) => ({
|
||||
key,
|
||||
label,
|
||||
indicator: key === 'auth' && hasAuth ? <StatusDot dataTestId="auth" /> : null
|
||||
})),
|
||||
[hasAuth]
|
||||
);
|
||||
|
||||
const handlePrettify = useCallback(() => {
|
||||
if (queryEditorRef.current?.beautifyRequestBody) {
|
||||
|
||||
@@ -39,7 +39,7 @@ const MessageToolbar = ({
|
||||
</ToolHint>
|
||||
|
||||
<ToolHint text="Generate sample" toolhintId={`regenerate-msg-${index}`}>
|
||||
<button onClick={onRegenerateMessage} className="toolbar-btn">
|
||||
<button onClick={onRegenerateMessage} className="toolbar-btn" data-testid={`grpc-regenerate-message-${index}`}>
|
||||
<IconRefresh size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</ToolHint>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import GrpcAuthMode from './GrpcAuthMode';
|
||||
@@ -9,32 +9,32 @@ import OAuth2 from '../../Auth/OAuth2/index';
|
||||
import WsseAuth from '../../Auth/WsseAuth';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections';
|
||||
import { getTreePathFromCollectionToItem } from 'utils/collections/index';
|
||||
import { getEffectiveAuthSource } from 'utils/auth';
|
||||
import { updateRequestAuthMode, updateAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
|
||||
// List of auth modes supported by gRPC
|
||||
// Note: Only header-based auth modes work with gRPC
|
||||
// Complex auth modes like AWS Sig v4, Digest, and NTLM require axios interceptors
|
||||
// and cannot be supported in gRPC requests as of now
|
||||
const supportedGrpcAuthModes = ['basic', 'bearer', 'apikey', 'oauth2', 'wsse', 'none', 'inherit'];
|
||||
import { AUTH_MODES_GRPC } from 'utils/common/constants';
|
||||
|
||||
const GrpcAuth = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
|
||||
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
|
||||
|
||||
const request = item.draft
|
||||
? get(item, 'draft.request', {})
|
||||
: get(item, 'request', {});
|
||||
|
||||
const inheritedSource = useMemo(
|
||||
() => (authMode === 'inherit' ? getEffectiveAuthSource(collection, item) : null),
|
||||
[authMode, item, collection]
|
||||
);
|
||||
|
||||
const save = () => {
|
||||
return saveRequest(item.uid, collection.uid);
|
||||
};
|
||||
|
||||
// Reset to 'none' if current auth mode is not supported by gRPC
|
||||
useEffect(() => {
|
||||
if (authMode && !supportedGrpcAuthModes.includes(authMode)) {
|
||||
if (authMode && !AUTH_MODES_GRPC.includes(authMode)) {
|
||||
dispatch(
|
||||
updateRequestAuthMode({
|
||||
itemUid: item.uid,
|
||||
@@ -45,35 +45,6 @@ const GrpcAuth = ({ item, collection }) => {
|
||||
}
|
||||
}, [authMode, collection.uid, dispatch, item.uid]);
|
||||
|
||||
const getEffectiveAuthSource = () => {
|
||||
if (authMode !== 'inherit') return null;
|
||||
|
||||
const collectionRoot = collection?.draft?.root || collection?.root || {};
|
||||
const collectionAuth = get(collectionRoot, 'request.auth');
|
||||
let effectiveSource = {
|
||||
type: 'collection',
|
||||
name: 'Collection',
|
||||
auth: collectionAuth
|
||||
};
|
||||
|
||||
// Check folders in reverse to find the closest auth configuration
|
||||
for (let i of [...requestTreePath].reverse()) {
|
||||
if (i.type === 'folder') {
|
||||
const folderAuth = get(i, 'root.request.auth');
|
||||
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
|
||||
effectiveSource = {
|
||||
type: 'folder',
|
||||
name: i.name,
|
||||
auth: folderAuth
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return effectiveSource;
|
||||
};
|
||||
|
||||
const getAuthView = () => {
|
||||
switch (authMode) {
|
||||
case 'none': {
|
||||
@@ -95,15 +66,13 @@ const GrpcAuth = ({ item, collection }) => {
|
||||
return <WsseAuth collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
|
||||
}
|
||||
case 'inherit': {
|
||||
const source = getEffectiveAuthSource();
|
||||
|
||||
// Only show inherited auth if it's one of the supported types
|
||||
if (source && supportedGrpcAuthModes.includes(source.auth?.mode)) {
|
||||
if (inheritedSource && AUTH_MODES_GRPC.includes(inheritedSource.auth?.mode)) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row w-full gap-2">
|
||||
<div>Auth inherited from {source.name}: </div>
|
||||
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
|
||||
<div>Auth inherited from {inheritedSource.name}: </div>
|
||||
<div className="inherit-mode-text">{humanizeRequestAuthMode(inheritedSource.auth?.mode)}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,8 @@ import Documentation from 'components/Documentation/index';
|
||||
import { getPropertyFromDraftOrRequest } from 'utils/collections/index';
|
||||
import ResponsiveTabs from 'ui/ResponsiveTabs';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { hasEffectiveAuth } from 'utils/auth';
|
||||
import { AUTH_MODES_GRPC } from 'utils/common/constants';
|
||||
|
||||
const GrpcRequestPane = ({ item, collection, handleRun }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -53,8 +55,11 @@ const GrpcRequestPane = ({ item, collection, handleRun }) => {
|
||||
const body = getPropertyFromDraftOrRequest(item, 'request.body');
|
||||
const headers = getPropertyFromDraftOrRequest(item, 'request.headers');
|
||||
const docs = getPropertyFromDraftOrRequest(item, 'request.docs');
|
||||
const auth = getPropertyFromDraftOrRequest(item, 'request.auth');
|
||||
|
||||
const itemAuthMode = item.draft?.request?.auth?.mode ?? item.request?.auth?.mode ?? item.root?.request?.auth?.mode;
|
||||
const hasAuth = useMemo(
|
||||
() => hasEffectiveAuth(collection, item, AUTH_MODES_GRPC),
|
||||
[item, itemAuthMode, collection]
|
||||
);
|
||||
const activeHeadersLength = headers.filter((header) => header.enabled).length;
|
||||
const grpcMessagesCount = body?.grpc?.length || 0;
|
||||
|
||||
@@ -88,7 +93,7 @@ const GrpcRequestPane = ({ item, collection, handleRun }) => {
|
||||
{
|
||||
key: 'auth',
|
||||
label: 'Auth',
|
||||
indicator: auth?.mode && auth.mode !== 'none' ? <StatusDot type="default" /> : null
|
||||
indicator: hasAuth ? <StatusDot type="default" dataTestId="auth" /> : null
|
||||
},
|
||||
{
|
||||
key: 'docs',
|
||||
@@ -96,7 +101,7 @@ const GrpcRequestPane = ({ item, collection, handleRun }) => {
|
||||
indicator: docs && docs.length > 0 ? <StatusDot type="default" /> : null
|
||||
}
|
||||
];
|
||||
}, [grpcMessagesCount, isClientStreaming, activeHeadersLength, auth?.mode, docs]);
|
||||
}, [grpcMessagesCount, isClientStreaming, activeHeadersLength, hasAuth, docs]);
|
||||
|
||||
// Initialize tab to 'body' if no tab is currently set
|
||||
useEffect(() => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import StatusDot from 'components/StatusDot';
|
||||
import ResponsiveTabs from 'ui/ResponsiveTabs';
|
||||
import HeightBoundContainer from 'ui/HeightBoundContainer';
|
||||
import AuthMode from '../Auth/AuthMode/index';
|
||||
import { hasEffectiveAuth } from 'utils/auth';
|
||||
|
||||
const TAB_CONFIG = [
|
||||
{ key: 'params', label: 'Params' },
|
||||
@@ -54,7 +55,6 @@ const HttpRequestPane = ({ item, collection }) => {
|
||||
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const requestPaneTab = focusedTab?.requestPaneTab;
|
||||
|
||||
const getProperty = useCallback(
|
||||
(key) => (item.draft ? get(item, `draft.${key}`, []) : get(item, key, [])),
|
||||
[item.draft, item]
|
||||
@@ -86,6 +86,12 @@ const HttpRequestPane = ({ item, collection }) => {
|
||||
[dispatch, item.uid]
|
||||
);
|
||||
|
||||
const itemAuthMode = item.draft?.request?.auth?.mode ?? item.request?.auth?.mode ?? item.root?.request?.auth?.mode;
|
||||
const hasAuth = useMemo(
|
||||
() => hasEffectiveAuth(collection, item),
|
||||
[item, itemAuthMode, collection]
|
||||
);
|
||||
|
||||
const indicators = useMemo(() => {
|
||||
const hasScriptError = item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage;
|
||||
const hasTestError = item.testScriptErrorMessage;
|
||||
@@ -94,7 +100,7 @@ const HttpRequestPane = ({ item, collection }) => {
|
||||
params: activeCounts.params > 0 ? <sup className="font-medium">{activeCounts.params}</sup> : null,
|
||||
body: body.mode !== 'none' ? <StatusDot /> : null,
|
||||
headers: activeCounts.headers > 0 ? <sup className="font-medium">{activeCounts.headers}</sup> : null,
|
||||
auth: auth.mode !== 'none' ? <StatusDot /> : null,
|
||||
auth: hasAuth ? <StatusDot dataTestId="auth" /> : null,
|
||||
vars: activeCounts.vars > 0 ? <sup className="font-medium">{activeCounts.vars}</sup> : null,
|
||||
script: (script.req || script.res) ? (hasScriptError ? <StatusDot type="error" /> : <StatusDot />) : null,
|
||||
assert: activeCounts.assertions > 0 ? <sup className="font-medium">{activeCounts.assertions}</sup> : null,
|
||||
@@ -102,7 +108,7 @@ const HttpRequestPane = ({ item, collection }) => {
|
||||
docs: docs?.length > 0 ? <StatusDot /> : null,
|
||||
settings: tags?.length > 0 ? <StatusDot /> : null
|
||||
};
|
||||
}, [activeCounts, body.mode, auth.mode, script, item.preRequestScriptErrorMessage, item.postResponseScriptErrorMessage, item.testScriptErrorMessage, tests, docs, tags]);
|
||||
}, [activeCounts, body.mode, hasAuth, script, item.preRequestScriptErrorMessage, item.postResponseScriptErrorMessage, item.testScriptErrorMessage, tests, docs, tags]);
|
||||
|
||||
const allTabs = useMemo(
|
||||
() => TAB_CONFIG.map(({ key, label }) => ({ key, label, indicator: indicators[key] })),
|
||||
|
||||
@@ -13,6 +13,7 @@ const Wrapper = styled.div`
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s ease;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
@@ -23,15 +24,6 @@ const Wrapper = styled.div`
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
|
||||
.file-value-cell {
|
||||
width: 100%;
|
||||
|
||||
.file-name {
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.value-cell {
|
||||
width: 100%;
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { IconUpload, IconX, IconFile } from '@tabler/icons';
|
||||
import { IconUpload } from '@tabler/icons';
|
||||
import {
|
||||
moveMultipartFormParam,
|
||||
setMultipartFormParams
|
||||
@@ -10,14 +11,18 @@ import {
|
||||
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import MultipartFileChipsCell from 'components/MultipartFileChipsCell';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import path from 'utils/common/path';
|
||||
import path, { getRelativePathWithinBasePath, normalizePath } from 'utils/common/path';
|
||||
import { getMultipartAutoContentType } from 'utils/common/multipartContentType';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
import { isWindowsOS } from 'utils/common/platform';
|
||||
|
||||
const fileBasename = (filePath) =>
|
||||
filePath ? path.basename(normalizePath(String(filePath))) : '';
|
||||
|
||||
const MultipartFormParams = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -57,32 +62,53 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
}, [dispatch, collection.uid, item.uid]);
|
||||
|
||||
const handleBrowseFiles = useCallback((row, onChange) => {
|
||||
dispatch(browseFiles())
|
||||
dispatch(browseFiles([], ['multiSelections']))
|
||||
.then((filePaths) => {
|
||||
if (!Array.isArray(filePaths) || filePaths.length === 0) return;
|
||||
|
||||
const processedPaths = filePaths.map((filePath) => {
|
||||
const collectionDir = collection.pathname;
|
||||
if (filePath.startsWith(collectionDir)) {
|
||||
return path.relative(collectionDir, filePath);
|
||||
}
|
||||
return filePath;
|
||||
return getRelativePathWithinBasePath(collection.pathname, filePath);
|
||||
});
|
||||
|
||||
const currentParams = item.draft
|
||||
? get(item, 'draft.request.body.multipartForm')
|
||||
: get(item, 'request.body.multipartForm');
|
||||
const existsInParams = (currentParams || []).some((p) => p.uid === row.uid);
|
||||
const existingParam = (currentParams || []).find((p) => p.uid === row.uid);
|
||||
const existingValue = existingParam && existingParam.type === 'file' && Array.isArray(existingParam.value)
|
||||
? existingParam.value
|
||||
: [];
|
||||
const seen = new Set(existingValue);
|
||||
const merged = [...existingValue];
|
||||
const skipped = [];
|
||||
for (const p of processedPaths) {
|
||||
if (!seen.has(p)) {
|
||||
seen.add(p);
|
||||
merged.push(p);
|
||||
} else {
|
||||
skipped.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
if (skipped.length === 1) {
|
||||
toast(`"${fileBasename(skipped[0])}" is already added`);
|
||||
} else if (skipped.length > 1) {
|
||||
toast(`${skipped.length} files are already added — skipped`);
|
||||
}
|
||||
|
||||
const autoContentType = getMultipartAutoContentType(merged);
|
||||
|
||||
let updatedParams;
|
||||
if (existsInParams) {
|
||||
if (existingParam) {
|
||||
updatedParams = currentParams.map((p) => {
|
||||
if (p.uid === row.uid) {
|
||||
return { ...p, type: 'file', value: processedPaths };
|
||||
return { ...p, type: 'file', value: merged, contentType: autoContentType };
|
||||
}
|
||||
return p;
|
||||
});
|
||||
} else {
|
||||
updatedParams = [
|
||||
...(currentParams || []),
|
||||
{ uid: row.uid, name: row.name || '', enabled: true, type: 'file', value: processedPaths, contentType: '' }
|
||||
{ uid: row.uid, name: row.name || '', enabled: true, type: 'file', value: merged, contentType: autoContentType }
|
||||
];
|
||||
}
|
||||
handleParamsChange(updatedParams);
|
||||
@@ -92,13 +118,21 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
});
|
||||
}, [dispatch, collection.pathname, item, handleParamsChange]);
|
||||
|
||||
const handleClearFile = useCallback((row) => {
|
||||
const handleRemoveFile = useCallback((row, filePathToRemove) => {
|
||||
const currentParams = params || [];
|
||||
const target = currentParams.find((p) => p.uid === row.uid);
|
||||
if (!target || target.type !== 'file') return;
|
||||
const currentValue = Array.isArray(target.value)
|
||||
? target.value
|
||||
: (target.value ? [target.value] : []);
|
||||
const nextValue = currentValue.filter((p) => p !== filePathToRemove);
|
||||
|
||||
const updatedParams = currentParams.map((p) => {
|
||||
if (p.uid === row.uid) {
|
||||
return { ...p, type: 'text', value: '' };
|
||||
if (p.uid !== row.uid) return p;
|
||||
if (nextValue.length === 0) {
|
||||
return { ...p, type: 'text', value: '', contentType: '' };
|
||||
}
|
||||
return p;
|
||||
return { ...p, type: 'file', value: nextValue, contentType: getMultipartAutoContentType(nextValue) };
|
||||
});
|
||||
handleParamsChange(updatedParams);
|
||||
}, [params, handleParamsChange]);
|
||||
@@ -119,19 +153,12 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
}
|
||||
}, [params, handleParamsChange]);
|
||||
|
||||
const getFileName = (filePaths) => {
|
||||
const getFileList = (filePaths) => {
|
||||
if (!filePaths || (Array.isArray(filePaths) && filePaths.length === 0)) {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
|
||||
const validPaths = paths.filter((v) => v != null && v !== '');
|
||||
if (validPaths.length === 0) return null;
|
||||
|
||||
const separator = isWindowsOS() ? '\\' : '/';
|
||||
if (validPaths.length === 1) {
|
||||
return validPaths[0].split(separator).pop();
|
||||
}
|
||||
return `${validPaths.length} file(s)`;
|
||||
return paths.filter((v) => v != null && v !== '');
|
||||
};
|
||||
|
||||
const columns = [
|
||||
@@ -148,29 +175,14 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
placeholder: 'Value',
|
||||
width: '35%',
|
||||
render: ({ row, value, onChange }) => {
|
||||
const isFile = row.type === 'file';
|
||||
const fileName = isFile ? getFileName(value) : null;
|
||||
if (fileName) {
|
||||
const files = row.type === 'file' ? getFileList(value) : [];
|
||||
if (files.length > 0) {
|
||||
return (
|
||||
<div className="flex items-center file-value-cell">
|
||||
<IconFile size={16} className="text-muted mr-1" />
|
||||
<div className="file-name flex-1 truncate" title={Array.isArray(value) ? value.join(', ') : value}>
|
||||
<SingleLineEditor
|
||||
theme={storedTheme}
|
||||
value={fileName}
|
||||
readOnly={true}
|
||||
collection={collection}
|
||||
item={item}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="clear-file-btn ml-1"
|
||||
onClick={() => handleClearFile(row)}
|
||||
title="Remove file"
|
||||
>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<MultipartFileChipsCell
|
||||
files={files}
|
||||
onRemove={(filePath) => handleRemoveFile(row, filePath)}
|
||||
onAdd={() => handleBrowseFiles(row, onChange)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -190,9 +202,10 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
data-testid="multipart-file-upload"
|
||||
className="upload-btn ml-1"
|
||||
onClick={() => handleBrowseFiles(row, onChange)}
|
||||
title="Select file"
|
||||
title="Select File"
|
||||
>
|
||||
<IconUpload size={16} />
|
||||
</button>
|
||||
|
||||
@@ -16,6 +16,7 @@ import toast from 'react-hot-toast';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import onHasCompletion from './onHasCompletion';
|
||||
import { setupLinkAware } from 'utils/codemirror/linkAware';
|
||||
import { setupCodeMirrorResizeRefresh } from 'utils/codemirror/resize';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
|
||||
@@ -53,6 +54,16 @@ export default class QueryEditor extends React.Component {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const runShortcut = () => {};
|
||||
|
||||
const editor = (this.editor = CodeMirror(this._node, {
|
||||
value: this.props.value || '',
|
||||
lineNumbers: true,
|
||||
@@ -125,7 +136,9 @@ export default class QueryEditor extends React.Component {
|
||||
}
|
||||
},
|
||||
'Cmd-F': 'findPersistent',
|
||||
'Ctrl-F': 'findPersistent'
|
||||
'Ctrl-F': 'findPersistent',
|
||||
'Cmd-Enter': runShortcut,
|
||||
'Ctrl-Enter': runShortcut
|
||||
}
|
||||
}));
|
||||
if (editor) {
|
||||
@@ -137,6 +150,7 @@ export default class QueryEditor extends React.Component {
|
||||
this.addOverlay();
|
||||
|
||||
setupLinkAware(editor);
|
||||
this.cleanupResizeRefresh = setupCodeMirrorResizeRefresh(editor, this._node);
|
||||
|
||||
// Add mousetrap class so Mousetrap captures shortcuts even when CodeMirror is focused
|
||||
const cmInput = editor.getInputField();
|
||||
@@ -180,6 +194,7 @@ export default class QueryEditor extends React.Component {
|
||||
if (this.editor?._destroyLinkAware) {
|
||||
this.editor._destroyLinkAware();
|
||||
}
|
||||
this.cleanupResizeRefresh?.();
|
||||
this.editor.off('change', this._onEdit);
|
||||
this.editor.off('keyup', this._onKeyUp);
|
||||
this.editor.off('hasCompletion', this._onHasCompletion);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useMemo, 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 { buildRequestContextFromItem } from 'utils/ai';
|
||||
import { updateRequestScript, updateResponseScript } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
@@ -78,6 +80,8 @@ const Script = ({ item, collection }) => {
|
||||
const onRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
const requestContext = useMemo(() => buildRequestContextFromItem(item), [item]);
|
||||
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
const hasPostResponseScript = responseScript && responseScript.trim().length > 0;
|
||||
|
||||
@@ -104,41 +108,57 @@ const Script = ({ item, collection }) => {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pre-request" className="mt-2" dataTestId="pre-request-script-editor">
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
docKey="script:pre-request"
|
||||
value={requestScript || ''}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
onEdit={onRequestScriptEdit}
|
||||
mode="javascript"
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'bru']}
|
||||
initialScroll={preReqScroll}
|
||||
onScroll={setPreReqScroll}
|
||||
/>
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
docKey="script:pre-request"
|
||||
value={requestScript || ''}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
onEdit={onRequestScriptEdit}
|
||||
mode="javascript"
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'bru']}
|
||||
initialScroll={preReqScroll}
|
||||
onScroll={setPreReqScroll}
|
||||
/>
|
||||
<AIAssist
|
||||
scriptType="pre-request"
|
||||
currentScript={requestScript || ''}
|
||||
requestContext={requestContext}
|
||||
onApply={onRequestScriptEdit}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="post-response" className="mt-2" dataTestId="post-response-script-editor">
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
docKey="script:post-response"
|
||||
value={responseScript || ''}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
onEdit={onResponseScriptEdit}
|
||||
mode="javascript"
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={postResScroll}
|
||||
onScroll={setPostResScroll}
|
||||
/>
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
docKey="script:post-response"
|
||||
value={responseScript || ''}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
onEdit={onResponseScriptEdit}
|
||||
mode="javascript"
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={postResScroll}
|
||||
onScroll={setPostResScroll}
|
||||
/>
|
||||
<AIAssist
|
||||
scriptType="post-response"
|
||||
currentScript={responseScript || ''}
|
||||
requestContext={requestContext}
|
||||
onApply={onResponseScriptEdit}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { buildRequestContextFromItem } from 'utils/ai';
|
||||
import { updateRequestTests } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
@@ -29,8 +31,10 @@ const Tests = ({ item, collection }) => {
|
||||
const onRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
const requestContext = useMemo(() => buildRequestContextFromItem(item), [item]);
|
||||
|
||||
return (
|
||||
<div data-testid="test-script-editor">
|
||||
<div data-testid="test-script-editor" className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={testsEditorRef}
|
||||
collection={collection}
|
||||
@@ -47,6 +51,7 @@ const Tests = ({ item, collection }) => {
|
||||
initialScroll={testsScroll}
|
||||
onScroll={setTestsScroll}
|
||||
/>
|
||||
<AIAssist scriptType="tests" currentScript={tests || ''} requestContext={requestContext} onApply={onEdit} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import BearerAuth from '../../Auth/BearerAuth';
|
||||
@@ -6,16 +6,15 @@ import BasicAuth from '../../Auth/BasicAuth';
|
||||
import ApiKeyAuth from '../../Auth/ApiKeyAuth';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections';
|
||||
import { getTreePathFromCollectionToItem } from 'utils/collections/index';
|
||||
import { getEffectiveAuthSource } from 'utils/auth';
|
||||
import { updateRequestAuthMode, updateAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
|
||||
const supportedAuthModes = ['basic', 'bearer', 'apikey', 'oauth2', 'none', 'inherit'];
|
||||
import { AUTH_MODES_WS } from 'utils/common/constants';
|
||||
|
||||
const WSAuth = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
|
||||
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
|
||||
|
||||
const request = item.draft
|
||||
? get(item, 'draft.request', {})
|
||||
@@ -25,9 +24,14 @@ const WSAuth = ({ item, collection }) => {
|
||||
return saveRequest(item.uid, collection.uid);
|
||||
};
|
||||
|
||||
const inheritedSource = useMemo(
|
||||
() => (authMode === 'inherit' ? getEffectiveAuthSource(collection, item) : null),
|
||||
[authMode, item, collection]
|
||||
);
|
||||
|
||||
// Reset to 'none' if current auth mode is not supported
|
||||
useEffect(() => {
|
||||
if (authMode && !supportedAuthModes.includes(authMode)) {
|
||||
if (authMode && !AUTH_MODES_WS.includes(authMode)) {
|
||||
dispatch(updateRequestAuthMode({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
@@ -36,35 +40,6 @@ const WSAuth = ({ item, collection }) => {
|
||||
}
|
||||
}, [authMode, collection.uid, dispatch, item.uid]);
|
||||
|
||||
const getEffectiveAuthSource = () => {
|
||||
if (authMode !== 'inherit') return null;
|
||||
|
||||
const collectionRoot = collection?.draft?.root || collection?.root || {};
|
||||
const collectionAuth = get(collectionRoot, 'request.auth');
|
||||
let effectiveSource = {
|
||||
type: 'collection',
|
||||
name: 'Collection',
|
||||
auth: collectionAuth
|
||||
};
|
||||
|
||||
// Check folders in reverse to find the closest auth configuration
|
||||
for (let i of [...requestTreePath].reverse()) {
|
||||
if (i.type === 'folder') {
|
||||
const folderAuth = get(i, 'root.request.auth');
|
||||
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
|
||||
effectiveSource = {
|
||||
type: 'folder',
|
||||
name: i.name,
|
||||
auth: folderAuth
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return effectiveSource;
|
||||
};
|
||||
|
||||
const getAuthView = () => {
|
||||
switch (authMode) {
|
||||
case 'none': {
|
||||
@@ -91,26 +66,24 @@ const WSAuth = ({ item, collection }) => {
|
||||
);
|
||||
}
|
||||
case 'inherit': {
|
||||
const source = getEffectiveAuthSource();
|
||||
|
||||
// Check if inherited auth is OAuth1/OAuth2 - not supported for WebSockets
|
||||
if (source?.auth?.mode === 'oauth1' || source?.auth?.mode === 'oauth2') {
|
||||
if (inheritedSource?.auth?.mode === 'oauth1' || inheritedSource?.auth?.mode === 'oauth2') {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row w-full mt-2 gap-2">
|
||||
{source.auth.mode === 'oauth1' ? 'OAuth 1.0' : 'OAuth 2'} not <strong>yet</strong> supported by WebSockets. Using no auth instead.
|
||||
{inheritedSource.auth.mode === 'oauth1' ? 'OAuth 1.0' : 'OAuth 2'} not <strong>yet</strong> supported by WebSockets. Using no auth instead.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Only show inherited auth if it's one of the supported types
|
||||
if (source && supportedAuthModes.includes(source.auth?.mode)) {
|
||||
if (inheritedSource && AUTH_MODES_WS.includes(inheritedSource.auth?.mode)) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row w-full gap-2">
|
||||
<div> Auth inherited from {source.name}: </div>
|
||||
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
|
||||
<div> Auth inherited from {inheritedSource.name}: </div>
|
||||
<div className="inherit-mode-text">{humanizeRequestAuthMode(inheritedSource.auth?.mode)}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,17 +2,26 @@ import React, { useMemo, useCallback, useRef } from 'react';
|
||||
import Documentation from 'components/Documentation/index';
|
||||
import RequestHeaders from 'components/RequestPane/RequestHeaders';
|
||||
import StatusDot from 'components/StatusDot/index';
|
||||
import { find } from 'lodash';
|
||||
import ActionIcon from 'ui/ActionIcon';
|
||||
import ToolHint from 'components/ToolHint/index';
|
||||
import { IconPlus, IconWand } from '@tabler/icons';
|
||||
import { find, get } from 'lodash';
|
||||
import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import HeightBoundContainer from 'ui/HeightBoundContainer';
|
||||
import ResponsiveTabs from 'ui/ResponsiveTabs';
|
||||
import { getPropertyFromDraftOrRequest } from 'utils/collections/index';
|
||||
import { prettifyJsonString, uuid } from 'utils/common/index';
|
||||
import xmlFormat from 'xml-formatter';
|
||||
import toast from 'react-hot-toast';
|
||||
import WsBody from '../WsBody/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import WSAuth from './WSAuth';
|
||||
import WSAuthMode from './WSAuth/WSAuthMode';
|
||||
import WSSettingsPane from '../WSSettingsPane/index';
|
||||
import { hasEffectiveAuth } from 'utils/auth';
|
||||
import { AUTH_MODES_WS } from 'utils/common/constants';
|
||||
|
||||
const WSRequestPane = ({ item, collection, handleRun }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -24,6 +33,8 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const requestPaneTab = focusedTab?.requestPaneTab;
|
||||
|
||||
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
|
||||
|
||||
const selectTab = useCallback(
|
||||
(tab) => {
|
||||
dispatch(updateRequestPaneTab({
|
||||
@@ -34,10 +45,70 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
|
||||
[dispatch, item.uid]
|
||||
);
|
||||
|
||||
const addNewMessage = useCallback(() => {
|
||||
const currentMessages = Array.isArray(body?.ws)
|
||||
? body.ws.map((msg) => ({ ...msg, selected: false }))
|
||||
: [];
|
||||
currentMessages.push({
|
||||
uid: uuid(),
|
||||
name: `message ${currentMessages.length + 1}`,
|
||||
content: '{}',
|
||||
type: 'json',
|
||||
selected: true
|
||||
});
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
}, [body, dispatch, item.uid, collection.uid]);
|
||||
|
||||
const onPrettifyAll = useCallback(() => {
|
||||
const currentMessages = [...(body?.ws || [])];
|
||||
let changed = false;
|
||||
|
||||
currentMessages.forEach((msg, i) => {
|
||||
if (msg.type === 'json') {
|
||||
try {
|
||||
const pretty = prettifyJsonString(msg.content);
|
||||
if (pretty !== msg.content) {
|
||||
currentMessages[i] = { ...msg, content: pretty };
|
||||
changed = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// skip invalid json
|
||||
}
|
||||
} else if (msg.type === 'xml') {
|
||||
try {
|
||||
const pretty = xmlFormat(msg.content, { collapseContent: true });
|
||||
if (pretty !== msg.content) {
|
||||
currentMessages[i] = { ...msg, content: pretty };
|
||||
changed = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// skip invalid xml
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
} else {
|
||||
toast.error('Nothing to prettify');
|
||||
}
|
||||
}, [body, dispatch, item.uid, collection.uid]);
|
||||
|
||||
const headers = getPropertyFromDraftOrRequest(item, 'request.headers');
|
||||
const docs = getPropertyFromDraftOrRequest(item, 'request.docs');
|
||||
const auth = getPropertyFromDraftOrRequest(item, 'request.auth');
|
||||
|
||||
const itemAuthMode = item.draft?.request?.auth?.mode ?? item.request?.auth?.mode ?? item.root?.request?.auth?.mode;
|
||||
const hasAuth = useMemo(
|
||||
() => hasEffectiveAuth(collection, item, AUTH_MODES_WS),
|
||||
[item, itemAuthMode, collection]
|
||||
);
|
||||
const activeHeadersLength = headers.filter((header) => header.enabled).length;
|
||||
|
||||
const allTabs = useMemo(() => {
|
||||
@@ -55,7 +126,7 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
|
||||
{
|
||||
key: 'auth',
|
||||
label: 'Auth',
|
||||
indicator: auth.mode !== 'none' ? <StatusDot type="default" /> : null
|
||||
indicator: hasAuth ? <StatusDot type="default" dataTestId="auth" /> : null
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
@@ -68,7 +139,7 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
|
||||
indicator: docs && docs.length > 0 ? <StatusDot type="default" /> : null
|
||||
}
|
||||
];
|
||||
}, [activeHeadersLength, auth.mode, docs]);
|
||||
}, [activeHeadersLength, hasAuth, docs]);
|
||||
|
||||
const tabPanel = useMemo(() => {
|
||||
switch (requestPaneTab) {
|
||||
@@ -77,9 +148,8 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
|
||||
<WsBody
|
||||
item={item}
|
||||
collection={collection}
|
||||
hideModeSelector={true}
|
||||
hidePrettifyButton={true}
|
||||
handleRun={handleRun}
|
||||
onAddMessage={addNewMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -99,17 +169,41 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
|
||||
return <div className="mt-4">404 | Not found</div>;
|
||||
}
|
||||
}
|
||||
}, [requestPaneTab, item, collection, handleRun]);
|
||||
}, [requestPaneTab, item, collection, handleRun, addNewMessage]);
|
||||
|
||||
if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) {
|
||||
return <div className="pb-4 px-4">An error occurred!</div>;
|
||||
}
|
||||
|
||||
const rightContent = requestPaneTab === 'auth' ? (
|
||||
<div ref={rightContentRef} className="flex flex-grow justify-start items-center">
|
||||
<WSAuthMode item={item} collection={collection} />
|
||||
</div>
|
||||
) : null;
|
||||
let rightContent = null;
|
||||
if (requestPaneTab === 'auth') {
|
||||
rightContent = (
|
||||
<div ref={rightContentRef} className="flex flex-grow justify-start items-center">
|
||||
<WSAuthMode item={item} collection={collection} />
|
||||
</div>
|
||||
);
|
||||
} else if (requestPaneTab === 'body') {
|
||||
rightContent = (
|
||||
<div ref={rightContentRef} className="flex items-center gap-2">
|
||||
<ToolHint text="Prettify All" toolhintId="prettify-all-ws">
|
||||
<ActionIcon
|
||||
data-testid="ws-prettify-all"
|
||||
onClick={onPrettifyAll}
|
||||
>
|
||||
<IconWand size={14} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
</ToolHint>
|
||||
<ToolHint text="Add Message" toolhintId="add-msg-ws">
|
||||
<ActionIcon
|
||||
data-testid="ws-add-message"
|
||||
onClick={addNewMessage}
|
||||
>
|
||||
<IconPlus size={15} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
</ToolHint>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col h-full relative">
|
||||
|
||||
@@ -1,72 +1,92 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border0};
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&.single {
|
||||
height: 100%;
|
||||
|
||||
.editor-container {
|
||||
height: calc(100% - 32px);
|
||||
}
|
||||
&.disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
&:not(.single) {
|
||||
min-height: 240px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&.last {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.message-toolbar {
|
||||
.accordion-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
padding: 4px 0px;
|
||||
padding-top: 0px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
.message-label {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.subtext1};
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
.accordion-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
gap: 0.375rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.message-label {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
cursor: text;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.name-input {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: inherit;
|
||||
background: ${(props) => props.theme.background.surface1};
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
outline: none;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
.accordion-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
transition: all 0.15s ease;
|
||||
gap: 0.125rem;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.dropdown.hoverBg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
.hover-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&.delete:hover {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
.hover-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 0.25rem;
|
||||
color: ${(props) => props.theme.text};
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.dropdown.hoverBg};
|
||||
}
|
||||
|
||||
&.delete:hover {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .hover-actions {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
&:not(.disabled) .accordion-header .message-label {
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,56 +1,117 @@
|
||||
import { IconTrash, IconWand } from '@tabler/icons';
|
||||
import { IconTrash, IconSend, IconChevronRight, IconChevronDown } from '@tabler/icons';
|
||||
import CodeEditor from 'components/CodeEditor/index';
|
||||
import ToolHint from 'components/ToolHint/index';
|
||||
import { get } from 'lodash';
|
||||
import invert from 'lodash/invert';
|
||||
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { autoDetectLang } from 'utils/codemirror/lang-detect';
|
||||
import { toastError } from 'utils/common/error';
|
||||
import { prettifyJsonString } from 'utils/common/index';
|
||||
import xmlFormat from 'xml-formatter';
|
||||
import { queueWsMessage, isWsConnectionActive, connectWS } from 'utils/network/index';
|
||||
import { findCollectionByUid, findEnvironmentInCollection } from 'utils/collections/index';
|
||||
import toast from 'react-hot-toast';
|
||||
import WSRequestBodyMode from '../BodyMode/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
export const TYPE_BY_DECODER = {
|
||||
base64: 'binary',
|
||||
json: 'json',
|
||||
xml: 'xml'
|
||||
const codemirrorMode = {
|
||||
text: 'application/text',
|
||||
xml: 'application/xml',
|
||||
json: 'application/ld+json'
|
||||
};
|
||||
|
||||
export const DECODER_BY_TYPE = invert(TYPE_BY_DECODER);
|
||||
// Maps stored type to display mode
|
||||
const typeToMode = (type) => {
|
||||
switch (type) {
|
||||
case 'json': return 'json';
|
||||
case 'xml': return 'xml';
|
||||
default: return 'text';
|
||||
}
|
||||
};
|
||||
|
||||
export const SingleWSMessage = ({
|
||||
message,
|
||||
item,
|
||||
collection,
|
||||
index,
|
||||
methodType,
|
||||
handleRun,
|
||||
canClientSendMultipleMessages,
|
||||
isLast
|
||||
isExpanded,
|
||||
onToggle,
|
||||
isNew,
|
||||
onNewRendered,
|
||||
isSelected,
|
||||
onSelect
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
|
||||
const { name, content, type } = message;
|
||||
const [messageFormat, setMessageFormat] = useState(autoDetectLang(content));
|
||||
const displayMode = typeToMode(type);
|
||||
const displayName = name || `message ${index + 1}`;
|
||||
|
||||
const onUpdateMessageType = (type) => {
|
||||
setMessageFormat(type);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(displayName);
|
||||
|
||||
// Auto-focus the name input when this is a newly created message
|
||||
useEffect(() => {
|
||||
if (isNew) {
|
||||
setIsEditing(true);
|
||||
setEditValue(displayName);
|
||||
onNewRendered();
|
||||
}
|
||||
}, [isNew]);
|
||||
|
||||
const saveName = (value) => {
|
||||
const trimmed = value.trim() || `message ${index + 1}`;
|
||||
const currentMessages = [...(body.ws || [])];
|
||||
|
||||
currentMessages[index] = {
|
||||
...currentMessages[index],
|
||||
type: DECODER_BY_TYPE[type]
|
||||
name: trimmed
|
||||
};
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleNameKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
saveName(editValue);
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditValue(displayName);
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameBlur = () => {
|
||||
saveName(editValue);
|
||||
};
|
||||
|
||||
const handleNameClick = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
setEditValue(displayName);
|
||||
setIsEditing(true);
|
||||
}, [displayName, onToggle]);
|
||||
|
||||
const fontSize = get(preferences, 'font.codeFontSize', 14);
|
||||
const lineHeight = fontSize * 1.5;
|
||||
|
||||
const editorHeight = useMemo(() => {
|
||||
const lineCount = (content || '').split('\n').length;
|
||||
const lines = lineCount + 1;
|
||||
return `${lines * lineHeight + 10}px`;
|
||||
}, [content, lineHeight]);
|
||||
|
||||
const onUpdateMessageType = (newMode) => {
|
||||
const currentMessages = [...(body.ws || [])];
|
||||
currentMessages[index] = {
|
||||
...currentMessages[index],
|
||||
type: typeToMode(newMode)
|
||||
};
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
itemUid: item.uid,
|
||||
@@ -60,13 +121,11 @@ export const SingleWSMessage = ({
|
||||
|
||||
const onEdit = (value) => {
|
||||
const currentMessages = [...(body.ws || [])];
|
||||
|
||||
currentMessages[index] = {
|
||||
name: name ? name : `message ${index + 1}`,
|
||||
type: DECODER_BY_TYPE[messageFormat],
|
||||
...currentMessages[index],
|
||||
name: name || `message ${index + 1}`,
|
||||
content: value
|
||||
};
|
||||
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
itemUid: item.uid,
|
||||
@@ -78,9 +137,7 @@ export const SingleWSMessage = ({
|
||||
|
||||
const onDeleteMessage = () => {
|
||||
const currentMessages = [...(body.ws || [])];
|
||||
|
||||
currentMessages.splice(index, 1);
|
||||
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
itemUid: item.uid,
|
||||
@@ -88,97 +145,112 @@ export const SingleWSMessage = ({
|
||||
}));
|
||||
};
|
||||
|
||||
let codeType = messageFormat;
|
||||
if (TYPE_BY_DECODER[type]) {
|
||||
codeType = TYPE_BY_DECODER[type];
|
||||
}
|
||||
const onSendMessage = useCallback(async () => {
|
||||
try {
|
||||
const col = findCollectionByUid(collections, collection.uid);
|
||||
const environment = findEnvironmentInCollection(col, col?.activeEnvironmentUid);
|
||||
|
||||
const codemirrorMode = {
|
||||
text: 'application/text',
|
||||
xml: 'application/xml',
|
||||
json: 'application/ld+json'
|
||||
};
|
||||
|
||||
const onPrettify = () => {
|
||||
if (codeType === 'json') {
|
||||
try {
|
||||
const prettyBodyJson = prettifyJsonString(content);
|
||||
const currentMessages = [...(body.ws || [])];
|
||||
currentMessages[index] = {
|
||||
...currentMessages[index],
|
||||
name: name ? name : `message ${index + 1}`,
|
||||
content: prettyBodyJson
|
||||
};
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
} catch (e) {
|
||||
toastError(new Error('Unable to prettify. Invalid JSON format.'));
|
||||
// Auto-connect if not already connected
|
||||
const connectionStatus = await isWsConnectionActive(item.uid);
|
||||
if (!connectionStatus.isActive) {
|
||||
await connectWS(item, col, environment, col?.runtimeVariables, { connectOnly: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (codeType === 'xml') {
|
||||
try {
|
||||
const prettyBodyXML = xmlFormat(content, { collapseContent: true });
|
||||
|
||||
const currentMessages = [...(body.ws || [])];
|
||||
currentMessages[index] = {
|
||||
...currentMessages[index],
|
||||
name: name ? name : `message ${index + 1}`,
|
||||
content: prettyBodyXML
|
||||
};
|
||||
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
} catch (e) {
|
||||
toastError(new Error('Unable to prettify. Invalid XML format.'));
|
||||
const result = await queueWsMessage(item, col, environment, col?.runtimeVariables, index);
|
||||
if (!result.success) {
|
||||
toast.error(result.error || 'Failed to send message');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to send message');
|
||||
}
|
||||
};
|
||||
|
||||
const isSingleMessage = !canClientSendMultipleMessages || body.ws.length === 1;
|
||||
}, [collections]);
|
||||
|
||||
return (
|
||||
<StyledWrapper className={`message-container ${isSingleMessage ? 'single' : ''} ${isLast ? 'last' : ''}`}>
|
||||
<div className="message-toolbar">
|
||||
<span className="message-label">Message {index + 1}</span>
|
||||
<div className="toolbar-actions">
|
||||
<WSRequestBodyMode mode={messageFormat} onModeChange={onUpdateMessageType} />
|
||||
|
||||
<ToolHint text="Format" toolhintId={`prettify-msg-${index}`}>
|
||||
<button onClick={onPrettify} className="toolbar-btn">
|
||||
<IconWand size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</ToolHint>
|
||||
|
||||
{index > 0 && (
|
||||
<ToolHint text="Delete message" toolhintId={`delete-msg-${index}`}>
|
||||
<button onClick={onDeleteMessage} className="toolbar-btn delete">
|
||||
<IconTrash size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</ToolHint>
|
||||
<StyledWrapper
|
||||
className={!isSelected ? 'disabled' : ''}
|
||||
onMouseDownCapture={() => {
|
||||
if (!isSelected) setTimeout(onSelect, 0);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="accordion-header"
|
||||
data-testid={`ws-message-header-${index}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onToggle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onToggle();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="accordion-left">
|
||||
{isExpanded ? (
|
||||
<IconChevronDown size={14} strokeWidth={2} />
|
||||
) : (
|
||||
<IconChevronRight size={14} strokeWidth={2} />
|
||||
)}
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={(node) => node?.focus()}
|
||||
className="name-input"
|
||||
data-testid={`ws-message-name-input-${index}`}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleNameKeyDown}
|
||||
onBlur={handleNameBlur}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="message-label"
|
||||
data-testid={`ws-message-label-${index}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onToggle();
|
||||
}}
|
||||
onDoubleClick={handleNameClick}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="accordion-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="hover-actions">
|
||||
<ToolHint text="Send" toolhintId={`send-msg-${index}`}>
|
||||
<button onClick={onSendMessage} className="hover-action-btn" data-testid={`ws-send-msg-${index}`}>
|
||||
<IconSend size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
</ToolHint>
|
||||
{(body.ws || []).length > 1 && (
|
||||
<ToolHint text="Delete" toolhintId={`delete-msg-${index}`}>
|
||||
<button onClick={onDeleteMessage} className="hover-action-btn delete" data-testid={`ws-delete-msg-${index}`}>
|
||||
<IconTrash size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
</ToolHint>
|
||||
)}
|
||||
</div>
|
||||
<WSRequestBodyMode mode={displayMode} onModeChange={onUpdateMessageType} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="editor-container">
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
value={content}
|
||||
onEdit={onEdit}
|
||||
onRun={handleRun}
|
||||
onSave={onSave}
|
||||
mode={codemirrorMode[codeType] ?? 'text/plain'}
|
||||
enableVariableHighlighting={true}
|
||||
/>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="accordion-body" data-testid={`ws-message-body-${index}`} style={{ height: editorHeight }}>
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
value={content}
|
||||
onEdit={onEdit}
|
||||
onRun={handleRun}
|
||||
onSave={onSave}
|
||||
mode={codemirrorMode[displayMode] ?? 'text/plain'}
|
||||
enableVariableHighlighting={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,21 +5,10 @@ const Wrapper = styled.div`
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.single {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&.multi {
|
||||
overflow-y: auto;
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@@ -36,13 +25,20 @@ const Wrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.add-message-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 8px;
|
||||
background: ${(props) => props.theme.bg};
|
||||
.add-message-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.875rem;
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px 0;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,99 +1,124 @@
|
||||
import { get } from 'lodash';
|
||||
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
|
||||
import { IconPlus } from '@tabler/icons';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Button from 'ui/Button';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { SingleWSMessage } from './SingleWSMessage/index';
|
||||
|
||||
const WSBody = ({ item, collection, handleRun }) => {
|
||||
const getSelectedIndex = (messages) => {
|
||||
const idx = messages.findIndex((msg) => msg.selected);
|
||||
return idx >= 0 ? idx : 0;
|
||||
};
|
||||
|
||||
const WSBody = ({ item, collection, handleRun, onAddMessage }) => {
|
||||
const dispatch = useDispatch();
|
||||
const messagesContainerRef = useRef(null);
|
||||
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
|
||||
const messages = body?.ws || [];
|
||||
|
||||
const methodType = item.draft ? get(item, 'draft.request.methodType') : get(item, 'request.methodType');
|
||||
const canClientSendMultipleMessages = false;
|
||||
const selectedIndex = getSelectedIndex(messages);
|
||||
|
||||
// Auto-scroll to the latest message when messages are added
|
||||
useEffect(() => {
|
||||
if (messagesContainerRef.current && body?.ws?.length > 0) {
|
||||
const container = messagesContainerRef.current;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}, [body?.ws?.length]);
|
||||
|
||||
const addNewMessage = () => {
|
||||
const currentMessages = Array.isArray(body.ws) ? [...body.ws] : [];
|
||||
|
||||
currentMessages.push({
|
||||
name: `message ${currentMessages.length + 1}`,
|
||||
content: '{}'
|
||||
});
|
||||
// Expand the selected message by default (falls back to first)
|
||||
const [expandedUids, setExpandedUids] = useState(() => {
|
||||
const uid = messages[selectedIndex]?.uid || messages[0]?.uid;
|
||||
return new Set(uid ? [uid] : []);
|
||||
});
|
||||
const [newMessageUid, setNewMessageUid] = useState(null);
|
||||
const prevMessagesLengthRef = useRef(messages.length);
|
||||
|
||||
const setSelectedIndex = useCallback((index) => {
|
||||
const currentMessages = [...(body?.ws || [])];
|
||||
const updated = currentMessages.map((msg, i) => ({
|
||||
...msg,
|
||||
selected: i === index
|
||||
}));
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
content: updated,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
};
|
||||
}, [body, dispatch, item.uid, collection.uid]);
|
||||
|
||||
if (!body?.ws || !Array.isArray(body.ws)) {
|
||||
const toggleMessage = useCallback((uid) => {
|
||||
if (!uid) return;
|
||||
setExpandedUids((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(uid)) {
|
||||
next.delete(uid);
|
||||
} else {
|
||||
next.add(uid);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSelect = useCallback((index) => {
|
||||
if (index !== selectedIndex) {
|
||||
setSelectedIndex(index);
|
||||
}
|
||||
}, [selectedIndex, setSelectedIndex]);
|
||||
|
||||
// React to new message being added (messages.length increased)
|
||||
useEffect(() => {
|
||||
if (messages.length > prevMessagesLengthRef.current) {
|
||||
const newMsg = messages[messages.length - 1];
|
||||
if (newMsg?.uid) {
|
||||
setExpandedUids((prev) => new Set(prev).add(newMsg.uid));
|
||||
setNewMessageUid(newMsg.uid);
|
||||
setSelectedIndex(messages.length - 1);
|
||||
}
|
||||
}
|
||||
prevMessagesLengthRef.current = messages.length;
|
||||
}, [messages.length]);
|
||||
|
||||
const handleNewMessageRendered = useCallback(() => {
|
||||
setNewMessageUid(null);
|
||||
}, []);
|
||||
|
||||
// Auto-scroll to bottom when new message is added
|
||||
useEffect(() => {
|
||||
if (messagesContainerRef.current && messages.length > 0) {
|
||||
const container = messagesContainerRef.current;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
if (!messages.length) {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="empty-state">
|
||||
<p>No WebSocket messages available</p>
|
||||
<Button
|
||||
onClick={addNewMessage}
|
||||
variant="filled"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon={<IconPlus size={14} strokeWidth={1.5} />}
|
||||
>
|
||||
Add Message
|
||||
</Button>
|
||||
<button className="add-message-link" data-testid="ws-add-message" onClick={onAddMessage}>
|
||||
<IconPlus size={14} strokeWidth={1.5} />
|
||||
<span>Add message</span>
|
||||
</button>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const messagesToShow = body.ws.filter((_, index) => canClientSendMultipleMessages || index === 0);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className={`messages-container ${canClientSendMultipleMessages && messagesToShow.length > 1 ? 'multi' : 'single'}`}
|
||||
>
|
||||
{messagesToShow.map((message, index) => (
|
||||
<div ref={messagesContainerRef} className="messages-container">
|
||||
{messages.map((message, index) => (
|
||||
<SingleWSMessage
|
||||
key={index}
|
||||
key={message.uid}
|
||||
id={`ws-message-${message.uid}`}
|
||||
message={message}
|
||||
item={item}
|
||||
collection={collection}
|
||||
index={index}
|
||||
methodType={methodType}
|
||||
handleRun={handleRun}
|
||||
canClientSendMultipleMessages={canClientSendMultipleMessages}
|
||||
isLast={index === messagesToShow.length - 1}
|
||||
isExpanded={expandedUids.has(message.uid)}
|
||||
onToggle={() => toggleMessage(message.uid)}
|
||||
isNew={newMessageUid === message.uid}
|
||||
onNewRendered={handleNewMessageRendered}
|
||||
isSelected={selectedIndex === index}
|
||||
onSelect={() => handleSelect(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{canClientSendMultipleMessages && (
|
||||
<div className="add-message-footer">
|
||||
<Button
|
||||
onClick={addNewMessage}
|
||||
variant="filled"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
fullWidth
|
||||
icon={<IconPlus size={14} strokeWidth={1.5} />}
|
||||
>
|
||||
Add Message
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import { IconAlertTriangle } from '@tabler/icons';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import find from 'lodash/find';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { NON_CLOSABLE_TAB_TYPES } from 'providers/ReduxStore/slices/tabs';
|
||||
import Button from 'ui/Button';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
|
||||
class TabPanelErrorBoundaryInner extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error('[TabPanelErrorBoundary] Unexpected render error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { theme } = this.props;
|
||||
|
||||
if (this.state.hasError) {
|
||||
const { isClosable, onClose } = this.props;
|
||||
const errorMessage = this.state.error?.message;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<IconAlertTriangle size={36} strokeWidth={1.5} style={{ color: theme?.status?.warning?.text }} />
|
||||
<h2 className="text-lg font-medium">Something went wrong</h2>
|
||||
{isClosable ? (
|
||||
<p className="text-sm opacity-70 max-w-md">
|
||||
This tab encountered an unexpected error. Close it and try reopening the request. If the
|
||||
error repeats, the request file may be corrupt.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm opacity-70 max-w-md">
|
||||
This panel encountered an unexpected error. Restart Bruno to recover.
|
||||
</p>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<p className="text-xs font-mono opacity-50 max-w-md break-all">{errorMessage}</p>
|
||||
)}
|
||||
{isClosable && (
|
||||
<Button size="md" data-testid="tab-panel-error-boundary-close-tab" color="primary" onClick={onClose}>
|
||||
Close Tab
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const TabPanelErrorBoundary = ({ tabUid, children }) => {
|
||||
const dispatch = useDispatch();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const focusedTab = find(tabs, (t) => t.uid === tabUid);
|
||||
const isClosable = !focusedTab || !NON_CLOSABLE_TAB_TYPES.includes(focusedTab.type);
|
||||
const { theme } = useTheme();
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(closeTabs({ tabUids: [tabUid] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<TabPanelErrorBoundaryInner isClosable={isClosable} onClose={handleClose} theme={theme}>
|
||||
{children}
|
||||
</TabPanelErrorBoundaryInner>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabPanelErrorBoundary;
|
||||
@@ -43,6 +43,7 @@ import GlobalEnvironmentSettings from 'components/Environments/GlobalEnvironment
|
||||
import OpenAPISyncTab from 'components/OpenAPISyncTab';
|
||||
import OpenAPISpecTab from 'components/OpenAPISpecTab';
|
||||
import CollapsedPanelIndicator from './CollapsedPanelIndicator';
|
||||
import { IconLoader2 } from '@tabler/icons';
|
||||
|
||||
const MIN_LEFT_PANE_WIDTH = 300;
|
||||
const MIN_RIGHT_PANE_WIDTH = 490;
|
||||
@@ -299,7 +300,12 @@ const RequestTabPanel = () => {
|
||||
}
|
||||
|
||||
if (!activeTabUid || !focusedTab) {
|
||||
return <div className="pb-4 px-4">Loading...</div>;
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted">
|
||||
<IconLoader2 className="animate-spin" size={24} strokeWidth={1.5} />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (focusedTab.type === 'global-environment-settings') {
|
||||
@@ -335,6 +341,9 @@ const RequestTabPanel = () => {
|
||||
let example = null;
|
||||
if (item?.examples) {
|
||||
example = item.examples.find((ex) => ex.uid === focusedTab.uid);
|
||||
if (!example && typeof focusedTab.exampleIndex === 'number' && focusedTab.exampleIndex >= 0) {
|
||||
example = item.examples[focusedTab.exampleIndex] || null;
|
||||
}
|
||||
if (!example && focusedTab.exampleName) {
|
||||
example = item.examples.find((ex) => ex.name === focusedTab.exampleName);
|
||||
}
|
||||
@@ -387,7 +396,7 @@ const RequestTabPanel = () => {
|
||||
if (folder) {
|
||||
return (
|
||||
<ScopedPersistenceProvider scope={focusedTab.uid}>
|
||||
<FolderSettings collection={collection} folder={folder} />;
|
||||
<FolderSettings collection={collection} folder={folder} />
|
||||
</ScopedPersistenceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,11 +26,15 @@ const ExampleTab = ({ tab, collection }) => {
|
||||
if (!item?.examples) return null;
|
||||
const byUid = item.examples.find((ex) => ex.uid === tab.uid);
|
||||
if (byUid) return byUid;
|
||||
if (typeof tab.exampleIndex === 'number' && tab.exampleIndex >= 0) {
|
||||
const byIndex = item.examples[tab.exampleIndex];
|
||||
if (byIndex) return byIndex;
|
||||
}
|
||||
if (tab.exampleName) {
|
||||
return item.examples.find((ex) => ex.name === tab.exampleName);
|
||||
}
|
||||
return null;
|
||||
}, [item?.examples, tab.uid, tab.exampleName]);
|
||||
}, [item?.examples, tab.uid, tab.exampleIndex, tab.exampleName]);
|
||||
|
||||
const hasChanges = useMemo(() => hasExampleChanges(item, example?.uid), [item, example?.uid]);
|
||||
|
||||
|
||||
@@ -259,7 +259,11 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
} else if (tab.type === 'global-environment-settings') {
|
||||
if (globalEnvironmentDraft) {
|
||||
const { environmentUid, variables } = globalEnvironmentDraft;
|
||||
dispatch(saveGlobalEnvironment({ variables, environmentUid }));
|
||||
if (environmentUid?.startsWith('dotenv:')) {
|
||||
window.dispatchEvent(new Event('dotenv-save'));
|
||||
} else {
|
||||
dispatch(saveGlobalEnvironment({ variables, environmentUid }));
|
||||
}
|
||||
}
|
||||
} else if (tab.type === 'folder-settings') {
|
||||
if (folder) {
|
||||
|
||||
@@ -62,7 +62,7 @@ const Wrapper = styled.div`
|
||||
|
||||
tr {
|
||||
position: relative;
|
||||
|
||||
|
||||
&:hover .delete-button.edit-mode {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
@@ -92,10 +92,6 @@ const Wrapper = styled.div`
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
|
||||
.file-value-cell {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.value-cell {
|
||||
width: 100%;
|
||||
|
||||
@@ -114,7 +110,7 @@ const Wrapper = styled.div`
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
margin-left: 8px;
|
||||
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.colors.text.red};
|
||||
}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { IconUpload, IconX, IconFile } from '@tabler/icons';
|
||||
import { IconUpload } from '@tabler/icons';
|
||||
import { updateResponseExampleMultipartFormParams } from 'providers/ReduxStore/slices/collections';
|
||||
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import mime from 'mime-types';
|
||||
import path from 'utils/common/path';
|
||||
import path, { getRelativePathWithinBasePath, normalizePath } from 'utils/common/path';
|
||||
import { getMultipartAutoContentType } from 'utils/common/multipartContentType';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import MultipartFileChipsCell from 'components/MultipartFileChipsCell';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { isWindowsOS } from 'utils/common/platform';
|
||||
|
||||
const fileBasename = (filePath) =>
|
||||
filePath ? path.basename(normalizePath(String(filePath))) : '';
|
||||
|
||||
const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, editMode = false }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -48,50 +52,59 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
|
||||
const handleBrowseFiles = useCallback((row, onChange) => {
|
||||
if (!editMode) return;
|
||||
|
||||
dispatch(browseFiles())
|
||||
dispatch(browseFiles([], ['multiSelections']))
|
||||
.then((filePaths) => {
|
||||
if (!Array.isArray(filePaths) || filePaths.length === 0) return;
|
||||
|
||||
const processedPaths = filePaths.map((filePath) => {
|
||||
const collectionDir = collection.pathname;
|
||||
if (filePath.startsWith(collectionDir)) {
|
||||
return path.relative(collectionDir, filePath);
|
||||
}
|
||||
return filePath;
|
||||
return getRelativePathWithinBasePath(collection.pathname, filePath);
|
||||
});
|
||||
|
||||
const currentParams = params || [];
|
||||
const existingParam = currentParams.find((p) => p.uid === row.uid);
|
||||
const existingValue = existingParam && existingParam.type === 'file' && Array.isArray(existingParam.value)
|
||||
? existingParam.value
|
||||
: [];
|
||||
const seen = new Set(existingValue);
|
||||
const merged = [...existingValue];
|
||||
const skipped = [];
|
||||
for (const p of processedPaths) {
|
||||
if (!seen.has(p)) {
|
||||
seen.add(p);
|
||||
merged.push(p);
|
||||
} else {
|
||||
skipped.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
if (skipped.length === 1) {
|
||||
toast(`"${fileBasename(skipped[0])}" is already added`);
|
||||
} else if (skipped.length > 1) {
|
||||
toast(`${skipped.length} files are already added — skipped`);
|
||||
}
|
||||
|
||||
const autoContentType = getMultipartAutoContentType(merged);
|
||||
|
||||
let updatedParams;
|
||||
if (existingParam) {
|
||||
// Update existing param
|
||||
updatedParams = currentParams.map((p) => {
|
||||
if (p.uid === row.uid) {
|
||||
const updated = { ...p, type: 'file', value: processedPaths };
|
||||
// Auto-detect content type from first file
|
||||
if (processedPaths.length > 0) {
|
||||
const contentType = mime.contentType(path.extname(processedPaths[0]));
|
||||
updated.contentType = contentType || '';
|
||||
}
|
||||
return updated;
|
||||
return { ...p, type: 'file', value: merged, contentType: autoContentType };
|
||||
}
|
||||
return p;
|
||||
});
|
||||
} else {
|
||||
// Add new param (from EditableTable's empty row)
|
||||
const newParam = {
|
||||
uid: row.uid,
|
||||
name: row.name || '',
|
||||
type: 'file',
|
||||
value: processedPaths,
|
||||
contentType: '',
|
||||
enabled: true
|
||||
};
|
||||
// Auto-detect content type from first file
|
||||
if (processedPaths.length > 0) {
|
||||
const contentType = mime.contentType(path.extname(processedPaths[0]));
|
||||
newParam.contentType = contentType || '';
|
||||
}
|
||||
updatedParams = [...currentParams, newParam];
|
||||
updatedParams = [
|
||||
...currentParams,
|
||||
{
|
||||
uid: row.uid,
|
||||
name: row.name || '',
|
||||
type: 'file',
|
||||
value: merged,
|
||||
contentType: autoContentType,
|
||||
enabled: true
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
handleParamsChange(updatedParams);
|
||||
@@ -101,21 +114,24 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
|
||||
});
|
||||
}, [editMode, dispatch, collection.pathname, params, handleParamsChange]);
|
||||
|
||||
const handleClearFile = useCallback((row) => {
|
||||
const handleRemoveFile = useCallback((row, filePathToRemove) => {
|
||||
if (!editMode) return;
|
||||
|
||||
const currentParams = params || [];
|
||||
const existingParam = currentParams.find((p) => p.uid === row.uid);
|
||||
const target = currentParams.find((p) => p.uid === row.uid);
|
||||
if (!target || target.type !== 'file') return;
|
||||
const currentValue = Array.isArray(target.value)
|
||||
? target.value
|
||||
: (target.value ? [target.value] : []);
|
||||
const nextValue = currentValue.filter((p) => p !== filePathToRemove);
|
||||
|
||||
if (existingParam) {
|
||||
const updatedParams = currentParams.map((p) => {
|
||||
if (p.uid === row.uid) {
|
||||
return { ...p, type: 'text', value: '' };
|
||||
}
|
||||
return p;
|
||||
});
|
||||
handleParamsChange(updatedParams);
|
||||
}
|
||||
const updatedParams = currentParams.map((p) => {
|
||||
if (p.uid !== row.uid) return p;
|
||||
if (nextValue.length === 0) {
|
||||
return { ...p, type: 'text', value: '', contentType: '' };
|
||||
}
|
||||
return { ...p, type: 'file', value: nextValue, contentType: getMultipartAutoContentType(nextValue) };
|
||||
});
|
||||
handleParamsChange(updatedParams);
|
||||
}, [editMode, params, handleParamsChange]);
|
||||
|
||||
const handleValueChange = useCallback((row, newValue, onChange) => {
|
||||
@@ -151,19 +167,12 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
|
||||
}));
|
||||
}, [editMode, dispatch, item.uid, collection.uid, exampleUid, params]);
|
||||
|
||||
const getFileName = (filePaths) => {
|
||||
const getFileList = (filePaths) => {
|
||||
if (!filePaths || (Array.isArray(filePaths) && filePaths.length === 0)) {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
|
||||
const validPaths = paths.filter((v) => v != null && v !== '');
|
||||
if (validPaths.length === 0) return null;
|
||||
|
||||
const separator = isWindowsOS() ? '\\' : '/';
|
||||
if (validPaths.length === 1) {
|
||||
return validPaths[0].split(separator).pop();
|
||||
}
|
||||
return `${validPaths.length} file(s)`;
|
||||
return paths.filter((v) => v != null && v !== '');
|
||||
};
|
||||
|
||||
const columns = [
|
||||
@@ -182,29 +191,15 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
|
||||
width: '40%',
|
||||
readOnly: !editMode,
|
||||
render: ({ row, value, onChange }) => {
|
||||
const isFile = row.type === 'file';
|
||||
const fileName = isFile ? getFileName(value) : null;
|
||||
if (fileName) {
|
||||
const fileList = row.type === 'file' ? getFileList(value) : [];
|
||||
if (fileList.length > 0) {
|
||||
return (
|
||||
<div className="flex items-center file-value-cell">
|
||||
<IconFile size={16} className="text-muted mr-1" />
|
||||
<div className="file-name flex-1 truncate" title={Array.isArray(value) ? value.join(', ') : value}>
|
||||
<SingleLineEditor
|
||||
theme={storedTheme}
|
||||
value={fileName}
|
||||
readOnly={true}
|
||||
collection={collection}
|
||||
item={item}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="clear-file-btn ml-1"
|
||||
onClick={() => handleClearFile(row)}
|
||||
title="Remove file"
|
||||
>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<MultipartFileChipsCell
|
||||
files={fileList}
|
||||
onRemove={(filePath) => handleRemoveFile(row, filePath)}
|
||||
onAdd={() => handleBrowseFiles(row, onChange)}
|
||||
editMode={editMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,7 +222,7 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
|
||||
<button
|
||||
className="upload-btn ml-1"
|
||||
onClick={() => handleBrowseFiles(row, onChange)}
|
||||
title="Select file"
|
||||
title="Select File"
|
||||
>
|
||||
<IconUpload size={16} />
|
||||
</button>
|
||||
|
||||
@@ -1,68 +1,60 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import forOwn from 'lodash/forOwn';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import TimelineItem from '../Timeline/TimelineItem';
|
||||
|
||||
const RunnerTimeline = ({ request = {}, response = {}, item, collection }) => {
|
||||
const requestHeaders = [];
|
||||
// Reads from the runner item only, never collection.timeline, so a later
|
||||
// single-request invocation of the same item can't bleed into this view.
|
||||
const entries = useMemo(() => {
|
||||
const mainTimestamp = request?.timestamp ?? response?.timestamp ?? Date.now();
|
||||
|
||||
forOwn(request.headers, (value, key) => {
|
||||
requestHeaders.push({
|
||||
name: key,
|
||||
value
|
||||
const oauth = (item?.oauth2DebugEntries || []).flatMap((event) => {
|
||||
const debugInfo = event.debugInfo || [];
|
||||
return [...debugInfo].reverse().map((sub, i) => ({
|
||||
kind: 'oauth2',
|
||||
timestamp: mainTimestamp - 1 - i,
|
||||
request: sub?.request,
|
||||
response: sub?.response
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
const oauth2Events = useMemo(
|
||||
() =>
|
||||
collection?.timeline?.filter(
|
||||
(event) => event.type === 'oauth2' && event.itemUid === item.uid
|
||||
) || [],
|
||||
[collection?.timeline, item.uid]
|
||||
);
|
||||
const scripted = (item?.scriptedRequestEntries || []).map((e) => ({
|
||||
kind: 'scripted',
|
||||
timestamp: e.timestamp,
|
||||
request: e.data?.request,
|
||||
response: e.data?.response,
|
||||
source: e.source,
|
||||
scope: e.scope,
|
||||
phase: e.phase
|
||||
}));
|
||||
|
||||
const main = {
|
||||
kind: 'main',
|
||||
timestamp: mainTimestamp,
|
||||
request,
|
||||
response
|
||||
};
|
||||
|
||||
return [main, ...oauth, ...scripted].sort((a, b) => b.timestamp - a.timestamp);
|
||||
}, [item?.oauth2DebugEntries, item?.scriptedRequestEntries, request, response]);
|
||||
|
||||
return (
|
||||
<StyledWrapper className="pb-4 w-full">
|
||||
{/* Show the main request/response timeline item */}
|
||||
<TimelineItem
|
||||
request={request}
|
||||
response={response}
|
||||
item={item}
|
||||
collection={collection}
|
||||
hideTimestamp={true}
|
||||
/>
|
||||
|
||||
{oauth2Events.map((event, index) => {
|
||||
const { data, timestamp } = event;
|
||||
const { debugInfo } = data;
|
||||
return (
|
||||
<div key={`oauth2-${index}`} className="timeline-event mt-4">
|
||||
<div className="timeline-event-header cursor-pointer flex items-center">
|
||||
<div className="flex items-center">
|
||||
<span className="font-bold">OAuth2.0 Calls</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
{debugInfo && debugInfo.length > 0 ? (
|
||||
debugInfo.map((data, idx) => (
|
||||
<div key={idx} className="ml-4">
|
||||
<TimelineItem
|
||||
timestamp={timestamp}
|
||||
request={data?.request}
|
||||
response={data?.response}
|
||||
item={item}
|
||||
collection={collection}
|
||||
isOauth2={true}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div>No debug information available.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{entries.map((entry, idx) => (
|
||||
<TimelineItem
|
||||
key={`${entry.kind}-${idx}`}
|
||||
timestamp={entry.timestamp}
|
||||
request={entry.request}
|
||||
response={entry.response}
|
||||
item={item}
|
||||
collection={collection}
|
||||
isOauth2={entry.kind === 'oauth2'}
|
||||
source={entry.kind === 'main' ? 'main' : (entry.kind === 'scripted' ? entry.source : undefined)}
|
||||
scope={entry.kind === 'scripted' ? entry.scope : undefined}
|
||||
phase={entry.kind === 'scripted' ? entry.phase : undefined}
|
||||
hideTimestamp={true}
|
||||
/>
|
||||
))}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -40,7 +40,7 @@ const GrpcTimelineItem = ({ timestamp, request, response, eventType, collection,
|
||||
|
||||
// Extract relevant data from request and response
|
||||
const { method, url = '' } = effectiveRequest;
|
||||
const { statusCode, statusText, duration } = response || {};
|
||||
const { statusCode, duration } = response || {};
|
||||
|
||||
// Get event-specific icon and class names
|
||||
const getEventIcon = () => {
|
||||
@@ -194,7 +194,7 @@ const GrpcTimelineItem = ({ timestamp, request, response, eventType, collection,
|
||||
return (
|
||||
<div className="content-status">
|
||||
<div className="flex items-center gap-2">
|
||||
<Status statusCode={statusCode} statusText={statusText} />
|
||||
<Status statusCode={statusCode} />
|
||||
</div>
|
||||
|
||||
{response.statusDescription && (
|
||||
@@ -227,7 +227,7 @@ const GrpcTimelineItem = ({ timestamp, request, response, eventType, collection,
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Status statusCode={statusCode} statusText={statusText} />
|
||||
<Status statusCode={statusCode} />
|
||||
</div>
|
||||
|
||||
{response.trailers && response.trailers.length > 0 && (
|
||||
@@ -286,7 +286,7 @@ const GrpcTimelineItem = ({ timestamp, request, response, eventType, collection,
|
||||
)}
|
||||
{eventType === 'status' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Status statusCode={statusCode} statusText={statusText} />
|
||||
<Status statusCode={statusCode} />
|
||||
</div>
|
||||
)}
|
||||
<pre className="event-timestamp">[{new Date(timestamp).toISOString()}]</pre>
|
||||
|
||||
@@ -10,154 +10,58 @@ const StyledWrapper = styled.div`
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
border-color: ${(props) => props.theme.border.border1};
|
||||
.timeline-filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 0;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border1};
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.timeline-chip {
|
||||
padding: 4px 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
transition: color 0.1s ease, background-color 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.bg2 || 'rgba(255, 255, 255, 0.04)'};
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.bg2 || 'rgba(255, 255, 255, 0.06)'};
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-chip-count {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
opacity: 0.6;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.timeline-chip.is-active .timeline-chip-count {
|
||||
color: ${(props) => props.theme.tabs.active.border};
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.timeline-event {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timeline-event-content {
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.timeline-event-header {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.method-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-code {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.url-text {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
}
|
||||
|
||||
.meta-info {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
}
|
||||
|
||||
.oauth-section {
|
||||
.oauth-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-weight: 500;
|
||||
|
||||
span {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tabs-switcher {
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border1};
|
||||
margin-bottom: 16px;
|
||||
|
||||
button {
|
||||
position: relative;
|
||||
padding: 8px 16px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&.active {
|
||||
color: ${(props) => props.theme.tabs.active.color};
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: ${(props) => props.theme.tabs.active.border};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.network-logs {
|
||||
background: ${(props) => props.theme.codemirror.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.oauth-request-item-content {
|
||||
border-radius: 4px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.collapsible-section {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.section-header {
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.line {
|
||||
white-space: pre-line;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
font-family: ${(props) => props.theme.font || 'Inter, sans-serif'} !important;
|
||||
|
||||
.arrow {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.request {
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
|
||||
&.response {
|
||||
color: ${(props) => props.theme.colors.text.purple};
|
||||
}
|
||||
}
|
||||
|
||||
.request-label {
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
margin-left: 8px;
|
||||
background: ${(props) => props.theme.requestTabs.bg};
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-weight: 500;
|
||||
table-layout: fixed;
|
||||
|
||||
thead,
|
||||
td {
|
||||
border: 1px solid ${(props) => props.theme.table.border};
|
||||
}
|
||||
|
||||
thead {
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
user-select: none;
|
||||
}
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -1,35 +1,43 @@
|
||||
import QueryResponse from 'components/ResponsePane/QueryResponse/index';
|
||||
import { useState } from 'react';
|
||||
import { IconChevronDown, IconChevronRight } from '@tabler/icons';
|
||||
import QueryResponse from 'components/ResponsePane/QueryResponse/index';
|
||||
|
||||
const BodyBlock = ({ collection, data, dataBuffer, headers, error, item, type }) => {
|
||||
const [isBodyCollapsed, toggleBody] = useState(true);
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const hasBody = !!(data || dataBuffer);
|
||||
|
||||
return (
|
||||
<div className="collapsible-section">
|
||||
<div className="section-header" onClick={() => toggleBody(!isBodyCollapsed)}>
|
||||
<pre className="flex flex-row items-center">
|
||||
<div className="opacity-70">{isBodyCollapsed ? '▼' : '▶'}</div> Body
|
||||
</pre>
|
||||
</div>
|
||||
{isBodyCollapsed && (
|
||||
<div className="mt-2">
|
||||
{data || dataBuffer ? (
|
||||
<div className="h-96 overflow-auto">
|
||||
<QueryResponse
|
||||
item={item}
|
||||
collection={collection}
|
||||
data={data}
|
||||
dataBuffer={dataBuffer}
|
||||
headers={headers}
|
||||
error={error}
|
||||
key={item?.uid}
|
||||
hideResultTypeSelector={type === 'request'}
|
||||
docKey={`timeline-body:${type}:${item?.uid}`}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="timeline-item-timestamp">No Body found</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="tl-block">
|
||||
<button
|
||||
type="button"
|
||||
className="tl-block-h"
|
||||
aria-expanded={isOpen}
|
||||
data-testid="response-body-toggle"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<span className="tl-block-chev">
|
||||
{isOpen ? <IconChevronDown size={12} strokeWidth={2} /> : <IconChevronRight size={12} strokeWidth={2} />}
|
||||
</span>
|
||||
Body
|
||||
</button>
|
||||
{isOpen && (
|
||||
hasBody ? (
|
||||
<div className="h-96 overflow-auto">
|
||||
<QueryResponse
|
||||
item={item}
|
||||
collection={collection}
|
||||
data={data}
|
||||
dataBuffer={dataBuffer}
|
||||
headers={headers}
|
||||
error={error}
|
||||
key={item?.uid}
|
||||
hideResultTypeSelector={type === 'request'}
|
||||
docKey={`timeline-body:${type}:${item?.uid}`}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tl-empty">No Body</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,52 +1,52 @@
|
||||
import { useState } from 'react';
|
||||
import { IconChevronDown, IconChevronRight } from '@tabler/icons';
|
||||
|
||||
const HeadersBlock = ({ headers, type }) => {
|
||||
const [areHeadersCollapsed, toggleHeaders] = useState(true);
|
||||
const toEntries = (headers) => {
|
||||
if (!headers) return [];
|
||||
if (Array.isArray(headers)) {
|
||||
return headers.map((h) => ({ name: h?.name, value: h?.value }));
|
||||
}
|
||||
return Object.entries(headers).map(([name, value]) => ({ name, value }));
|
||||
};
|
||||
|
||||
const Headers = ({ headers }) => {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const entries = toEntries(headers);
|
||||
const count = entries.length;
|
||||
|
||||
return (
|
||||
<div className="collapsible-section mt-2">
|
||||
<div className="section-header" onClick={() => toggleHeaders(!areHeadersCollapsed)}>
|
||||
<pre className="flex flex-row items-center">
|
||||
<div className="opacity-70">{areHeadersCollapsed ? '▼' : '▶'}</div> Headers
|
||||
{headers && Object.keys(headers).length > 0
|
||||
&& <div className="ml-1">({Object.keys(headers).length})</div>}
|
||||
</pre>
|
||||
</div>
|
||||
{areHeadersCollapsed && (
|
||||
<div className="mt-1">
|
||||
{headers && Object.keys(headers).length > 0
|
||||
? <Headers headers={headers} type={type} />
|
||||
: <div className="timeline-item-timestamp">No Headers found</div>}
|
||||
</div>
|
||||
<div className="tl-block">
|
||||
<button
|
||||
type="button"
|
||||
className="tl-block-h"
|
||||
aria-expanded={isOpen}
|
||||
data-testid="headers-toggle"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<span className="tl-block-chev">
|
||||
{isOpen ? <IconChevronDown size={12} strokeWidth={2} /> : <IconChevronRight size={12} strokeWidth={2} />}
|
||||
</span>
|
||||
Headers
|
||||
<span className="tl-block-count">({count})</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
count === 0
|
||||
? <div className="tl-empty">No Headers</div>
|
||||
: (
|
||||
<table className="tl-headers-table">
|
||||
<tbody>
|
||||
{entries.map((h, i) => (
|
||||
<tr key={i}>
|
||||
<td className="tl-headers-key">{h.name}</td>
|
||||
<td className="tl-headers-val">{String(h.value)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Headers = ({ headers, type }) => {
|
||||
if (Array.isArray(headers)) {
|
||||
return (
|
||||
<div className="mt-1">
|
||||
{headers.map((header, index) => (
|
||||
<pre key={index} className="mb-1 whitespace-pre-wrap">
|
||||
{type === 'request' ? '>' : '<'} <span className="opacity-60">{header?.name}:</span>
|
||||
<span className="whitespace-pre-wrap">{String(header?.value)}</span>
|
||||
</pre>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="mt-1">
|
||||
{Object.entries(headers).map(([key, value], index) => (
|
||||
<pre key={index} className="mb-1 whitespace-pre-wrap">
|
||||
{type === 'request' ? '>' : '<'} <span className="opacity-60">{key}:</span>
|
||||
<span>{String(value)}</span>
|
||||
</pre>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default HeadersBlock;
|
||||
export default Headers;
|
||||
|
||||
@@ -1,21 +1,39 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { rgba } from 'polished';
|
||||
|
||||
const Status = ({ statusCode, statusText }) => {
|
||||
const Status = ({ statusCode }) => {
|
||||
const { theme } = useTheme();
|
||||
const isStringCode = typeof statusCode === 'string' && statusCode.length > 0;
|
||||
|
||||
let statusColor = theme.colors.text.muted;
|
||||
let color = theme.colors.text.muted;
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
statusColor = theme.requestTabPanel.responseOk;
|
||||
color = theme.requestTabPanel.responseOk;
|
||||
} else if (statusCode >= 300 && statusCode < 400) {
|
||||
statusColor = theme.colors.text.warning;
|
||||
color = theme.colors.text.warning;
|
||||
} else if (statusCode >= 400 && statusCode < 600) {
|
||||
statusColor = theme.requestTabPanel.responseError;
|
||||
color = theme.requestTabPanel.responseError;
|
||||
}
|
||||
|
||||
const isStatusKnown = (typeof statusCode === 'number' && statusCode > 0) || isStringCode;
|
||||
const background = isStatusKnown ? rgba(color, 0.12) : 'transparent';
|
||||
|
||||
return (
|
||||
<span className="timeline-status" style={{ color: statusColor, fontWeight: 'bold' }}>
|
||||
{statusCode}{' '}
|
||||
{statusText || ''}
|
||||
<span
|
||||
className="timeline-status"
|
||||
data-testid="timeline-status"
|
||||
style={{
|
||||
color,
|
||||
background,
|
||||
fontWeight: 600,
|
||||
fontSize: 11,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 3,
|
||||
letterSpacing: '0.02em',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{statusCode}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider as SCThemeProvider } from 'styled-components';
|
||||
import { ThemeContext } from 'providers/Theme';
|
||||
import Status from './index';
|
||||
|
||||
const theme = {
|
||||
colors: {
|
||||
text: { muted: '#888888', warning: '#f59e0b' }
|
||||
},
|
||||
requestTabPanel: {
|
||||
responseOk: '#22c55e',
|
||||
responseError: '#ef4444'
|
||||
}
|
||||
};
|
||||
|
||||
const renderStatus = (props) =>
|
||||
render(
|
||||
<ThemeContext.Provider value={{ theme, displayedTheme: 'dark', storedTheme: 'system', setStoredTheme: () => {} }}>
|
||||
<SCThemeProvider theme={theme}>
|
||||
<Status {...props} />
|
||||
</SCThemeProvider>
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
|
||||
const getPill = () => document.querySelector('.timeline-status');
|
||||
|
||||
describe('Timeline Status', () => {
|
||||
describe('numeric HTTP codes', () => {
|
||||
it('colors 2xx as success and shows a tinted background', () => {
|
||||
renderStatus({ statusCode: 200 });
|
||||
const pill = getPill();
|
||||
expect(pill).toHaveTextContent('200');
|
||||
expect(pill).toHaveStyle({ color: theme.requestTabPanel.responseOk });
|
||||
expect(pill.style.background).not.toBe('transparent');
|
||||
});
|
||||
|
||||
it('colors 3xx as warning', () => {
|
||||
renderStatus({ statusCode: 301 });
|
||||
expect(getPill()).toHaveStyle({ color: theme.colors.text.warning });
|
||||
});
|
||||
|
||||
it('colors 4xx as error', () => {
|
||||
renderStatus({ statusCode: 404 });
|
||||
expect(getPill()).toHaveStyle({ color: theme.requestTabPanel.responseError });
|
||||
});
|
||||
|
||||
it('colors 5xx as error', () => {
|
||||
renderStatus({ statusCode: 503 });
|
||||
expect(getPill()).toHaveStyle({ color: theme.requestTabPanel.responseError });
|
||||
});
|
||||
});
|
||||
|
||||
describe('string codes (pre-send network failures)', () => {
|
||||
it('renders ECONNREFUSED in muted/gray (not red)', () => {
|
||||
renderStatus({ statusCode: 'ECONNREFUSED' });
|
||||
const pill = getPill();
|
||||
expect(pill).toHaveTextContent('ECONNREFUSED');
|
||||
expect(pill).toHaveStyle({ color: theme.colors.text.muted });
|
||||
// String codes still get a tinted pill background so they're visible
|
||||
expect(pill.style.background).not.toBe('transparent');
|
||||
});
|
||||
|
||||
it('renders "Error" in muted/gray', () => {
|
||||
renderStatus({ statusCode: 'Error' });
|
||||
const pill = getPill();
|
||||
expect(pill).toHaveTextContent('Error');
|
||||
expect(pill).toHaveStyle({ color: theme.colors.text.muted });
|
||||
});
|
||||
|
||||
it('renders ETIMEDOUT in muted/gray', () => {
|
||||
renderStatus({ statusCode: 'ETIMEDOUT' });
|
||||
expect(getPill()).toHaveStyle({ color: theme.colors.text.muted });
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown / absent codes', () => {
|
||||
it('renders nothing visible when statusCode is undefined', () => {
|
||||
renderStatus({ statusCode: undefined });
|
||||
const pill = getPill();
|
||||
// Pill still mounts but has transparent background and no text
|
||||
expect(pill).toBeInTheDocument();
|
||||
expect(pill.textContent).toBe('');
|
||||
expect(pill.style.background).toBe('transparent');
|
||||
});
|
||||
|
||||
it('keeps background transparent when statusCode is 0 (no real status)', () => {
|
||||
renderStatus({ statusCode: 0 });
|
||||
const pill = getPill();
|
||||
expect(pill.style.background).toBe('transparent');
|
||||
});
|
||||
|
||||
it('keeps background transparent for empty string', () => {
|
||||
renderStatus({ statusCode: '' });
|
||||
const pill = getPill();
|
||||
expect(pill.style.background).toBe('transparent');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,16 +2,18 @@ import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.network-logs-container {
|
||||
background: ${(props) => props.theme.codemirror.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
border-radius: 4px;
|
||||
overflow: auto;
|
||||
height: 24rem;
|
||||
}
|
||||
|
||||
.network-logs-pre {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
white-space: pre-wrap;
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
word-break: break-word;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
|
||||
@@ -25,7 +27,7 @@ const StyledWrapper = styled.div`
|
||||
&--response {
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
|
||||
|
||||
&--error {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
@@ -33,20 +35,20 @@ const StyledWrapper = styled.div`
|
||||
&--tls {
|
||||
color: ${(props) => props.theme.colors.text.purple};
|
||||
}
|
||||
|
||||
|
||||
&--info {
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.network-logs-separator {
|
||||
border-top: 2px solid ${(props) => props.theme.border.border1};
|
||||
border-top: 1px solid ${(props) => props.theme.border.border1};
|
||||
width: 100%;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.network-logs-spacing {
|
||||
margin-top: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -3,11 +3,7 @@ import BodyBlock from '../Common/Body/index';
|
||||
|
||||
const safeStringifyJSONIfNotString = (obj) => {
|
||||
if (obj === null || obj === undefined) return '';
|
||||
|
||||
if (typeof obj === 'string') {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (typeof obj === 'string') return obj;
|
||||
try {
|
||||
return JSON.stringify(obj);
|
||||
} catch (e) {
|
||||
@@ -16,24 +12,24 @@ const safeStringifyJSONIfNotString = (obj) => {
|
||||
};
|
||||
|
||||
const Request = ({ collection, request, item }) => {
|
||||
let { url, headers, data, dataBuffer, error } = request || {};
|
||||
let { headers, data, dataBuffer, error } = request || {};
|
||||
if (!dataBuffer) {
|
||||
dataBuffer = Buffer.from(safeStringifyJSONIfNotString(data))?.toString('base64');
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Method and URL */}
|
||||
<div className="mb-1 flex gap-2">
|
||||
<pre className="whitespace-pre-wrap" title={url}>{url}</pre>
|
||||
</div>
|
||||
|
||||
{/* Headers */}
|
||||
<Headers headers={headers} type="request" />
|
||||
|
||||
{/* Body */}
|
||||
<BodyBlock collection={collection} data={data} dataBuffer={dataBuffer} error={error} headers={headers} item={item} type="request" />
|
||||
</div>
|
||||
<>
|
||||
<Headers headers={headers} />
|
||||
<BodyBlock
|
||||
collection={collection}
|
||||
data={data}
|
||||
dataBuffer={dataBuffer}
|
||||
error={error}
|
||||
headers={headers}
|
||||
item={item}
|
||||
type="request"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { formatSize } from 'utils/common';
|
||||
import BodyBlock from '../Common/Body/index';
|
||||
import Headers from '../Common/Headers/index';
|
||||
import Status from '../Common/Status/index';
|
||||
|
||||
const safeStringifyJSONIfNotString = (obj) => {
|
||||
if (obj === null || obj === undefined) return '';
|
||||
|
||||
if (typeof obj === 'string') {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (typeof obj === 'string') return obj;
|
||||
try {
|
||||
return JSON.stringify(obj);
|
||||
} catch (e) {
|
||||
@@ -16,27 +13,59 @@ const safeStringifyJSONIfNotString = (obj) => {
|
||||
}
|
||||
};
|
||||
|
||||
const statusColor = (theme, statusCode) => {
|
||||
if (statusCode >= 200 && statusCode < 300) return theme.requestTabPanel.responseOk;
|
||||
if (statusCode >= 300 && statusCode < 400) return theme.colors.text.warning;
|
||||
if (statusCode >= 400 && statusCode < 600) return theme.requestTabPanel.responseError;
|
||||
return theme.colors.text.muted;
|
||||
};
|
||||
|
||||
const ResponseMeta = ({ code, statusText, duration, size }) => {
|
||||
const { theme } = useTheme();
|
||||
const sizeLabel = typeof size === 'number' ? formatSize(size) : null;
|
||||
const hasCode = code != null;
|
||||
const hasAny = hasCode || statusText || (typeof duration === 'number') || sizeLabel;
|
||||
if (!hasAny) return null;
|
||||
return (
|
||||
<div className="tl-response-meta">
|
||||
{(hasCode || statusText) && (
|
||||
<span className="tl-response-meta-status" style={{ color: statusColor(theme, code) }}>
|
||||
{code} {statusText || ''}
|
||||
</span>
|
||||
)}
|
||||
{typeof duration === 'number' && (
|
||||
<span className="tl-response-meta-item">{Math.round(duration)}ms</span>
|
||||
)}
|
||||
{sizeLabel && <span className="tl-response-meta-item">{sizeLabel}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Response = ({ collection, response, item }) => {
|
||||
let { status, statusCode, statusText, dataBuffer, headers, data, error } = response || {};
|
||||
let { status, statusCode, statusText, dataBuffer, headers, data, error, duration, size } = response || {};
|
||||
if (!dataBuffer) {
|
||||
dataBuffer = Buffer.from(safeStringifyJSONIfNotString(data))?.toString('base64');
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Status */}
|
||||
<div className="mb-1">
|
||||
<Status statusCode={status || statusCode} statusText={statusText} />
|
||||
{response.duration && <span className="timeline-item-metadata">{response.duration}ms</span>}
|
||||
{response.size && <span className="timeline-item-metadata">{response.size}B</span>}
|
||||
</div>
|
||||
|
||||
{/* Headers */}
|
||||
<Headers headers={headers} type="response" />
|
||||
|
||||
{/* Body */}
|
||||
<BodyBlock collection={collection} data={data} dataBuffer={dataBuffer} error={error} headers={headers} item={item} type="response" />
|
||||
</div>
|
||||
<>
|
||||
<ResponseMeta
|
||||
code={statusCode ?? status}
|
||||
statusText={statusText}
|
||||
duration={duration}
|
||||
size={size}
|
||||
/>
|
||||
<Headers headers={headers} />
|
||||
<BodyBlock
|
||||
collection={collection}
|
||||
data={data}
|
||||
dataBuffer={dataBuffer}
|
||||
error={error}
|
||||
headers={headers}
|
||||
item={item}
|
||||
type="response"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,111 +2,288 @@ import styled from 'styled-components';
|
||||
import { rgba } from 'polished';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.timeline-item {
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border1};
|
||||
padding: 0.5rem 0;
|
||||
|
||||
&--oauth2 {
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border1};
|
||||
}
|
||||
.tl-row-wrap {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.timeline-item-header {
|
||||
.tl-row {
|
||||
display: grid;
|
||||
/* Badge and time use fixed widths so they line up across rows. */
|
||||
grid-template-columns: 14px auto 50px minmax(0, 1fr) 96px 100px;
|
||||
column-gap: 10px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color 0.08s ease;
|
||||
min-width: 0;
|
||||
padding: 7px 4px;
|
||||
border-top: 1px solid ${(props) => props.theme.border.border1};
|
||||
}
|
||||
.tl-row:hover {
|
||||
background: ${(props) => props.theme.bg2 || rgba(props.theme.text, 0.04)};
|
||||
}
|
||||
.tl-row.is-expanded {
|
||||
background: ${(props) => props.theme.bg2 || rgba(props.theme.text, 0.06)};
|
||||
}
|
||||
.tl-row:focus-visible {
|
||||
outline: 2px solid ${(props) => props.theme.textLink};
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.tl-row-wrap:first-child .tl-row {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.tl-col-chev {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
opacity: 0.7;
|
||||
line-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tl-col-status,
|
||||
.tl-col-method,
|
||||
.tl-col-url,
|
||||
.tl-col-badge,
|
||||
.tl-col-time {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tl-col-status .timeline-status {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tl-col-method {
|
||||
padding-right: 14px;
|
||||
}
|
||||
|
||||
.tl-col-url {
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tl-col-time {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.tl-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
letter-spacing: 0.02em;
|
||||
background: ${(props) => props.theme.bg2 || rgba(props.theme.text, 0.06)};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tl-badge--main {
|
||||
background: ${(props) => rgba(props.theme.colors.text.green, 0.14)};
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
.tl-badge--oauth2 {
|
||||
background: ${(props) => rgba(props.theme.textLink, 0.12)};
|
||||
color: ${(props) => props.theme.textLink};
|
||||
}
|
||||
.tl-badge--scripted {
|
||||
background: ${(props) => rgba(props.theme.colors.text.yellow, 0.12)};
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
}
|
||||
.tl-badge--run-request {
|
||||
background: ${(props) => rgba(props.theme.colors.text.purple, 0.14)};
|
||||
color: ${(props) => props.theme.colors.text.purple};
|
||||
}
|
||||
|
||||
.tl-detail {
|
||||
border-top: 1px dashed ${(props) => props.theme.border.border1};
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.tl-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px 10px 28px;
|
||||
}
|
||||
.tl-header-url {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: ${(props) => props.theme.font?.mono || 'var(--font-family-mono)'};
|
||||
font-size: 13px;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
.tl-header-url-method {
|
||||
font-weight: 600;
|
||||
margin-right: 6px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tl-header-src {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-family: ${(props) => props.theme.font?.mono || 'var(--font-family-mono)'};
|
||||
font-size: 11px;
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tl-header-src:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
.tl-header-src-file {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tl-header-src-icon {
|
||||
color: ${(props) => props.theme.textLink};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Outer padding compensates for the first tab's 14px left padding so the
|
||||
tab text lines up with the URL above. */
|
||||
.tl-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px 0 14px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border1};
|
||||
}
|
||||
.tl-tab {
|
||||
position: relative;
|
||||
padding: 9px 14px;
|
||||
margin-bottom: -1px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timeline-item-header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
.tl-tab:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
.tl-tab.is-active {
|
||||
color: ${(props) => props.theme.tabs.active.color};
|
||||
}
|
||||
.tl-tab.is-active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background: ${(props) => props.theme.tabs.active.border};
|
||||
}
|
||||
|
||||
.timeline-item-header-items {
|
||||
.tl-panel {
|
||||
padding: 12px 12px 14px 28px;
|
||||
}
|
||||
|
||||
.tl-response-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
padding: 6px 0 4px 0;
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
.tl-response-meta-status {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
.tl-response-meta-item {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.tl-block {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.tl-block:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.tl-block-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.timeline-item-url {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-top: 0.25rem;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
margin-bottom: 8px;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.timeline-item-timestamp {
|
||||
.tl-block-h:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
.tl-block-chev {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.timeline-item-timestamp-iso {
|
||||
opacity: 0.7;
|
||||
.tl-block-count {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
opacity: 0.65;
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.timeline-item-oauth-label {
|
||||
opacity: 0.5;
|
||||
.tl-headers-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-family: ${(props) => props.theme.font?.mono || 'var(--font-family-mono)'};
|
||||
font-size: 12px;
|
||||
table-layout: auto;
|
||||
}
|
||||
.tl-headers-table tr {
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border1};
|
||||
}
|
||||
.tl-headers-table tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.tl-headers-table tr:hover {
|
||||
background: ${(props) => props.theme.bg2 || rgba(props.theme.text, 0.03)};
|
||||
}
|
||||
.tl-headers-table td {
|
||||
padding: 5px 10px 5px 0;
|
||||
vertical-align: top;
|
||||
word-break: break-all;
|
||||
border: none;
|
||||
}
|
||||
.tl-headers-table td.tl-headers-key {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
width: 220px;
|
||||
min-width: 120px;
|
||||
max-width: 280px;
|
||||
}
|
||||
.tl-headers-table td.tl-headers-val {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.timeline-item-content {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timeline-item-tabs {
|
||||
display: flex;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.timeline-item-tab {
|
||||
margin-right: 1rem;
|
||||
position: relative;
|
||||
padding: 0.5rem 1rem;
|
||||
.tl-empty {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
|
||||
&--active {
|
||||
color: ${(props) => props.theme.tabs.active.color};
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: ${(props) => props.theme.tabs.active.border};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-item-tab-content {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.timeline-item-metadata {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
margin-left: 0.5rem;
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
}
|
||||
|
||||
.collapsible-section {
|
||||
.section-header {
|
||||
cursor: pointer;
|
||||
pre {
|
||||
color: ${(props) => rgba(props.theme.primary.text, 0.8)};
|
||||
}
|
||||
}
|
||||
font-size: 12px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,83 +1,221 @@
|
||||
import { useState } from 'react';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import Network from './Network/index';
|
||||
import Request from './Request/index';
|
||||
import Response from './Response/index';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { IconChevronDown, IconChevronRight } from '@tabler/icons';
|
||||
import Method from './Common/Method/index';
|
||||
import Status from './Common/Status/index';
|
||||
import { RelativeTime } from './Common/Time/index';
|
||||
import Network from './Network/index';
|
||||
import Request from './Request/index';
|
||||
import Response from './Response/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { usePersistedState } from 'hooks/usePersistedState/index';
|
||||
import { flattenItems } from 'utils/collections/index';
|
||||
import { getRelativePath } from 'utils/common/path';
|
||||
import { addTab, updateRequestPaneTab, updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { updateSettingsSelectedTab, updatedFolderSettingsSelectedTab } from 'providers/ReduxStore/slices/collections';
|
||||
import { getBadge } from '../entryMeta';
|
||||
|
||||
const TimelineItem = ({ timestamp, request, response, item, collection, isOauth2, hideTimestamp = false }) => {
|
||||
const { theme } = useTheme();
|
||||
const [isCollapsed, _toggleCollapse] = usePersistedState({
|
||||
const findFolderByScopeFile = (collection, sourceFile) => {
|
||||
if (!collection?.pathname || !sourceFile) return null;
|
||||
const dir = sourceFile.replace(/\/folder\.(?:bru|yml)$/, '');
|
||||
if (!dir || dir === sourceFile) return null;
|
||||
return flattenItems(collection.items || []).find(
|
||||
(i) => i.type === 'folder' && getRelativePath(collection.pathname, i.pathname) === dir
|
||||
) || null;
|
||||
};
|
||||
|
||||
const TimelineItem = ({
|
||||
timestamp,
|
||||
request,
|
||||
response,
|
||||
error,
|
||||
item,
|
||||
collection,
|
||||
isOauth2,
|
||||
hideTimestamp = false,
|
||||
source,
|
||||
scope,
|
||||
phase
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const [isExpanded, _toggleExpand] = usePersistedState({
|
||||
key: `timeline-${timestamp}`,
|
||||
default: false
|
||||
});
|
||||
const [activeTab, setActiveTab] = useState('request');
|
||||
const toggleCollapse = () => _toggleCollapse((prev) => !prev);
|
||||
const { method, status, statusCode, statusText, url = '' } = request || {};
|
||||
const { status: responseStatus, statusCode: responseStatusCode, statusText: responseStatusText } = response || {};
|
||||
const showNetworkLogs = response.timeline && response.timeline.length > 0;
|
||||
// CodeMirror reads its size on mount and stays blank if hidden. Lazy-mount
|
||||
// each tab on first visit and keep it mounted, toggling display only.
|
||||
const [visitedTabs, setVisitedTabs] = useState({ request: true });
|
||||
const toggleExpand = () => _toggleExpand((prev) => !prev);
|
||||
const handleRowKeyDown = (ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault();
|
||||
toggleExpand();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded) setVisitedTabs({ [activeTab]: true });
|
||||
}, [isExpanded]);
|
||||
|
||||
const handleTabClick = (id) => {
|
||||
setActiveTab(id);
|
||||
setVisitedTabs((v) => (v[id] ? v : { ...v, [id]: true }));
|
||||
};
|
||||
|
||||
const { method, url = '' } = request || {};
|
||||
// Main-request entries use `status`; scripted entries use `statusCode`.
|
||||
const { status, statusCode, statusText } = response || {};
|
||||
const numericCode = typeof statusCode === 'number'
|
||||
? statusCode
|
||||
: typeof status === 'number'
|
||||
? status
|
||||
: null;
|
||||
const code = numericCode != null
|
||||
? numericCode
|
||||
: (statusText || (error ? 'Error' : undefined));
|
||||
const showNetworkLogs = response?.timeline && response.timeline.length > 0;
|
||||
const badge = getBadge({ source, isOauth2 });
|
||||
|
||||
const isMainOrOauth = !source || source === 'main' || isOauth2;
|
||||
const scopeType = scope?.type || (isMainOrOauth ? null : 'request');
|
||||
const requestExt = collection?.format === 'yml' ? '.yml' : '.bru';
|
||||
const scopeFile = scope?.sourceFile
|
||||
|| (scopeType === 'request' ? (item?.filename || (item?.name ? `${item.name}${requestExt}` : null)) : null);
|
||||
const sourceFile = isMainOrOauth ? null : scopeFile;
|
||||
|
||||
const folderForScope = scopeType === 'folder'
|
||||
? findFolderByScopeFile(collection, scope?.sourceFile)
|
||||
: null;
|
||||
const navTarget = (() => {
|
||||
if (!collection?.uid) return null;
|
||||
if (scopeType === 'collection') return { kind: 'collection' };
|
||||
if (scopeType === 'folder' && folderForScope?.uid) return { kind: 'folder', uid: folderForScope.uid };
|
||||
if (scopeType === 'request' && item?.uid) return { kind: 'request', uid: item.uid };
|
||||
return null;
|
||||
})();
|
||||
const canNavigate = !!navTarget;
|
||||
const handleNavigate = (ev) => {
|
||||
ev?.preventDefault?.();
|
||||
ev?.stopPropagation?.();
|
||||
if (!navTarget) return;
|
||||
// Collection settings expect tab 'tests' (plural); folder settings expect 'test' (singular).
|
||||
const isTestsPhase = phase === 'tests';
|
||||
const scriptPaneTab = phase || 'pre-request';
|
||||
if (navTarget.kind === 'collection') {
|
||||
dispatch(addTab({ uid: collection.uid, collectionUid: collection.uid, type: 'collection-settings' }));
|
||||
if (isTestsPhase) {
|
||||
dispatch(updateSettingsSelectedTab({ collectionUid: collection.uid, tab: 'tests' }));
|
||||
} else {
|
||||
dispatch(updateSettingsSelectedTab({ collectionUid: collection.uid, tab: 'script' }));
|
||||
dispatch(updateScriptPaneTab({ uid: collection.uid, scriptPaneTab }));
|
||||
}
|
||||
} else if (navTarget.kind === 'folder') {
|
||||
dispatch(addTab({ uid: navTarget.uid, collectionUid: collection.uid, type: 'folder-settings' }));
|
||||
if (isTestsPhase) {
|
||||
dispatch(updatedFolderSettingsSelectedTab({ collectionUid: collection.uid, folderUid: navTarget.uid, tab: 'test' }));
|
||||
} else {
|
||||
dispatch(updatedFolderSettingsSelectedTab({ collectionUid: collection.uid, folderUid: navTarget.uid, tab: 'script' }));
|
||||
dispatch(updateScriptPaneTab({ uid: navTarget.uid, scriptPaneTab }));
|
||||
}
|
||||
} else if (navTarget.kind === 'request') {
|
||||
dispatch(addTab({ uid: navTarget.uid, collectionUid: collection.uid, type: 'request' }));
|
||||
if (isTestsPhase) {
|
||||
dispatch(updateRequestPaneTab({ uid: navTarget.uid, requestPaneTab: 'tests' }));
|
||||
} else {
|
||||
dispatch(updateRequestPaneTab({ uid: navTarget.uid, requestPaneTab: 'script' }));
|
||||
dispatch(updateScriptPaneTab({ uid: navTarget.uid, scriptPaneTab }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'request', label: 'Request' },
|
||||
{ id: 'response', label: 'Response' },
|
||||
...(showNetworkLogs ? [{ id: 'network', label: 'Network' }] : [])
|
||||
];
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className={`timeline-item ${isOauth2 ? 'timeline-item--oauth2' : ''}`}>
|
||||
<div className="oauth-request-item-header relative cursor-pointer flex items-center justify-between gap-3 min-w-0" onClick={toggleCollapse}>
|
||||
<Status statusCode={responseStatus || responseStatusCode} statusText={responseStatusText} />
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={`tl-row-wrap ${isOauth2 ? 'tl-row-wrap--oauth2' : ''}`} data-testid="timeline-entry">
|
||||
<div
|
||||
className={`tl-row ${isExpanded ? 'is-expanded' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={isExpanded}
|
||||
onClick={toggleExpand}
|
||||
onKeyDown={handleRowKeyDown}
|
||||
data-testid="timeline-item-header"
|
||||
>
|
||||
<div className="tl-col-chev">
|
||||
{isExpanded ? <IconChevronDown size={14} strokeWidth={2} /> : <IconChevronRight size={14} strokeWidth={2} />}
|
||||
</div>
|
||||
<div className="tl-col-status">
|
||||
<Status statusCode={code} />
|
||||
</div>
|
||||
<div className="tl-col-method">
|
||||
<Method method={method} />
|
||||
<div className="truncate flex-1 min-w-0">{url}</div>
|
||||
{isOauth2 && <span className="text-xs flex-shrink-0" style={{ color: theme.colors.text.muted }}>[oauth2.0]</span>}
|
||||
</div>
|
||||
<div className="tl-col-url" title={url} data-testid="timeline-url">{url}</div>
|
||||
<div className="tl-col-badge">
|
||||
<span className={badge.badgeClass} data-testid={`timeline-badge-${badge.kind}`}>{badge.badgeLabel}</span>
|
||||
</div>
|
||||
{!hideTimestamp && (
|
||||
<span className="flex-shrink-0 ml-auto">
|
||||
<div className="tl-col-time">
|
||||
<RelativeTime timestamp={timestamp} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isCollapsed && (
|
||||
<div className="timeline-item-content">
|
||||
{/* Tabs */}
|
||||
<div className="timeline-item-tabs">
|
||||
<button
|
||||
className={`timeline-item-tab ${activeTab === 'request' ? 'timeline-item-tab--active' : ''}`}
|
||||
onClick={() => setActiveTab('request')}
|
||||
>
|
||||
Request
|
||||
</button>
|
||||
<button
|
||||
className={`timeline-item-tab ${activeTab === 'response' ? 'timeline-item-tab--active' : ''}`}
|
||||
onClick={() => setActiveTab('response')}
|
||||
>
|
||||
Response
|
||||
</button>
|
||||
{showNetworkLogs && (
|
||||
<button
|
||||
className={`timeline-item-tab ${activeTab === 'networkLogs' ? 'timeline-item-tab--active' : ''}`}
|
||||
onClick={() => setActiveTab('networkLogs')}
|
||||
|
||||
{isExpanded && (
|
||||
<div className="tl-detail" data-testid="timeline-detail">
|
||||
<div className="tl-header">
|
||||
<div className="tl-header-url" title={`${method || ''} ${url}`}>
|
||||
<span className="tl-header-url-method">{method}</span>
|
||||
<span className="tl-header-url-text">{url}</span>
|
||||
</div>
|
||||
{sourceFile && (
|
||||
<a
|
||||
className={`tl-header-src${canNavigate ? '' : ' is-disabled'}`}
|
||||
href="#"
|
||||
title={canNavigate ? `Open ${sourceFile}` : sourceFile}
|
||||
onClick={canNavigate ? handleNavigate : (ev) => ev.preventDefault()}
|
||||
data-testid="timeline-source-link"
|
||||
>
|
||||
Network Logs
|
||||
</button>
|
||||
<span className="tl-header-src-file" data-testid="timeline-source-file">{sourceFile}</span>
|
||||
<span className="tl-header-src-icon">↗</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="timeline-item-tab-content">
|
||||
{/* Request Tab */}
|
||||
{activeTab === 'request' && (
|
||||
<Request request={request} item={item} collection={collection} />
|
||||
)}
|
||||
<div className="tl-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={`tl-tab ${activeTab === tab.id ? 'is-active' : ''}`}
|
||||
onClick={() => handleTabClick(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Response Tab */}
|
||||
{activeTab === 'response' && (
|
||||
<Response response={response} item={item} collection={collection} />
|
||||
<div className="tl-panel">
|
||||
{visitedTabs.request && (
|
||||
<div style={{ display: activeTab === 'request' ? 'block' : 'none' }}>
|
||||
<Request request={request} item={item} collection={collection} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Network Logs Tab */}
|
||||
{activeTab === 'networkLogs' && showNetworkLogs && (
|
||||
<Network logs={response?.timeline} />
|
||||
{visitedTabs.response && (
|
||||
<div style={{ display: activeTab === 'response' ? 'block' : 'none' }}>
|
||||
<Response response={response} item={item} collection={collection} />
|
||||
</div>
|
||||
)}
|
||||
{showNetworkLogs && visitedTabs.network && (
|
||||
<div style={{ display: activeTab === 'network' ? 'block' : 'none' }}>
|
||||
<Network logs={response?.timeline} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
export const getEntryKind = (entry) => {
|
||||
if (entry.type === 'request') return 'main';
|
||||
if (entry.type === 'oauth2') return 'oauth';
|
||||
if (entry.type === 'scripted-request') {
|
||||
// 'post-response' and 'tests' both run after the main response bucket together.
|
||||
if (entry.phase === 'post-response' || entry.phase === 'tests') return 'post';
|
||||
return 'pre';
|
||||
}
|
||||
return 'main';
|
||||
};
|
||||
|
||||
const findPairedMainTimestamps = (fullTimeline) => {
|
||||
const map = new Map();
|
||||
fullTimeline.forEach((entry, idx) => {
|
||||
if (entry.type !== 'oauth2') return;
|
||||
for (let j = idx + 1; j < fullTimeline.length; j++) {
|
||||
const candidate = fullTimeline[j];
|
||||
if (
|
||||
candidate.type === 'request'
|
||||
&& candidate.itemUid === entry.itemUid
|
||||
&& typeof candidate.timestamp === 'number'
|
||||
) {
|
||||
map.set(idx, candidate.timestamp);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
return map;
|
||||
};
|
||||
|
||||
const isVisibleEntry = (entry, itemUid, authSource) => {
|
||||
if (entry.itemUid === itemUid) return true;
|
||||
if (entry.type === 'oauth2' && authSource) {
|
||||
if (authSource.type === 'folder' && entry.folderUid === authSource.uid) return true;
|
||||
if (authSource.type === 'collection' && !entry.folderUid) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const expandOauthEntry = (entry, paired) => {
|
||||
const debugInfo = entry.data?.debugInfo || [];
|
||||
// No sub-calls to render drop the parent so the OAuth chip count
|
||||
if (debugInfo.length === 0) return [];
|
||||
const n = debugInfo.length;
|
||||
const mainAnchor = paired != null ? paired : entry.timestamp + n;
|
||||
return debugInfo.map((sub, i) => ({
|
||||
...entry,
|
||||
timestamp: mainAnchor - (n - i),
|
||||
_oauth2Child: sub
|
||||
}));
|
||||
};
|
||||
|
||||
export const buildTimelineEntries = (timeline, itemUid, authSource) => {
|
||||
const fullTimeline = timeline || [];
|
||||
const visible = fullTimeline.filter((entry) => isVisibleEntry(entry, itemUid, authSource));
|
||||
const pairedMainByOauthIdx = findPairedMainTimestamps(fullTimeline);
|
||||
|
||||
const flat = [];
|
||||
visible.forEach((entry) => {
|
||||
if (entry.type === 'oauth2') {
|
||||
const paired = pairedMainByOauthIdx.get(fullTimeline.indexOf(entry));
|
||||
flat.push(...expandOauthEntry(entry, paired));
|
||||
} else {
|
||||
flat.push(entry);
|
||||
}
|
||||
});
|
||||
|
||||
return flat.sort((a, b) => b.timestamp - a.timestamp);
|
||||
};
|
||||
|
||||
export const countByKind = (entries) => {
|
||||
const counts = { all: entries.length, main: 0, pre: 0, post: 0, oauth: 0 };
|
||||
entries.forEach((entry) => {
|
||||
const kind = getEntryKind(entry);
|
||||
if (counts[kind] != null) counts[kind]++;
|
||||
});
|
||||
return counts;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
// Keys must match getEntryKind() in buildEntries.js.
|
||||
// `kind` is a stable identifier used for data-testids (e.g. timeline-badge-pre).
|
||||
export const ENTRY_KINDS = {
|
||||
main: { kind: 'main', chipLabel: 'Request', badgeLabel: 'request', badgeClass: 'tl-badge tl-badge--main' },
|
||||
oauth: { kind: 'oauth', chipLabel: 'OAuth', badgeLabel: 'oauth2.0', badgeClass: 'tl-badge tl-badge--oauth2' },
|
||||
pre: { kind: 'pre', chipLabel: 'Pre-Request', badgeLabel: 'sendRequest', badgeClass: 'tl-badge tl-badge--scripted' },
|
||||
post: { kind: 'post', chipLabel: 'Post-Response', badgeLabel: 'runRequest', badgeClass: 'tl-badge tl-badge--run-request' }
|
||||
};
|
||||
|
||||
export const FILTER_CHIPS = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'main', label: ENTRY_KINDS.main.chipLabel },
|
||||
{ id: 'pre', label: ENTRY_KINDS.pre.chipLabel },
|
||||
{ id: 'post', label: ENTRY_KINDS.post.chipLabel },
|
||||
{ id: 'oauth', label: ENTRY_KINDS.oauth.chipLabel }
|
||||
];
|
||||
|
||||
export const getBadge = ({ source, isOauth2 }) => {
|
||||
if (isOauth2) return ENTRY_KINDS.oauth;
|
||||
if (!source || source === 'main') return ENTRY_KINDS.main;
|
||||
if (source === 'runRequest') return ENTRY_KINDS.post;
|
||||
return ENTRY_KINDS.pre;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { findItemInCollection, findParentItemInCollection } from 'utils/collections/index';
|
||||
import { get } from 'lodash';
|
||||
@@ -6,6 +6,8 @@ import TimelineItem from './TimelineItem/index';
|
||||
import GrpcTimelineItem from './GrpcTimelineItem/index';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
import { buildTimelineEntries, getEntryKind, countByKind } from './buildEntries';
|
||||
import { FILTER_CHIPS } from './entryMeta';
|
||||
|
||||
const getEffectiveAuthSource = (collection, item) => {
|
||||
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
|
||||
@@ -49,42 +51,66 @@ const Timeline = ({ collection, item }) => {
|
||||
const wrapperRef = useRef(null);
|
||||
const [scroll, setScroll] = usePersistedState({ key: `response-timeline-scroll-${item.uid}`, default: 0 });
|
||||
useTrackScroll({ ref: wrapperRef, selector: null, onChange: setScroll, initialValue: scroll });
|
||||
const [activeFilter, setActiveFilter] = useState('all');
|
||||
|
||||
// Get the effective auth source if auth mode is inherit
|
||||
const authSource = getEffectiveAuthSource(collection, item);
|
||||
const itemAuthMode = item.draft?.request?.auth?.mode ?? item.request?.auth?.mode ?? item.root?.request?.auth?.mode;
|
||||
const authSource = useMemo(
|
||||
() => getEffectiveAuthSource(collection, item),
|
||||
[item, itemAuthMode, collection]
|
||||
);
|
||||
const isGrpcRequest = item.type === 'grpc-request' || item.type === 'ws-request';
|
||||
|
||||
// Filter timeline entries based on new rules
|
||||
const combinedTimeline = ([...(collection?.timeline || [])]).filter((obj) => {
|
||||
// Always show entries for this item
|
||||
if (obj.itemUid === item.uid) return true;
|
||||
const entries = useMemo(
|
||||
() => buildTimelineEntries(collection?.timeline, item.uid, authSource),
|
||||
[collection?.timeline, item.uid, authSource]
|
||||
);
|
||||
const counts = useMemo(() => countByKind(entries), [entries]);
|
||||
|
||||
// For OAuth2 entries, also show if auth is inherited
|
||||
if (obj.type === 'oauth2' && authSource) {
|
||||
if (authSource.type === 'folder' && obj.folderUid === authSource.uid) return true;
|
||||
if (authSource.type === 'collection' && !obj.folderUid) return true;
|
||||
}
|
||||
const visibleChips = FILTER_CHIPS.filter((chip) => chip.id === 'all' || counts[chip.id] > 0);
|
||||
const hasOtherKinds = counts.pre > 0 || counts.post > 0 || counts.oauth > 0;
|
||||
const showFilterBar = entries.length > 0 && hasOtherKinds;
|
||||
|
||||
return false;
|
||||
}).sort((a, b) => b.timestamp - a.timestamp);
|
||||
useEffect(() => {
|
||||
if (activeFilter === 'all') return;
|
||||
const stillVisible = visibleChips.some((chip) => chip.id === activeFilter);
|
||||
if (!stillVisible) setActiveFilter('all');
|
||||
}, [activeFilter, visibleChips]);
|
||||
|
||||
return (
|
||||
<StyledWrapper
|
||||
className="pb-4 w-full flex flex-grow flex-col"
|
||||
ref={wrapperRef}
|
||||
>
|
||||
{/* Timeline container with scrollbar */}
|
||||
<div
|
||||
className="timeline-container"
|
||||
>
|
||||
{combinedTimeline.map((event, index) => {
|
||||
// Handle regular requests
|
||||
if (event.type === 'request') {
|
||||
const { data, timestamp, eventType } = event;
|
||||
{showFilterBar && (
|
||||
<div className="timeline-filter-bar" data-testid="timeline-filter-bar">
|
||||
{visibleChips.map((chip) => (
|
||||
<button
|
||||
key={chip.id}
|
||||
type="button"
|
||||
className={`timeline-chip ${activeFilter === chip.id ? 'is-active' : ''}`}
|
||||
onClick={() => setActiveFilter(chip.id)}
|
||||
data-testid={`timeline-chip-${chip.id}`}
|
||||
>
|
||||
{chip.label}
|
||||
<span className="timeline-chip-count" data-testid="timeline-chip-count">{counts[chip.id] ?? 0}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="timeline-container" data-testid="timeline-container">
|
||||
{entries.map((entry, index) => {
|
||||
const kind = getEntryKind(entry);
|
||||
if (activeFilter !== 'all' && activeFilter !== kind) return null;
|
||||
|
||||
if (entry.type === 'request') {
|
||||
const { data, timestamp, eventType } = entry;
|
||||
const { request, response, eventData = {}, timestamp: eventTimestamp = timestamp } = data;
|
||||
|
||||
if (isGrpcRequest) {
|
||||
return (
|
||||
<div key={index} className="timeline-event">
|
||||
<div key={index} className="timeline-event" data-testid="timeline-item">
|
||||
<GrpcTimelineItem
|
||||
timestamp={eventTimestamp}
|
||||
request={request}
|
||||
@@ -98,46 +124,50 @@ const Timeline = ({ collection, item }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Regular HTTP request
|
||||
return (
|
||||
<div key={index} className="timeline-event">
|
||||
<div key={index} className="timeline-event" data-testid="timeline-item">
|
||||
<TimelineItem
|
||||
timestamp={timestamp}
|
||||
request={request}
|
||||
response={response}
|
||||
item={item}
|
||||
collection={collection}
|
||||
source="main"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (event.type === 'oauth2') { // Handle OAuth2 events
|
||||
const { data, timestamp } = event;
|
||||
const { debugInfo } = data;
|
||||
}
|
||||
|
||||
if (entry.type === 'oauth2' && entry._oauth2Child) {
|
||||
return (
|
||||
<div key={index} className="timeline-event">
|
||||
<div className="timeline-event-header cursor-pointer flex items-center">
|
||||
<div className="flex items-center">
|
||||
<span className="font-bold">OAuth2.0 Calls</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
{debugInfo && debugInfo.length > 0 ? (
|
||||
debugInfo.map((data, idx) => (
|
||||
<div className="ml-4" key={idx}>
|
||||
<TimelineItem
|
||||
timestamp={timestamp}
|
||||
request={data?.request}
|
||||
response={data?.response}
|
||||
item={item}
|
||||
collection={collection}
|
||||
isOauth2={true}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div>No debug information available.</div>
|
||||
)}
|
||||
</div>
|
||||
<div key={index} className="timeline-event" data-testid="timeline-item">
|
||||
<TimelineItem
|
||||
timestamp={entry.timestamp}
|
||||
request={entry._oauth2Child.request}
|
||||
response={entry._oauth2Child.response}
|
||||
item={item}
|
||||
collection={collection}
|
||||
source="oauth2.0"
|
||||
isOauth2={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.type === 'scripted-request') {
|
||||
return (
|
||||
<div key={index} className="timeline-event" data-testid="timeline-item">
|
||||
<TimelineItem
|
||||
timestamp={entry.timestamp}
|
||||
request={entry.data?.request}
|
||||
response={entry.data?.response}
|
||||
error={entry.data?.error}
|
||||
item={item}
|
||||
collection={collection}
|
||||
source={entry.source || 'sendRequest'}
|
||||
scope={entry.scope}
|
||||
phase={entry.phase}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
background: ${({ theme }) => theme.background.crust};
|
||||
border: 1px solid ${({ theme }) => theme.border.border0};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
|
||||
.request-name {
|
||||
color: ${({ theme }) => theme.text};
|
||||
}
|
||||
|
||||
.collection-name{
|
||||
color: ${({ theme }) => theme.colors.text.subtext1};
|
||||
}
|
||||
`;
|
||||
|
||||
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