Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
8f37eb2d1f chore(deps): bump actions/github-script from 7 to 9
Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 9.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 17:34:29 +00:00
640 changed files with 4421 additions and 42901 deletions

2
.gitattributes vendored
View File

@@ -1,2 +0,0 @@
# Force LF line endings for all text files
* text=auto eol=lf

View File

@@ -5,10 +5,6 @@ inputs:
description: 'Skip building libraries'
required: false
default: 'false'
shell:
description: 'Shell to use (bash, pwsh)'
required: false
default: 'bash'
runs:
using: 'composite'
steps:
@@ -20,12 +16,12 @@ runs:
cache-dependency-path: './package-lock.json'
- name: Install node dependencies
shell: ${{ inputs.shell }}
shell: bash
run: npm ci --legacy-peer-deps
- name: Build libraries
if: inputs.skip-build != 'true'
shell: ${{ inputs.shell }}
shell: bash
run: |
npm run build:graphql-docs
npm run build:bruno-query

View File

@@ -7,13 +7,13 @@ runs:
shell: bash
run: |
set -euo pipefail
xvfb-run npm run test:e2e:ssl
- name: Upload Playwright Report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report-linux-ssl
name: playwright-report-linux
path: playwright-report/
retention-days: 30

View File

@@ -12,6 +12,6 @@ runs:
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report-macos-ssl
name: playwright-report-macos
path: playwright-report/
retention-days: 30

View File

@@ -12,6 +12,6 @@ runs:
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report-windows-ssl
name: playwright-report-windows
path: playwright-report/
retention-days: 30

View File

@@ -1,38 +0,0 @@
name: 'Run Benchmark Tests'
description: 'Run Playwright benchmark tests and compare against baseline'
inputs:
os:
description: 'Operating system (ubuntu, macos, windows)'
default: 'ubuntu'
update-baseline:
description: 'Update baseline instead of comparing'
default: 'false'
runs:
using: 'composite'
steps:
- name: Run Benchmark Tests (Ubuntu)
if: inputs.os == 'ubuntu'
shell: bash
run: xvfb-run npm run test:benchmark
- name: Run Benchmark Tests
if: inputs.os != 'ubuntu'
shell: bash
run: npm run test:benchmark
- name: Update Baseline
if: inputs.update-baseline == 'true'
shell: bash
run: >-
node tests/benchmarks/utils/compare.js
--results tests/benchmarks/results/mounting.json
--baseline tests/benchmarks/mounting/baseline.${{ inputs.os }}.json
--update-baseline
- name: Compare Against Baseline
if: inputs.update-baseline != 'true'
shell: bash
run: >-
node tests/benchmarks/utils/compare.js
--results tests/benchmarks/results/mounting.json
--baseline tests/benchmarks/mounting/baseline.${{ inputs.os }}.json

View File

@@ -1,41 +1,20 @@
name: 'Run CLI Tests'
description: 'Setup dependencies, start local testbench and run CLI tests'
inputs:
shell:
description: 'Shell to use (bash, pwsh)'
required: false
default: 'bash'
runs:
using: 'composite'
steps:
- name: Install Test Collection Dependencies
shell: ${{ inputs.shell }}
run: npm ci --prefix packages/bruno-tests/collection
- name: Run Local Testbench and CLI Tests
if: inputs.shell != 'pwsh'
shell: ${{ inputs.shell }}
- name: Run Local Testbench
shell: bash
run: |
npm start --workspace=packages/bruno-tests &
sleep 5
cd packages/bruno-tests/collection
node ../../bruno-cli/bin/bru.js run --env Prod --output junit.xml --format junit --sandbox developer
- name: Run Local Testbench and CLI Tests - Windows
if: inputs.shell == 'pwsh'
shell: pwsh
- name: Install Test Collection Dependencies
shell: bash
run: npm ci --prefix packages/bruno-tests/collection
- name: Run CLI Tests
shell: bash
run: |
$process = Start-Process "npm.cmd" `
-ArgumentList "start","--workspace=packages/bruno-tests" `
-NoNewWindow `
-PassThru
Start-Sleep -Seconds 5
if ($process.HasExited) {
Write-Error "Server exited early"
exit 1
}
cd packages/bruno-tests/collection
node ../../bruno-cli/bin/bru.js run --env Prod --output junit.xml --format junit --sandbox developer

View File

@@ -4,23 +4,19 @@ inputs:
os:
description: 'Operating system (ubuntu, macos, windows)'
default: 'ubuntu'
shell:
description: 'Shell to use (bash, pwsh)'
required: false
default: 'bash'
runs:
using: 'composite'
steps:
- name: Install Test Collection Dependencies
shell: ${{ inputs.shell }}
shell: bash
run: npm ci --prefix packages/bruno-tests/collection
- name: Run Playwright Tests (Ubuntu)
if: inputs.os == 'ubuntu'
shell: bash
run: xvfb-run dbus-run-session -- npm run test:e2e
run: xvfb-run npm run test:e2e
- name: Run Playwright Tests
if: inputs.os != 'ubuntu'
shell: ${{ inputs.shell }}
shell: bash
run: npm run test:e2e

View File

@@ -1,53 +1,48 @@
name: 'Run Unit Tests'
description: 'Setup dependencies and run unit tests for all packages'
inputs:
shell:
description: 'Shell to use (bash, pwsh)'
required: false
default: 'bash'
runs:
using: 'composite'
steps:
- name: Test Package bruno-js
shell: ${{ inputs.shell }}
run: npm run test:ci --workspace=packages/bruno-js
shell: bash
run: npm run test --workspace=packages/bruno-js
- name: Test Package bruno-cli
shell: ${{ inputs.shell }}
run: npm run test:ci --workspace=packages/bruno-cli
shell: bash
run: npm run test --workspace=packages/bruno-cli
- name: Test Package bruno-query
shell: ${{ inputs.shell }}
shell: bash
run: npm run test --workspace=packages/bruno-query
- name: Test Package bruno-lang
shell: ${{ inputs.shell }}
shell: bash
run: npm run test --workspace=packages/bruno-lang
- name: Test Package bruno-schema
shell: ${{ inputs.shell }}
shell: bash
run: npm run test --workspace=packages/bruno-schema
- name: Test Package bruno-app
shell: ${{ inputs.shell }}
shell: bash
run: npm run test --workspace=packages/bruno-app
- name: Test Package bruno-common
shell: ${{ inputs.shell }}
shell: bash
run: npm run test --workspace=packages/bruno-common
- name: Test Package bruno-converters
shell: ${{ inputs.shell }}
run: npm run test:ci --workspace=packages/bruno-converters
shell: bash
run: npm run test --workspace=packages/bruno-converters
- name: Test Package bruno-electron
shell: ${{ inputs.shell }}
run: npm run test:ci --workspace=packages/bruno-electron
shell: bash
run: npm run test --workspace=packages/bruno-electron
- name: Test Package bruno-requests
shell: ${{ inputs.shell }}
shell: bash
run: npm run test --workspace=packages/bruno-requests
- name: Test Package bruno-filestore
shell: ${{ inputs.shell }}
shell: bash
run: npm run test --workspace=packages/bruno-filestore

79
.github/workflows/auth-tests.yml vendored Normal file
View File

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

View File

@@ -1,88 +0,0 @@
name: Benchmarks
on:
workflow_dispatch:
inputs:
update-baseline:
description: 'Update baseline with current results instead of comparing'
type: boolean
default: false
pull_request:
branches: [main, 'release/v*']
jobs:
benchmark:
name: Performance Benchmarks (${{ matrix.os }})
timeout-minutes: 60
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, macos-latest, windows-latest]
include:
- os: ubuntu-24.04
os-name: ubuntu
- os: macos-latest
os-name: macos
- os: windows-latest
os-name: windows
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v6
- name: Install System Dependencies (Ubuntu)
if: matrix.os-name == 'ubuntu'
run: |
sudo apt-get update
sudo apt-get --no-install-recommends install -y \
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
xvfb
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Configure Chrome Sandbox
if: matrix.os-name == 'ubuntu'
run: |
sudo chown root node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 node_modules/electron/dist/chrome-sandbox
- name: Run Benchmark Tests
uses: ./.github/actions/tests/run-benchmark-tests
with:
os: ${{ matrix.os-name }}
update-baseline: ${{ github.event.inputs.update-baseline || 'false' }}
- name: Upload Benchmark Results
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: benchmark-results-${{ matrix.os-name }}
path: |
tests/benchmarks/results/
benchmark-report/
retention-days: 30
- name: Commit Updated Baseline
if: github.event.inputs.update-baseline == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add tests/benchmarks/mounting/baseline.${{ matrix.os-name }}.json
git diff --staged --quiet || git commit -m "chore: update ${{ matrix.os-name }} benchmark baseline" && git push
- name: Comment Benchmark Results on PR
if: github.event_name == 'pull_request' && !cancelled()
continue-on-error: true
uses: actions/github-script@v7
with:
script: |
const run = require('./tests/benchmarks/utils/pr-comment.js');
await run({
github,
context,
resultsPath: 'tests/benchmarks/results/mounting.json',
baselinePath: 'tests/benchmarks/mounting/baseline.${{ matrix.os-name }}.json',
title: 'Benchmark Results — Collection Mount (${{ matrix.os-name }})'
});

View File

@@ -73,7 +73,7 @@ jobs:
- name: Post PR comment
if: hashFiles('pr-comment.md') != ''
uses: actions/github-script@v7
uses: actions/github-script@v9
with:
script: |
const fs = require('fs');

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

@@ -0,0 +1,91 @@
name: SSL Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
tests-for-linux:
name: SSL Tests - Linux
timeout-minutes: 60
runs-on: ubuntu-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@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

View File

@@ -1,123 +0,0 @@
name: macOS Tests
on:
workflow_dispatch:
push:
branches: [main, 'release/v*']
pull_request:
branches: [main, 'release/v*']
jobs:
unit-test:
name: Unit Tests (macOS)
timeout-minutes: 60
runs-on: macos-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Run Unit Tests
uses: ./.github/actions/tests/run-unit-tests
cli-test:
name: CLI Tests (macOS)
runs-on: macos-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Run CLI Tests
uses: ./.github/actions/tests/run-cli-tests
- name: Publish Test Report
uses: EnricoMi/publish-unit-test-result-action/macos@v2
if: always()
with:
check_name: CLI Test Results (macOS)
files: packages/bruno-tests/collection/junit.xml
comment_mode: off
check_run: false
e2e-test:
name: Playwright E2E Tests (macOS)
timeout-minutes: 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

View File

@@ -1,134 +0,0 @@
name: Windows Tests
on:
workflow_dispatch:
push:
branches: [main, 'release/v*']
pull_request:
branches: [main, 'release/v*']
jobs:
unit-test:
name: Unit Tests (Windows)
if: false # @TODO: Temporarily disabled. Remove this once the tests are fixed.
timeout-minutes: 60
runs-on: windows-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
with:
shell: pwsh
- name: Run Unit Tests
uses: ./.github/actions/tests/run-unit-tests
with:
shell: pwsh
cli-test:
name: CLI Tests (Windows)
runs-on: windows-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
with:
shell: pwsh
- name: Run CLI Tests
uses: ./.github/actions/tests/run-cli-tests
with:
shell: pwsh
- name: Publish Test Report
uses: EnricoMi/publish-unit-test-result-action/windows@v2
if: always()
with:
check_name: CLI Test Results (Windows)
files: packages/bruno-tests/collection/junit.xml
comment_mode: off
check_run: false
e2e-test:
name: Playwright E2E Tests (Windows)
timeout-minutes: 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

View File

@@ -1,4 +1,4 @@
name: Linux Tests
name: Tests
on:
workflow_dispatch:
push:
@@ -8,7 +8,7 @@ on:
jobs:
unit-test:
name: Unit Tests (Linux)
name: Unit Tests
timeout-minutes: 60
runs-on: ubuntu-latest
permissions:
@@ -23,7 +23,7 @@ jobs:
uses: ./.github/actions/tests/run-unit-tests
cli-test:
name: CLI Tests (Linux)
name: CLI Tests
runs-on: ubuntu-latest
permissions:
checks: write
@@ -42,14 +42,13 @@ jobs:
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
check_name: CLI Test Results (Linux)
check_name: CLI Test Results
files: packages/bruno-tests/collection/junit.xml
comment_mode: always
check_run: false
e2e-test:
name: Playwright E2E Tests (Linux)
timeout-minutes: 120
name: Playwright E2E Tests
timeout-minutes: 60
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
@@ -59,8 +58,7 @@ jobs:
sudo apt-get update
sudo apt-get --no-install-recommends install -y \
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
xvfb \
gsettings-desktop-schemas dbus-x11
xvfb
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
@@ -79,61 +77,6 @@ jobs:
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: playwright-report-linux
name: playwright-report
path: playwright-report/
retention-days: 30
ssl-test:
name: SSL Tests (Linux)
timeout-minutes: 60
runs-on: ubuntu-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Setup Feature Dependencies
uses: ./.github/actions/ssl/linux/setup-feature-specific-deps
- name: Setup CA Certificates
uses: ./.github/actions/ssl/linux/setup-ca-certs
- name: Run Basic SSL CLI Tests
uses: ./.github/actions/ssl/linux/run-basic-ssl-cli-tests
- name: Run Custom CA Certs CLI Tests
uses: ./.github/actions/ssl/linux/run-custom-ca-certs-cli-tests
- name: Run Custom CA Certs E2E Tests
uses: ./.github/actions/ssl/linux/run-ssl-e2e-tests
oauth1-tests:
name: OAuth 1.0 Auth Tests (Linux)
timeout-minutes: 60
runs-on: ubuntu-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Setup Feature Dependencies
uses: ./.github/actions/auth/oauth1/linux/setup-feature-specific-deps
- name: Run Auth E2E Tests
uses: ./.github/actions/auth/oauth1/linux/run-auth-e2e-tests
- name: Start Test Server
uses: ./.github/actions/auth/oauth1/linux/start-test-server
- name: Run OAuth1 CLI Tests
uses: ./.github/actions/auth/oauth1/linux/run-oauth1-cli-tests

6
.gitignore vendored
View File

@@ -58,10 +58,6 @@ skills-lock.json
# Playwright
/blob-report/
# Benchmark results (generated at runtime)
tests/benchmarks/results/
/benchmark-report/
# Development plan files
CLAUDE.md
AGENTS.md
@@ -71,4 +67,4 @@ AGENTS.md
packages/bruno-filestore/dist
packages/bruno-requests/dist
packages/bruno-schema-types/dist
packages/bruno-converters/dist
packages/bruno-converters/dist

View File

@@ -59,47 +59,6 @@ Remember, these rules are here to make our codebase harmonious. If something doe
- Tests should be fast enough to run continuously. Avoid long-running operations unless absolutely necessary; prefer lightweight fixtures and isolated units.
### E2E Tests
When reviewing Electron-specific Playwright tests, treat `<project-root>/tests/**` as the canonical location for specs, typically matching `<project-root>/tests/**/*.spec.{ts,js}`. For broader Playwright workflow guidance, also refer to `docs/playwright-testing-guide.md`.
Goal: rewrite or critique the tests so they are genuinely behavioural, maintainable, and safely parallelizable.
Rules:
1. Tests must verify user-visible behaviour, not implementation details.
- Prefer assertions on UI state, persisted data, windows, dialogs, filesystem effects, and app-level outcomes.
- Avoid hardcoded waits, brittle selectors, fake internal state checks, and “click then expect mock called” tests unless the user behaviour is the point.
2. Tests must be Electron-aware.
- Use Electron app launch patterns correctly.
- Handle main window, secondary windows, dialogs, menus, native prompts, clipboard, file pickers, and IPC-driven UI behaviour through observable outcomes.
- Do not reach into app internals unless absolutely necessary for setup or controlled test fixtures.
3. Tests must be parallel-safe.
- No shared user data directories.
- No shared ports, files, DBs, caches, clipboard assumptions, or global app state.
- Each test gets isolated temp paths, unique workspace/project names, and deterministic cleanup.
- Avoid test ordering assumptions.
4. No hardcoded mess.
- Replace magic timeouts with event-driven waits.
- Replace brittle text/index selectors with role, label, test id, or stable user-facing selectors.
- Replace duplicated setup with fixtures.
- Replace hardcoded absolute paths with temp dirs.
- Replace random sleeps with waiting for actual app signals.
5. Every test should follow this shape:
- Arrange: create isolated fixture state.
- Act: perform real user actions.
- Assert: verify observable behavioural outcome.
- Cleanup: remove isolated resources.
For each test file:
- Identify behavioural vs non-behavioural tests.
- Flag brittle selectors, hardcoded waits, shared state, serial dependencies, and fake assertions.
- Rewrite the tests using Playwright best practices for Electron.
- Make them parallel-ready.
- Explain briefly why each rewrite is better.
## UI Specific instructions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 615 KiB

After

Width:  |  Height:  |  Size: 153 KiB

440
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -43,7 +43,7 @@
"globals": "^16.1.0",
"husky": "^9.1.7",
"jest": "^29.2.0",
"lodash-es": "^4.17.23",
"lodash-es": "^4.17.21",
"nano-staged": "^0.8.0",
"playwright": "^1.51.1",
"pretty-quick": "^3.1.3",
@@ -80,10 +80,9 @@
"watch:common": "npm run watch --workspace=packages/bruno-common",
"watch:requests": "npm run watch --workspace=packages/bruno-requests",
"test:codegen": "node playwright/codegen.ts",
"test:e2e": "playwright test --project=default --project=system-pac",
"test:e2e": "playwright test --project=default",
"test:e2e:ssl": "playwright test --project=ssl",
"test:e2e:auth": "playwright test --project=auth",
"test:benchmark": "playwright test --config=playwright.benchmark.config.ts",
"lint": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" npx eslint",
"lint:fix": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" npx eslint --fix",
"prepare": "husky"
@@ -94,7 +93,7 @@
]
},
"overrides": {
"axios": "1.16.0",
"axios":"1.13.6",
"rollup": "3.30.0",
"pbkdf2":"3.1.5",
"electron-store": {

View File

@@ -86,7 +86,7 @@
"react-virtuoso": "^4.18.1",
"sass": "^1.46.0",
"semver": "^7.7.1",
"shell-quote": "^1.8.4",
"shell-quote": "^1.8.3",
"strip-json-comments": "^5.0.1",
"styled-components": "^5.3.3",
"swagger-ui-react": "^5.31.0",

View File

@@ -1,75 +1,14 @@
import { memo } from 'react';
import SwaggerUI from 'swagger-ui-react';
import StyledWrapper from './StyledWrapper';
import { serializeBody } from './serializeBody';
const serializeHeaders = (headers) => {
if (!headers) return {};
if (typeof headers.entries === 'function') {
const out = {};
for (const [k, v] of headers.entries()) out[k] = v;
return out;
}
return { ...headers };
};
const proxiedFetch = async (url, options = {}) => {
const result = await window.ipcRenderer.invoke('renderer:swagger-fetch', {
url,
method: options.method || 'GET',
headers: serializeHeaders(options.headers),
body: serializeBody(options.body)
});
if (result.error) {
const err = new TypeError(result.message);
err.code = result.code;
throw err;
}
// The Response constructor throws if a null-body status carries a body.
const nullBodyStatus = [101, 204, 205, 304].includes(result.status);
const bodyBytes = !nullBodyStatus && result.bodyBase64
? Uint8Array.from(atob(result.bodyBase64), (c) => c.charCodeAt(0))
: null;
// Build Headers manually so multi-value response headers (e.g. Set-Cookie,
// which axios returns as string[]) end up as repeated entries rather than
// joined via toString(). new Headers({ 'set-cookie': ['a','b'] }) coerces
// the array to "a,b", which is invalid Set-Cookie syntax.
const responseHeaders = new Headers();
for (const [name, value] of Object.entries(result.headers || {})) {
if (Array.isArray(value)) {
value.forEach((v) => responseHeaders.append(name, String(v)));
} else if (value != null) {
responseHeaders.append(name, String(value));
}
}
return new Response(bodyBytes, {
status: result.status,
statusText: result.statusText,
headers: responseHeaders
});
};
const requestInterceptor = (req) => {
req.userFetch = proxiedFetch;
return req;
};
const Swagger = ({ spec, onComplete }) => {
const Swagger = ({ spec }) => {
return (
<StyledWrapper>
<div className="swagger-root w-full">
<SwaggerUI
spec={spec}
onComplete={onComplete}
requestInterceptor={requestInterceptor}
/>
<SwaggerUI spec={spec} />
</div>
</StyledWrapper>
);
};
export default memo(Swagger);
export default Swagger;

View File

@@ -1,83 +0,0 @@
// Serializes a SwaggerUI fetch body for transport across the renderer ↔ main
// IPC bridge in `renderer:swagger-fetch`. Only types that survive Electron's
// structured-clone serialization (and that our axios bridge knows how to send
// as an HTTP body) are supported. Multipart / binary types throw so the user
// gets a clear message in the SwaggerUI response panel instead of a silent
// failure.
const detectBodyType = (body) => {
if (body == null) return 'null';
if (typeof body === 'string') return 'string';
if (typeof FormData !== 'undefined' && body instanceof FormData) return 'FormData';
if (typeof File !== 'undefined' && body instanceof File) return 'File';
if (typeof Blob !== 'undefined' && body instanceof Blob) return 'Blob';
if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) return 'URLSearchParams';
if (typeof ArrayBuffer !== 'undefined' && body instanceof ArrayBuffer) return 'ArrayBuffer';
if (ArrayBuffer.isView && ArrayBuffer.isView(body)) return body.constructor?.name || 'TypedArray';
if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) return 'ReadableStream';
return typeof body;
};
export const UNSUPPORTED_BODY_TYPE_CODE = 'UNSUPPORTED_BODY_TYPE';
// Mapping from Web API class name (the raw detected type) to the user-facing
// subject used in the error message. SwaggerUI itself supports these body
// types fine; the limitation is Bruno's renderer↔main IPC bridge, not Swagger.
const BODY_TYPE_LABEL_MAP = {
File: 'File upload',
Blob: 'Binary file upload',
FormData: 'Multipart form data',
ArrayBuffer: 'Binary data',
ReadableStream: 'Streaming upload'
};
const mapBodyTypeToLabel = (typeName) => {
if (BODY_TYPE_LABEL_MAP[typeName]) return BODY_TYPE_LABEL_MAP[typeName];
// TypedArrays (Uint8Array, Float32Array, etc.) share a label.
if (typeof typeName === 'string' && typeName.endsWith('Array')) return 'Binary data';
return 'This request body type';
};
export const UNSUPPORTED_BODY_MESSAGE = (typeName) =>
`${mapBodyTypeToLabel(typeName)} via the Swagger Try-it-out panel isn't supported in Bruno yet. `
+ `Supported body types: JSON, URL-encoded forms, plain text. `
+ `Create a Bruno request to test this endpoint.`;
// Build a TypeError that carries the detected type as a property so downstream
// catchers can branch on `err.code` / `err.bodyType` instead of regex-parsing
// the message. `err.bodyType` keeps the raw Web API class name for diagnostics;
// the user-visible message uses the friendly subject above.
const unsupportedBodyError = (typeName) => {
const err = new TypeError(UNSUPPORTED_BODY_MESSAGE(typeName));
err.code = UNSUPPORTED_BODY_TYPE_CODE;
err.bodyType = typeName;
return err;
};
export const serializeBody = (body) => {
const typeName = detectBodyType(body);
switch (typeName) {
case 'null':
return undefined;
case 'string':
return body;
case 'URLSearchParams':
return body.toString();
case 'FormData':
case 'File':
case 'Blob':
case 'ArrayBuffer':
case 'ReadableStream':
throw unsupportedBodyError(typeName);
default:
// TypedArrays land here (Uint8Array, etc.) — also unsupported by the bridge.
if (ArrayBuffer.isView && ArrayBuffer.isView(body)) {
throw unsupportedBodyError(typeName);
}
// Plain objects, numbers, booleans — pass through. SwaggerUI rarely sends
// these as body directly (it stringifies JSON before fetch), but keep the
// path open rather than rejecting unexpectedly.
return body;
}
};

View File

@@ -1,95 +0,0 @@
import { serializeBody, UNSUPPORTED_BODY_MESSAGE, UNSUPPORTED_BODY_TYPE_CODE } from './serializeBody';
// Helper: invoke serializeBody and return the thrown error (or fail).
const catchSerializeError = (body) => {
try {
serializeBody(body);
} catch (err) {
return err;
}
throw new Error('expected serializeBody to throw');
};
describe('serializeBody', () => {
describe('supported body types', () => {
it('returns undefined for null', () => {
expect(serializeBody(null)).toBeUndefined();
});
it('returns undefined for undefined', () => {
expect(serializeBody(undefined)).toBeUndefined();
});
it('returns string bodies as-is', () => {
expect(serializeBody('{"name":"doggie"}')).toBe('{"name":"doggie"}');
expect(serializeBody('plain text')).toBe('plain text');
});
it('stringifies URLSearchParams', () => {
const params = new URLSearchParams({ a: '1', b: '2' });
expect(serializeBody(params)).toBe('a=1&b=2');
});
});
describe('unsupported body types (BRU-3300)', () => {
it('throws TypeError for FormData using "Multipart form data" subject', () => {
const fd = new FormData();
fd.append('file', new Blob(['x']));
expect(() => serializeBody(fd)).toThrow(TypeError);
expect(() => serializeBody(fd)).toThrow(/Multipart form data/);
expect(() => serializeBody(fd)).toThrow(/Create a Bruno request/);
});
it('throws TypeError for Blob using "Binary file upload" subject', () => {
const blob = new Blob(['payload']);
expect(() => serializeBody(blob)).toThrow(TypeError);
expect(() => serializeBody(blob)).toThrow(/Binary file upload/);
});
it('throws TypeError for File using "File upload" subject', () => {
const file = new File(['payload'], 'test.txt', { type: 'text/plain' });
expect(() => serializeBody(file)).toThrow(TypeError);
expect(() => serializeBody(file)).toThrow(/File upload/);
});
it('throws TypeError for ArrayBuffer using "Binary data" subject', () => {
const buf = new ArrayBuffer(8);
expect(() => serializeBody(buf)).toThrow(TypeError);
expect(() => serializeBody(buf)).toThrow(/Binary data/);
});
it('throws TypeError for TypedArray using "Binary data" subject', () => {
const u8 = new Uint8Array([1, 2, 3]);
expect(() => serializeBody(u8)).toThrow(TypeError);
expect(() => serializeBody(u8)).toThrow(/Binary data/);
});
it('message attributes the limitation to Bruno, not Swagger', () => {
expect(UNSUPPORTED_BODY_MESSAGE('FormData')).toMatch(/isn't supported in Bruno yet/);
});
it('message lists supported alternatives', () => {
expect(UNSUPPORTED_BODY_MESSAGE('FormData')).toMatch(/JSON, URL-encoded forms, plain text/);
});
});
describe('error metadata preservation (Bijin review feedback)', () => {
it('attaches err.code = UNSUPPORTED_BODY_TYPE so callers can branch programmatically', () => {
const err = catchSerializeError(new FormData());
expect(err.code).toBe(UNSUPPORTED_BODY_TYPE_CODE);
expect(UNSUPPORTED_BODY_TYPE_CODE).toBe('UNSUPPORTED_BODY_TYPE');
});
it('attaches err.bodyType naming the specific unsupported type', () => {
expect(catchSerializeError(new FormData()).bodyType).toBe('FormData');
expect(catchSerializeError(new Blob(['x'])).bodyType).toBe('Blob');
expect(catchSerializeError(new File(['x'], 'a.txt')).bodyType).toBe('File');
expect(catchSerializeError(new ArrayBuffer(4)).bodyType).toBe('ArrayBuffer');
expect(catchSerializeError(new Uint8Array([1, 2])).bodyType).toBe('Uint8Array');
});
it('thrown error is still a TypeError instance', () => {
expect(catchSerializeError(new FormData())).toBeInstanceOf(TypeError);
});
});
});

View File

@@ -1,31 +1,26 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, { useState, useEffect, Suspense } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useSelector } from 'react-redux';
import { IconDeviceFloppy, IconLoader2 } from '@tabler/icons';
import { IconDeviceFloppy } from '@tabler/icons';
import CodeEditor from './FileEditor/CodeEditor/index';
import Swagger from './Renderers/Swagger';
import { useDragResize } from 'hooks/useDragResize';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 450;
/**
* Shared split-pane spec viewer: CodeEditor (left) + Swagger preview (right).
*
* Props:
* - content (string) The spec content (YAML/JSON string)
* - readOnly (boolean) If true, editor is not editable and save icon is hidden
* - onSave (fn) Called with current editor content on save (editable mode only)
* - leftPaneWidth (number|null) Persisted left pane width in px; null = use 50/50 default
* - onLeftPaneWidthChange (fn) Persist the new width (called on mouseup / double-click / resize-clamp)
* - content (string) The spec content (YAML/JSON string)
* - readOnly (boolean) If true, editor is not editable and save icon is hidden
* - onSave (function) Called with current editor content on save (editable mode only)
*/
const SpecViewer = ({ content, readOnly, onSave, leftPaneWidth, onLeftPaneWidthChange }) => {
const SpecViewer = ({ content, readOnly, onSave }) => {
const { displayedTheme, theme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [editorContent, setEditorContent] = useState(content);
// Sync editor when saved content changes from outside (e.g. after save completes)
useEffect(() => {
setEditorContent(content);
}, [content]);
@@ -36,85 +31,38 @@ const SpecViewer = ({ content, readOnly, onSave, leftPaneWidth, onLeftPaneWidthC
if (onSave) onSave(editorContent);
};
const mainSectionRef = useRef(null);
const { dragging, dragWidth, dragbarProps } = useDragResize({
containerRef: mainSectionRef,
width: leftPaneWidth,
onWidthChange: onLeftPaneWidthChange,
minLeft: MIN_LEFT_PANE_WIDTH,
minRight: MIN_RIGHT_PANE_WIDTH
});
const effectiveWidth = dragging ? dragWidth : leftPaneWidth;
const leftPaneStyle = effectiveWidth != null
? { width: `${effectiveWidth}px`, flexShrink: 0 }
: { flex: '1 1 50%', minWidth: 0 };
const [swaggerReady, setSwaggerReady] = useState(false);
useEffect(() => {
setSwaggerReady(false);
}, [content]);
const handleSwaggerComplete = useCallback(() => {
// Double rAF: wait for one full paint cycle so Swagger is actually on screen
// before hiding the loader — avoids a flash of unrendered content.
requestAnimationFrame(() => {
requestAnimationFrame(() => setSwaggerReady(true));
});
}, []);
return (
<section
ref={mainSectionRef}
className={`main flex flex-grow pl-4 relative ${dragging ? 'dragging' : ''}`}
>
<div
className="api-spec-left-pane flex flex-grow relative h-full"
style={leftPaneStyle}
>
<CodeEditor
theme={displayedTheme}
value={readOnly ? content : editorContent}
readOnly={readOnly ? 'nocursor' : false}
onEdit={readOnly ? undefined : (val) => setEditorContent(val)}
onSave={readOnly ? undefined : handleSave}
mode="yaml"
font={get(preferences, 'font.codeFont', 'default')}
/>
{!readOnly && onSave && (
<IconDeviceFloppy
onClick={handleSave}
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={22}
className={`absolute right-0 top-0 m-4 ${
hasChanges ? 'cursor-pointer opacity-100' : 'cursor-default opacity-50'
}`}
/>
)}
</div>
<div className="dragbar-wrapper" {...dragbarProps}>
<div className="dragbar-handle" />
</div>
<div
className="api-spec-right-pane relative"
style={{ flex: '1 1 50%', minWidth: 0 }}
>
<div style={{ visibility: swaggerReady ? 'visible' : 'hidden', height: '100%' }}>
<Swagger spec={content} onComplete={handleSwaggerComplete} />
</div>
{!swaggerReady && (
<div
className="absolute inset-0 flex items-center justify-center gap-2"
style={{ background: theme.bg }}
>
<div className="flex items-center justify-center gap-2 opacity-70">
<IconLoader2 size={20} className="animate-spin" />
<span>Generating preview</span>
</div>
<section className="main flex flex-grow pl-4 relative">
<div className="w-full grid grid-cols-2">
<div className="col-span-1">
<div className="flex flex-grow relative">
<CodeEditor
theme={displayedTheme}
value={readOnly ? content : editorContent}
readOnly={readOnly ? 'nocursor' : false}
onEdit={readOnly ? undefined : (val) => setEditorContent(val)}
onSave={readOnly ? undefined : handleSave}
mode="yaml"
font={get(preferences, 'font.codeFont', 'default')}
/>
{!readOnly && onSave && (
<IconDeviceFloppy
onClick={handleSave}
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={22}
className={`absolute right-0 top-0 m-4 ${
hasChanges ? 'cursor-pointer opacity-100' : 'cursor-default opacity-50'
}`}
/>
)}
</div>
)}
</div>
<div className="col-span-1">
<Suspense fallback="">
<Swagger spec={content} />
</Suspense>
</div>
</div>
</section>
);

View File

@@ -17,35 +17,6 @@ const StyledWrapper = styled.div`
.react-tooltip {
z-index: 10;
}
section.main.dragging {
cursor: col-resize;
user-select: none;
}
div.dragbar-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 10px;
min-width: 10px;
padding: 0;
cursor: col-resize;
background: transparent;
position: relative;
flex-shrink: 0;
div.dragbar-handle {
display: flex;
height: 100%;
width: 1px;
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
}
&:hover div.dragbar-handle {
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
}
}
`;
export default StyledWrapper;

View File

@@ -1,11 +1,11 @@
import React, { forwardRef, useRef, useCallback } from 'react';
import React, { forwardRef, useRef } from 'react';
import find from 'lodash/find';
import { useSelector, useDispatch } from 'react-redux';
import { IconFileCode, IconDots } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import SpecViewer from './SpecViewer';
import Dropdown from 'components/Dropdown';
import { openApiSpec, saveApiSpecToFile, updateApiSpecPanelLeftPaneWidth } from 'providers/ReduxStore/slices/apiSpec';
import { openApiSpec, saveApiSpecToFile } from 'providers/ReduxStore/slices/apiSpec';
import { useState } from 'react';
import CreateApiSpec from 'components/Sidebar/ApiSpecs/CreateApiSpec';
import toast from 'react-hot-toast';
@@ -21,16 +21,7 @@ const ApiSpecPanel = () => {
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
let apiSpec = find(apiSpecs, (c) => c.uid === activeApiSpecUid);
const { filename, pathname, raw, uid, leftPaneWidth } = apiSpec || {};
const handleLeftPaneWidthChange = useCallback(
(w) => {
if (!uid) return;
dispatch(updateApiSpecPanelLeftPaneWidth({ uid, leftPaneWidth: w }));
},
[dispatch, uid]
);
const { filename, pathname, raw, uid } = apiSpec || {};
if (!uid) {
return <div className="p-4 opacity-50">API Spec not found!</div>;
}
@@ -88,8 +79,6 @@ const ApiSpecPanel = () => {
<SpecViewer
content={raw}
onSave={(content) => dispatch(saveApiSpecToFile({ uid, content }))}
leftPaneWidth={leftPaneWidth ?? null}
onLeftPaneWidthChange={handleLeftPaneWidthChange}
/>
</StyledWrapper>
);

View File

@@ -52,14 +52,6 @@ const AppTitleBar = () => {
const { ipcRenderer } = window;
if (!ipcRenderer) return;
ipcRenderer.invoke('renderer:window-is-fullscreen')
.then((fullscreen) => {
setIsFullScreen(fullscreen);
})
.catch((error) => {
console.error('Error getting initial fullscreen state:', error);
});
const removeEnterFullScreenListener = ipcRenderer.on('main:enter-full-screen', () => {
setIsFullScreen(true);
});
@@ -152,10 +144,8 @@ const AppTitleBar = () => {
const handleOpenWorkspace = async () => {
try {
const result = await dispatch(openWorkspaceDialog());
if (result) {
toast.success('Workspace opened successfully');
}
await dispatch(openWorkspaceDialog());
toast.success('Workspace opened successfully');
} catch (error) {
toast.error(error.message || 'Failed to open workspace');
}

View File

@@ -1,128 +0,0 @@
import '@testing-library/jest-dom';
import React from 'react';
import { render, waitFor, act } from '@testing-library/react';
import { ThemeProvider } from 'styled-components';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
jest.mock('ui/MenuDropdown', () => ({ children }) => <div>{children}</div>);
jest.mock('ui/ActionIcon', () => ({ children, onClick, label }) => (
<button onClick={onClick} aria-label={label}>{children}</button>
));
jest.mock('components/ResponsePane/ResponseLayoutToggle', () => () => null);
import AppTitleBar from './index';
const theme = {
text: '#333',
sidebar: {
bg: '#fff',
color: '#333',
muted: '#888',
collection: { item: { hoverBg: '#eee' } }
},
dropdown: { color: '#333', mutedText: '#888', hoverBg: '#eee' }
};
const mockStore = configureStore({
reducer: {
workspaces: (state = { workspaces: [], activeWorkspaceUid: null }) => state,
app: (state = { preferences: {}, sidebarCollapsed: false }) => state,
logs: (state = { isConsoleOpen: false }) => state
}
});
const renderWithProviders = () => render(
<Provider store={mockStore}>
<ThemeProvider theme={theme}>
<AppTitleBar />
</ThemeProvider>
</Provider>
);
const getTitleBar = (container) => container.querySelector('.app-titlebar');
const mockInvokeWithFullscreen = (isFullScreen) => jest.fn((channel) => {
if (channel === 'renderer:window-is-fullscreen') return Promise.resolve(isFullScreen);
return Promise.resolve(false);
});
describe('AppTitleBar — fullscreen state sync', () => {
let ipcListeners;
beforeEach(() => {
ipcListeners = {};
window.ipcRenderer = {
invoke: jest.fn().mockResolvedValue(false),
send: jest.fn(),
on: jest.fn((channel, cb) => {
ipcListeners[channel] = cb;
return jest.fn();
})
};
});
afterEach(() => {
delete window.ipcRenderer;
});
describe('initial state on mount', () => {
it('should query the main process for current fullscreen state', async () => {
renderWithProviders();
await waitFor(() => {
expect(window.ipcRenderer.invoke).toHaveBeenCalledWith('renderer:window-is-fullscreen');
});
});
it('should apply fullscreen class when window is already fullscreen at mount', async () => {
window.ipcRenderer.invoke = mockInvokeWithFullscreen(true);
const { container } = renderWithProviders();
await waitFor(() => {
expect(getTitleBar(container)).toHaveClass('fullscreen');
});
});
it('should not apply fullscreen class when window is windowed at mount', async () => {
const { container } = renderWithProviders();
await waitFor(() => {
expect(window.ipcRenderer.invoke).toHaveBeenCalledWith('renderer:window-is-fullscreen');
});
expect(getTitleBar(container)).not.toHaveClass('fullscreen');
});
});
describe('fullscreen transitions after mount', () => {
it('should add fullscreen class on main:enter-full-screen event', async () => {
const { container } = renderWithProviders();
await waitFor(() => {
expect(window.ipcRenderer.invoke).toHaveBeenCalledWith('renderer:window-is-fullscreen');
});
act(() => {
ipcListeners['main:enter-full-screen']();
});
expect(getTitleBar(container)).toHaveClass('fullscreen');
});
it('should remove fullscreen class on main:leave-full-screen event', async () => {
window.ipcRenderer.invoke = mockInvokeWithFullscreen(true);
const { container } = renderWithProviders();
await waitFor(() => {
expect(getTitleBar(container)).toHaveClass('fullscreen');
});
act(() => {
ipcListeners['main:leave-full-screen']();
});
expect(getTitleBar(container)).not.toHaveClass('fullscreen');
});
});
});

View File

@@ -31,7 +31,7 @@ const BulkEditor = ({ params, onChange, onToggle, onSave, onRun }) => {
/>
</div>
<div className="flex btn-action justify-between items-center mt-3">
<button className="text-link select-none ml-auto" data-testid="key-value-edit-toggle" onClick={onToggle}>
<button className="text-link select-none ml-auto" onClick={onToggle}>
Key/Value Edit
</button>
</div>

View File

@@ -6,7 +6,7 @@
*/
import React, { createRef } from 'react';
import { debounce, isEqual } from 'lodash';
import { isEqual, escapeRegExp } from 'lodash';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { setupAutoComplete, showRootHints } from 'utils/codemirror/autocomplete';
import StyledWrapper from './StyledWrapper';
@@ -16,16 +16,7 @@ import stripJsonComments from 'strip-json-comments';
import { getAllVariables } from 'utils/collections';
import { setupLinkAware } from 'utils/codemirror/linkAware';
import { setupLintErrorTooltip } from 'utils/codemirror/lint-errors';
import { setupCodeMirrorResizeRefresh } from 'utils/codemirror/resize';
import CodeMirrorSearch from 'components/CodeMirrorSearch/index';
import {
applyEditorState,
captureEditorState,
getDocKey,
readPersistedEditorState,
writePersistedEditorState
} from './state-persistence';
import { usePersistenceScope } from 'hooks/usePersistedState/PersistedScopeProvider';
const CodeMirror = require('codemirror');
window.jsonlint = jsonlint;
@@ -33,7 +24,7 @@ window.JSHINT = JSHINT;
const TAB_SIZE = 2;
class CodeEditor extends React.Component {
export default class CodeEditor extends React.Component {
constructor(props) {
super(props);
@@ -57,24 +48,8 @@ class CodeEditor extends React.Component {
};
}
// Thin wrapper around the pure getDocKey helper from state-persistence.js.
// Kept on the class so the rest of the lifecycle code reads naturally.
_getDocKey() {
return getDocKey(this.props);
}
componentDidMount() {
const variables = getAllVariables(this.props.collection, this.props.item);
/**
* No-op. We claim Cmd-Enter / Ctrl-Enter here only to suppress CodeMirror's
* sublime keymap default (insertLineAfter), which would otherwise insert a
* newline. sendRequest dispatch is owned by Mousetrap — the editor input has
* the `mousetrap` class (added below) so the global
* useKeybinding('sendRequest', …) in RequestTabPanel handles it, and only
* in request tabs. Falling through with CodeMirror.Pass when onRun is absent
* would re-introduce the newline in collection/folder-level editors.
*/
const runShortcut = () => {};
const editor = (this.editor = CodeMirror(this._node, {
value: this.props.value || '',
@@ -109,10 +84,8 @@ class CodeEditor extends React.Component {
this.searchBarRef.current?.focus();
});
},
'Cmd-H': this.props.readOnly ? false : 'replace',
'Ctrl-H': this.props.readOnly ? false : 'replace',
'Cmd-Enter': runShortcut,
'Ctrl-Enter': runShortcut,
'Cmd-H': 'replace',
'Ctrl-H': 'replace',
'Tab': function (cm) {
cm.getSelection().includes('\n') || editor.getLine(cm.getCursor().line) == cm.getSelection()
? cm.execCommand('indentMore')
@@ -202,49 +175,9 @@ class CodeEditor extends React.Component {
});
if (editor) {
// CM5 was constructed with props.value, so the editor already shows the
// right content. Read this tab's previously persisted view state from
// localStorage and apply it on top — restores folds, cursor, selection,
// undo history, and scroll position.
const docKey = getDocKey(this.props);
this._currentDocKey = docKey;
this.cachedValue = editor.getValue();
applyEditorState(
editor,
readPersistedEditorState({ scope: this.props.persistenceScope, key: docKey }),
this.cachedValue
);
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
editor.on('change', this._onEdit);
// Persist view state immediately when the user folds or unfolds — without
// this, a fold only gets saved on the next tab switch / unmount. That
// makes the persistence feel "delayed" or random, especially across
// sub-tab switches that don't change the docKey or unmount the editor.
// Debounced so rapid fold/unfold (e.g. Cmd-Y to fold all) doesn't write
// to localStorage on every event.
this._persistViewStateDebounced = debounce(() => {
if (!this.editor || !this._currentDocKey) return;
writePersistedEditorState({
scope: this.props.persistenceScope,
key: this._currentDocKey,
state: captureEditorState(this.editor)
});
}, 250);
editor.on('fold', this._persistViewStateDebounced);
editor.on('unfold', this._persistViewStateDebounced);
editor.scrollTo(null, this.props.initialScroll);
this._lastScrollTop = this.props.initialScroll || 0;
editor.on('scroll', () => {
const wrapper = editor.getWrapperElement();
if (wrapper && wrapper.offsetParent === null) return;
this._lastScrollTop = editor.getScrollInfo().top;
if (this.props.onScroll && typeof this.props.onScroll === 'function') {
this.props.onScroll(this._lastScrollTop);
}
});
this.addOverlay();
const getAllVariablesHandler = () => getAllVariables(this.props.collection, this.props.item);
@@ -270,8 +203,6 @@ class CodeEditor extends React.Component {
if (cmInput) {
cmInput.classList.add('mousetrap');
}
this.cleanupResizeRefresh = setupCodeMirrorResizeRefresh(editor, this._node);
}
}
@@ -287,52 +218,11 @@ class CodeEditor extends React.Component {
this.editor.options.jump.schema = this.props.schema;
CodeMirror.signal(this.editor, 'change', this.editor);
}
if (this.editor) {
// Two distinct update paths:
// 1. Doc key changed → tab switch → snapshot outgoing state, load new content, restore incoming state
// 2. Same doc, value changed → external content update → setValue (view state resets)
const newDocKey = getDocKey(this.props);
const docKeyChanged = newDocKey !== this._currentDocKey;
if (docKeyChanged) {
// Path 1 — tab switch.
// Snapshot the outgoing tab's view state to localStorage so a future
// visit can restore it. Then setValue the incoming content and apply
// any view state previously persisted for the incoming tab.
if (this._currentDocKey) {
writePersistedEditorState({
scope: this.props.persistenceScope,
key: this._currentDocKey,
state: captureEditorState(this.editor)
});
}
this.cachedValue = String(this?.props?.value ?? '');
this.editor.setValue(String(this.props.value) || '');
this._currentDocKey = newDocKey;
applyEditorState(
this.editor,
readPersistedEditorState({ scope: this.props.persistenceScope, key: newDocKey }),
this.cachedValue
);
// setValue resets the editor's mode-overlay state — re-apply the
// brunovariables overlay and re-evaluate lint config for the new content.
this.addOverlay();
this.editor.setOption(
'lint',
this.props.mode && this.editor.getValue().trim().length > 0 ? this.lintOptions : false
);
} else if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue) {
// Path 2 — same tab, new external value (e.g. a fresh response arrived
// while this tab was active). Update content; view state resets because
// line positions no longer correspond to anything. Invalidate the
// persisted snapshot too, since the saved cursor/folds/history reflect
// the prior content.
const cursor = this.editor.getCursor();
this.cachedValue = String(this?.props?.value ?? '');
this.editor.setValue(String(this.props.value) || '');
this.editor.setCursor(cursor);
writePersistedEditorState({ scope: this.props.persistenceScope, key: this._currentDocKey, state: null });
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
const cursor = this.editor.getCursor();
this.cachedValue = String(this?.props?.value ?? '');
this.editor.setValue(String(this.props.value) || '');
this.editor.setCursor(cursor);
}
if (this.editor) {
@@ -378,34 +268,14 @@ class CodeEditor extends React.Component {
componentWillUnmount() {
if (this.editor) {
if (this.props.onScroll) {
this.props.onScroll(this._lastScrollTop);
}
// Snapshot view state to localStorage before tearing down the editor so
// the next mount of a CodeEditor with this docKey can restore folds,
// cursor, selection, undo history, and scroll position.
if (this._currentDocKey) {
writePersistedEditorState({
scope: this.props.persistenceScope,
key: this._currentDocKey,
state: captureEditorState(this.editor)
});
this.props.onScroll(this.editor);
}
this.editor?._destroyLinkAware?.();
this.editor.off('change', this._onEdit);
// Tear down the debounced fold-persistence listener. Cancel any pending
// call so it can't fire after we've already snapshotted state above.
if (this._persistViewStateDebounced) {
this.editor.off('fold', this._persistViewStateDebounced);
this.editor.off('unfold', this._persistViewStateDebounced);
this._persistViewStateDebounced.cancel?.();
}
// Clean up lint error tooltip
this.cleanupLintErrorTooltip?.();
this.cleanupResizeRefresh?.();
const wrapper = this.editor.getWrapperElement();
wrapper?.parentNode?.removeChild(wrapper);
@@ -467,12 +337,3 @@ class CodeEditor extends React.Component {
}
};
}
const CodeEditorWithPersistenceScope = React.forwardRef((props, ref) => {
const persistenceScope = usePersistenceScope();
return <CodeEditor {...props} persistenceScope={persistenceScope} ref={ref} />;
});
CodeEditorWithPersistenceScope.displayName = 'CodeEditor';
export default CodeEditorWithPersistenceScope;

View File

@@ -1,129 +0,0 @@
/*
* CodeEditor view-state persistence — extracted for testability.
*
* Why this exists:
* Every tab switch causes CodeMirror's setValue() to wipe folds, cursor,
* selection, undo history, and scroll position. To preserve them, we serialize
* the relevant pieces to localStorage under a stable key for each editor and
* re-apply them on mount / tab switch. CodeMirror exposes a JSON-serializable
* representation of its undo stack via getHistory()/setHistory(), which is what
* makes Cmd-Z continue working across switches.
*
* Note: we deliberately do NOT persist the content itself — the canonical value
* lives in Redux (props.value). We only persist the editor's "view" state on
* top of that content. If content has drifted between save and restore, fold
* positions are applied leniently (foldCode silently no-ops on invalid lines)
* and history is skipped to avoid an inconsistent undo stack.
*/
export const STORAGE_PREFIX = 'persisted::';
export const DEFAULT_PERSISTENCE_SCOPE = 'global';
export const STORAGE_SEGMENT = 'codeeditor';
export const getScopedStorageKey = (scope, key) => {
const resolvedScope = scope || DEFAULT_PERSISTENCE_SCOPE;
return `${STORAGE_PREFIX}${resolvedScope}::${STORAGE_SEGMENT}::${key}`;
};
// Identifies which Doc state belongs to a given CodeEditor instance.
//
// Callers can pass an explicit `docKey` prop when the auto-derived key would
// collide — e.g. Pre-Request vs Post-Response script editors share the same
// item/mode/readOnly and need an extra disambiguator.
//
// Auto-derived parts:
// id — distinguishes different tabs (requests or collections)
// mode — distinguishes editors within the same tab (e.g. JSON body vs JS script)
// readOnly — distinguishes response viewer (ro) from body editor (rw) when modes match
export const getDocKey = (props) => {
if (props.docKey) return props.docKey;
const id = props.item?.uid || props.collection?.uid || 'default';
const mode = props.mode || 'default';
const readOnly = props.readOnly ? 'ro' : 'rw';
return `${id}:${mode}:${readOnly}`;
};
export const readPersistedEditorState = ({ scope, key }) => {
try {
const raw = localStorage.getItem(getScopedStorageKey(scope, key));
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
};
export const writePersistedEditorState = ({ scope, key, state }) => {
try {
const storageKey = getScopedStorageKey(scope, key);
if (state == null) {
localStorage.removeItem(storageKey);
} else {
localStorage.setItem(storageKey, JSON.stringify(state));
}
} catch {
// localStorage may be unavailable or full (Chromium ~10 MB cap). Editor
// state is non-critical — content lives in Redux — so silently ignore.
}
};
export const captureEditorState = (editor) => {
if (!editor) return null;
const doc = editor.getDoc();
const folds = editor
.getAllMarks()
.filter((m) => m.__isFold)
.map((m) => m.find())
.filter(Boolean)
.map((range) => range.from);
return {
contentLength: doc.getValue().length,
cursor: doc.getCursor(),
selections: doc.listSelections(),
history: doc.getHistory(),
folds,
scrollY: editor.getScrollInfo().top
};
};
export const applyEditorState = (editor, state, currentContent) => {
if (!editor || !state) return;
const doc = editor.getDoc();
const contentMatches = state.contentLength === (currentContent || '').length;
// History/cursor/selection only make sense if content didn't drift — applying
// a stale undo stack to different content would let Cmd-Z replay edits that
// no longer correspond to anything visible.
if (contentMatches) {
if (state.history) {
try { doc.setHistory(state.history); } catch {}
}
if (state.cursor) {
try { doc.setCursor(state.cursor); } catch {}
}
if (state.selections && state.selections.length) {
try { doc.setSelections(state.selections); } catch {}
}
}
// Folds are cheap and lenient — try them either way.
// Sort innermost-first (line desc): when folds are nested, applying the
// inner one before the outer one is safer because brace-fold's findRange
// re-scans the line text. With outer-first, deeply nested arrays inside a
// folded object can fail to refold (issue specific to JSON arrays where
// the helper's lookback can land on the wrong opening character once the
// outer block is collapsed).
if (state.folds && state.folds.length) {
const sorted = [...state.folds].sort(
(a, b) => b.line - a.line || b.ch - a.ch
);
editor.operation(() => {
sorted.forEach((from) => {
try {
editor.foldCode(from, null, 'fold');
} catch {}
});
});
}
if (state.scrollY != null) {
try { editor.scrollTo(null, state.scrollY); } catch {}
}
};

View File

@@ -1,10 +1,8 @@
import 'github-markdown-css/github-markdown.css';
import get from 'lodash/get';
import find from 'lodash/find';
import { updateCollectionDocs, deleteCollectionDraft } from 'providers/ReduxStore/slices/collections';
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { useRef } from 'react';
import { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import Markdown from 'components/MarkDown';
@@ -13,27 +11,16 @@ import StyledWrapper from './StyledWrapper';
import { IconEdit, IconX, IconFileText } from '@tabler/icons';
import Button from 'ui/Button/index';
import ActionIcon from 'ui/ActionIcon/index';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const Docs = ({ collection }) => {
const dispatch = useDispatch();
const { displayedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const isEditing = focusedTab?.docsEditing || false;
const [isEditing, setIsEditing] = useState(false);
const docs = collection.draft?.root ? get(collection, 'draft.root.docs', '') : get(collection, 'root.docs', '');
const preferences = useSelector((state) => state.app.preferences);
// StyledWrapper has overflow-y: auto — use null selector.
// Preview mode: hook tracks wrapper scroll. Edit mode: CodeEditor's onScroll/initialScroll.
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `collection-docs-scroll-${collection.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, onChange: setScroll, enabled: !isEditing, initialValue: scroll });
const toggleViewMode = () => {
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
setIsEditing((prev) => !prev);
};
const onEdit = (value) => {
@@ -61,7 +48,7 @@ const Docs = ({ collection }) => {
};
return (
<StyledWrapper className="h-full w-full relative flex flex-col" ref={wrapperRef}>
<StyledWrapper className="h-full w-full relative flex flex-col">
<div className="flex flex-row w-full justify-between items-center mb-4">
<div className="text-lg font-medium flex items-center gap-2">
<IconFileText size={20} strokeWidth={1.5} />
@@ -94,11 +81,9 @@ const Docs = ({ collection }) => {
mode="application/text"
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
initialScroll={scroll}
onScroll={setScroll}
/>
) : (
<div className="pl-1">
<div className="h-full overflow-auto pl-1">
<div className="h-[1px] min-h-[500px]">
{
docs?.length > 0

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback, useRef } from 'react';
import React, { useState, useCallback } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
@@ -13,8 +13,6 @@ import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import BulkEditor from 'components/BulkEditor/index';
import Button from 'ui/Button';
import { headerNameRegex, headerValueRegex } from 'utils/common/regex';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
@@ -27,9 +25,6 @@ const Headers = ({ collection }) => {
? get(collection, 'draft.root.request.headers', [])
: get(collection, 'root.request.headers', []);
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `collection-headers-scroll-${collection.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.collection-settings-content', onChange: setScroll, initialValue: scroll });
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
@@ -125,7 +120,7 @@ const Headers = ({ collection }) => {
}
return (
<StyledWrapper className="h-full w-full" ref={wrapperRef}>
<StyledWrapper className="h-full w-full">
<div className="text-xs mb-4 text-muted">
Add request headers that will be sent with every request in this collection.
</div>
@@ -138,10 +133,9 @@ const Headers = ({ collection }) => {
getRowError={getRowError}
columnWidths={collectionHeadersWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('collection-headers', widths)}
initialScroll={scroll}
/>
<div className="flex justify-end mt-2">
<button className="text-link select-none" data-testid="bulk-edit-toggle" onClick={toggleBulkEditMode}>
<button className="text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
</div>

View File

@@ -8,12 +8,10 @@ const Overview = ({ collection }) => {
return (
<div className="h-full">
<div className="grid grid-cols-5 gap-5 h-full">
<div className="col-span-2 overflow-clip text-ellipsis">
<div className="flex gap-2 items-center min-w-0">
<IconBox size={20} stroke={1.5} className="flex-shrink-0" />
<span className="overflow-hidden text-lg font-medium whitespace-nowrap text-ellipsis">
{collection?.name}
</span>
<div className="col-span-2">
<div className="text-lg font-medium flex items-center gap-2">
<IconBox size={20} stroke={1.5} />
{collection?.name}
</div>
<Info collection={collection} />
<RequestsNotLoaded collection={collection} />

View File

@@ -12,7 +12,6 @@ import StatusDot from 'components/StatusDot';
import { flattenItems, isItemARequest } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
const Script = ({ collection }) => {
const dispatch = useDispatch();
@@ -39,20 +38,13 @@ const Script = ({ collection }) => {
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [preReqScroll, setPreReqScroll] = usePersistedState({ key: `collection-pre-req-scroll-${collection.uid}`, default: 0 });
const [postResScroll, setPostResScroll] = usePersistedState({ key: `collection-post-res-scroll-${collection.uid}`, default: 0 });
// Refresh CodeMirror when tab becomes visible and restore scroll position.
// CodeMirror's scrollTo() is silently ignored when the editor is inside a display:none container
// (TabsContent hides inactive tabs via display:none). After refresh() recalculates layout, we re-apply scrollTo().
// Refresh CodeMirror when tab becomes visible
useEffect(() => {
const timer = setTimeout(() => {
if (activeTab === 'pre-request' && preRequestEditorRef.current?.editor) {
preRequestEditorRef.current.editor.refresh();
preRequestEditorRef.current.editor.scrollTo(null, preReqScroll);
} else if (activeTab === 'post-response' && postResponseEditorRef.current?.editor) {
postResponseEditorRef.current.editor.refresh();
postResponseEditorRef.current.editor.scrollTo(null, postResScroll);
}
}, 0);
@@ -107,11 +99,10 @@ const Script = ({ collection }) => {
</TabsTrigger>
</TabsList>
<TabsContent value="pre-request" className="mt-2" dataTestId="collection-pre-request-script-editor">
<TabsContent value="pre-request" className="mt-2">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
docKey="collection-script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
onEdit={onRequestScriptEdit}
@@ -120,16 +111,13 @@ const Script = ({ collection }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
</TabsContent>
<TabsContent value="post-response" className="mt-2" dataTestId="collection-post-response-script-editor">
<TabsContent value="post-response" className="mt-2">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
docKey="collection-script:post-response"
value={responseScript || ''}
theme={displayedTheme}
onEdit={onResponseScriptEdit}
@@ -138,8 +126,6 @@ const Script = ({ collection }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
</TabsContent>
</Tabs>

View File

@@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
@@ -7,16 +7,13 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
const Tests = ({ collection }) => {
const dispatch = useDispatch();
const testsEditorRef = useRef(null);
const tests = collection.draft?.root ? get(collection, 'draft.root.request.tests', '') : get(collection, 'root.request.tests', '');
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [testsScroll, setTestsScroll] = usePersistedState({ key: `collection-tests-scroll-${collection.uid}`, default: 0 });
const onEdit = (value) => {
dispatch(
@@ -33,9 +30,7 @@ const Tests = ({ collection }) => {
<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}
@@ -44,8 +39,6 @@ const Tests = ({ collection }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={testsScroll}
onScroll={setTestsScroll}
/>
<div className="mt-6">

View File

@@ -11,7 +11,7 @@ import toast from 'react-hot-toast';
import { variableNameRegex } from 'utils/common/regex';
import { setCollectionVars } from 'providers/ReduxStore/slices/collections/index';
const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => {
const VarsTable = ({ collection, vars, varType }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
@@ -87,7 +87,6 @@ const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => {
getRowError={getRowError}
columnWidths={collectionVarsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('collection-vars', widths)}
initialScroll={initialScroll}
/>
</StyledWrapper>
);

View File

@@ -1,12 +1,10 @@
import React, { useRef } from 'react';
import React from 'react';
import get from 'lodash/get';
import VarsTable from './VarsTable';
import StyledWrapper from './StyledWrapper';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const Vars = ({ collection }) => {
const dispatch = useDispatch();
@@ -14,19 +12,15 @@ const Vars = ({ collection }) => {
const responseVars = collection.draft?.root ? get(collection, 'draft.root.request.vars.res', []) : get(collection, 'root.request.vars.res', []);
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `collection-vars-scroll-${collection.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.collection-settings-content', onChange: setScroll, initialValue: scroll });
return (
<StyledWrapper className="w-full flex flex-col" ref={wrapperRef}>
<StyledWrapper className="w-full flex flex-col">
<div className="flex-1">
<div className="mb-3 title text-xs">Pre Request</div>
<VarsTable collection={collection} vars={requestVars} varType="request" initialScroll={scroll} />
<VarsTable collection={collection} vars={requestVars} varType="request" />
</div>
<div className="flex-1">
<div className="mt-3 mb-3 title text-xs">Post Response</div>
<VarsTable collection={collection} vars={responseVars} varType="response" initialScroll={scroll} />
<VarsTable collection={collection} vars={responseVars} varType="response" />
</div>
<div className="mt-6">
<Button type="submit" size="sm" onClick={handleSave}>

View File

@@ -146,7 +146,7 @@ const CollectionSettings = ({ collection }) => {
{protobufConfig.protoFiles && protobufConfig.protoFiles.length > 0 && <StatusDot />}
</div>
</div>
<section className="collection-settings-content mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
<section className="mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
</StyledWrapper>
);
};

View File

@@ -317,7 +317,6 @@ const StyledWrapper = styled.div`
height: 100% !important;
max-height: 400px !important;
padding: 0.5rem !important;
overflow: auto !important;
.network-logs-pre {
color: ${(props) => props.theme.console.messageColor} !important;

View File

@@ -4,14 +4,12 @@ import find from 'lodash/find';
import { updateRequestDocs } from 'providers/ReduxStore/slices/collections';
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { useRef } from 'react';
import { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import Markdown from 'components/MarkDown';
import CodeEditor from 'components/CodeEditor';
import StyledWrapper from './StyledWrapper';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const Documentation = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -23,10 +21,6 @@ const Documentation = ({ item, collection }) => {
const docs = item.draft ? get(item, 'draft.request.docs') : get(item, 'request.docs');
const preferences = useSelector((state) => state.app.preferences);
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `request-docs-scroll-${item.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, onChange: setScroll, enabled: !isEditing, initialValue: scroll });
const toggleViewMode = () => {
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
};
@@ -48,7 +42,7 @@ const Documentation = ({ item, collection }) => {
}
return (
<StyledWrapper className="flex flex-col gap-y-1 h-full w-full relative" ref={wrapperRef}>
<StyledWrapper className="flex flex-col gap-y-1 h-full w-full relative">
<div className="editing-mode" role="tab" onClick={toggleViewMode}>
{isEditing ? 'Preview' : 'Edit'}
</div>
@@ -63,8 +57,6 @@ const Documentation = ({ item, collection }) => {
onEdit={onEdit}
onSave={onSave}
mode="application/text"
initialScroll={scroll}
onScroll={setScroll}
/>
) : (
<Markdown collectionPath={collection.pathname} onDoubleClick={toggleViewMode} content={docs} />

View File

@@ -179,17 +179,6 @@ const Wrapper = styled.div`
}
}
.breadcrumb-collapsed-dropdown {
max-width: 250px;
}
.breadcrumb-collapsed-item {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown-separator {
height: 1px;
background-color: ${(props) => props.theme.dropdown.separator};

View File

@@ -1,9 +1,10 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: block;
width: 100%;
isolation: isolate;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
&.is-resizing {
cursor: col-resize !important;
@@ -11,9 +12,9 @@ const StyledWrapper = styled.div`
}
.table-container {
overflow: auto;
border-radius: ${(props) => props.theme.border.radius.base};
border: solid 1px ${(props) => props.theme.border.border0};
overflow: clip;
}
table {
@@ -79,8 +80,6 @@ const StyledWrapper = styled.div`
tbody {
tr {
height: 35px;
max-height: 35px;
transition: background 0.1s ease;
&:last-child td {
@@ -88,8 +87,6 @@ const StyledWrapper = styled.div`
}
td {
height: 35px;
max-height: 35px;
padding: 1px 10px !important;
border-top: none !important;
border-left: none !important;
@@ -99,23 +96,17 @@ const StyledWrapper = styled.div`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
box-sizing: border-box;
> div:not(.drag-handle) {
height: 33px;
max-height: 33px;
overflow: hidden;
&:last-child {
border-right: none;
}
/* Handle CodeMirror editors overflow */
.cm-editor {
max-width: 100%;
height: 33px !important;
max-height: 33px !important;
.cm-scroller {
overflow: hidden !important;
max-height: 33px;
}
.cm-content {
@@ -194,23 +185,12 @@ const StyledWrapper = styled.div`
}
.drag-handle {
opacity: 0;
transition: opacity 0.1s ease;
display: flex;
align-items: center;
justify-content: center;
.icon-grip,
.icon-minus {
color: ${(props) => props.theme.colors.text.muted};
}
}
tbody tr:hover .drag-handle,
tbody tr.drag-over .drag-handle {
opacity: 1;
}
select {
background-color: transparent;
color: ${(props) => props.theme.text};

View File

@@ -1,49 +1,10 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { TableVirtuoso } from 'react-virtuoso';
import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react';
import { IconTrash, IconAlertCircle, IconGripVertical, IconMinusVertical } from '@tabler/icons';
import { Tooltip } from 'react-tooltip';
import { uuid } from 'utils/common';
import StyledWrapper from './StyledWrapper';
const MIN_COLUMN_WIDTH = 80;
const ROW_HEIGHT = 35;
const findScrollParent = (element) => {
let parent = element?.parentElement;
while (parent) {
const { overflowY } = getComputedStyle(parent);
if (overflowY === 'auto' || overflowY === 'scroll') return parent;
parent = parent.parentElement;
}
return null;
};
const TableRow = React.memo(
({ children, item, context, ...rest }) => {
const rowIndex = Number(rest['data-item-index']);
const { reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, onDragStart, onDragOver, onDrop, onDragEnd, onDragLeave } = context;
const isEmpty = isLastEmptyRow(item, rowIndex);
const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount;
const isDragOver = canDrag && dragOverRow === rowIndex;
const existingClass = rest.className || '';
const className = isDragOver ? `${existingClass} drag-over`.trim() : existingClass;
return (
<tr
{...rest}
className={className}
draggable={canDrag}
onDragStart={canDrag ? (e) => onDragStart(e, rowIndex) : undefined}
onDragOver={canDrag ? (e) => onDragOver(e, rowIndex) : undefined}
onDragLeave={canDrag ? (e) => onDragLeave(e, rowIndex) : undefined}
onDrop={canDrag ? (e) => onDrop(e, rowIndex) : undefined}
onDragEnd={canDrag ? onDragEnd : undefined}
>
{children}
</tr>
);
}
);
const EditableTable = ({
tableId, // Not being used kept to maintain uniqueness & pass similar in onColumnWidthsChange
@@ -62,27 +23,15 @@ const EditableTable = ({
showAddRow = true,
testId = 'editable-table',
columnWidths,
initialScroll = 0,
onColumnWidthsChange
}) => {
const wrapperRef = useRef(null);
const virtuosoRef = useRef(null);
const tableRef = useRef(null);
const emptyRowUidRef = useRef(null);
const prevRowCountRef = useRef(0);
const [hoveredRow, setHoveredRow] = useState(null);
const [resizing, setResizing] = useState(null);
const [tableHeight, setTableHeight] = useState(0);
const [scrollParent, setScrollParent] = useState(null);
const [dragOverRow, setDragOverRow] = useState(null);
const widths = columnWidths || {};
useLayoutEffect(() => {
setScrollParent(findScrollParent(wrapperRef.current));
}, []);
const handleTotalHeightChanged = useCallback((h) => {
setTableHeight(h);
}, []);
const handleColumnWidthsChange = useCallback((newWidths) => {
onColumnWidthsChange?.(newWidths);
}, [onColumnWidthsChange]);
@@ -122,7 +71,7 @@ const EditableTable = ({
const handleMouseUp = () => {
// Convert pixel widths to percentages for responsive scaling
const table = wrapperRef.current?.querySelector('table');
const table = tableRef.current?.querySelector('table');
if (table) {
const tableWidth = table.offsetWidth;
const headerCells = table.querySelectorAll('thead td');
@@ -154,6 +103,23 @@ const EditableTable = ({
document.addEventListener('mouseup', handleMouseUp);
}, [columns, showCheckbox, widths, handleColumnWidthsChange]);
// Track table height for resize handles
useEffect(() => {
const table = tableRef.current?.querySelector('table');
if (!table) return;
const updateHeight = () => {
setTableHeight(table.offsetHeight);
};
updateHeight();
const resizeObserver = new ResizeObserver(updateHeight);
resizeObserver.observe(table);
return () => resizeObserver.disconnect();
}, [rows.length]);
const getColumnWidth = useCallback((column) => {
return widths[column.key] || column.width || 'auto';
}, [widths]);
@@ -213,16 +179,6 @@ const EditableTable = ({
return index === rowsWithEmpty.length - 1 && isEmptyRow(row);
}, [rowsWithEmpty.length, isEmptyRow, showAddRow]);
useEffect(() => {
if (rowsWithEmpty.length > prevRowCountRef.current && prevRowCountRef.current > 0) {
virtuosoRef.current?.scrollToIndex({
index: rowsWithEmpty.length - 1,
behavior: 'smooth'
});
}
prevRowCountRef.current = rowsWithEmpty.length;
}, [rowsWithEmpty.length]);
const handleValueChange = useCallback((rowUid, key, value) => {
const rowIndex = rowsWithEmpty.findIndex((r) => r.uid === rowUid);
if (rowIndex === -1) return;
@@ -289,31 +245,28 @@ const EditableTable = ({
const handleDragOver = useCallback((e, index) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverRow((prev) => (prev === index ? prev : index));
setHoveredRow(index);
}, []);
const handleDragLeave = useCallback((e, index) => {
if (e.currentTarget.contains(e.relatedTarget)) return;
setDragOverRow((prev) => (prev === index ? null : prev));
}, []);
const reorderableRowCount = showAddRow ? rowsWithEmpty.length - 1 : rowsWithEmpty.length;
const handleDrop = useCallback((e, toIndex) => {
e.preventDefault();
setDragOverRow(null);
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10);
if (fromIndex === toIndex || !onReorder) return;
const reorderableRows = showAddRow ? rowsWithEmpty.slice(0, -1) : rowsWithEmpty;
const updatedOrder = [...reorderableRows];
const [movedRow] = updatedOrder.splice(fromIndex, 1);
if (!movedRow) return;
updatedOrder.splice(toIndex, 0, movedRow);
onReorder({ updateReorderedItem: updatedOrder.map((row) => row.uid) });
if (fromIndex !== toIndex && onReorder) {
const reorderableRows = showAddRow ? rowsWithEmpty.slice(0, -1) : rowsWithEmpty;
const updatedOrder = [...reorderableRows];
const [movedRow] = updatedOrder.splice(fromIndex, 1);
if (!movedRow) {
setHoveredRow(null);
return;
}
updatedOrder.splice(toIndex, 0, movedRow);
onReorder({ updateReorderedItem: updatedOrder.map((row) => row.uid) });
}
setHoveredRow(null);
}, [onReorder, rowsWithEmpty, showAddRow]);
const handleDragEnd = useCallback(() => {
setDragOverRow(null);
setHoveredRow(null);
}, []);
const renderCell = useCallback((column, row, rowIndex) => {
@@ -370,124 +323,109 @@ const EditableTable = ({
);
}, [isLastEmptyRow, getRowError, handleValueChange]);
const virtuosoContext = useMemo(() => ({
reorderable,
reorderableRowCount,
isLastEmptyRow,
dragOverRow,
onDragStart: handleDragStart,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
onDragEnd: handleDragEnd
}), [reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, handleDragStart, handleDragOver, handleDragLeave, handleDrop, handleDragEnd]);
const fixedHeaderContent = useCallback(() => (
<tr>
{showCheckbox && (
<td className="text-center">{checkboxLabel}</td>
)}
{columns.map((column, colIndex) => (
<td
key={column.key}
style={{ width: getColumnWidth(column) }}
>
<span className="column-name">{column.name}</span>
{colIndex < columns.length - 1 && (
<div
className={`resize-handle ${resizing === column.key ? 'resizing' : ''}`}
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
onMouseDown={(e) => handleResizeStart(e, column.key)}
/>
)}
</td>
))}
{showDelete && (
<td style={{ width: '60px' }}></td>
)}
</tr>
), [showCheckbox, checkboxLabel, columns, getColumnWidth, resizing, tableHeight, handleResizeStart, showDelete]);
const itemContent = useCallback((rowIndex, row) => {
const isEmpty = isLastEmptyRow(row, rowIndex);
const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount;
return (
<>
{showCheckbox && (
<td className="text-center relative">
{reorderable && canDrag && (
<div
draggable
className="drag-handle group absolute z-10 left-[-8px] top-1/2 -translate-y-1/2 p-1 cursor-grab"
>
<IconGripVertical
size={14}
className="icon-grip hidden group-hover:block"
/>
<IconMinusVertical
size={14}
className="icon-minus block group-hover:hidden"
/>
</div>
)}
{!isEmpty && (
<input
type="checkbox"
className="mousetrap"
data-testid="column-checkbox"
checked={row[checkboxKey] ?? true}
disabled={disableCheckbox}
onChange={(e) => handleCheckboxChange(row.uid, e.target.checked)}
/>
)}
</td>
)}
{columns.map((column) => (
<td key={column.key} data-testid={`column-${column.key}`}>
{renderCell(column, row, rowIndex)}
</td>
))}
{showDelete && (
<td>
{!isEmpty && (
<button
data-testid="column-delete"
onClick={() => handleRemoveRow(row.uid)}
>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
)}
</>
);
}, [showCheckbox, reorderable, reorderableRowCount, isLastEmptyRow, checkboxKey, disableCheckbox, handleCheckboxChange, columns, renderCell, showDelete, handleRemoveRow]);
const initialTopMostItemIndex = useRef(Math.max(0, Math.floor(initialScroll / ROW_HEIGHT))).current;
const reorderableRowCount = showAddRow ? rowsWithEmpty.length - 1 : rowsWithEmpty.length;
return (
<StyledWrapper
ref={wrapperRef}
data-testid={testId}
className={`${showCheckbox ? 'has-checkbox' : 'no-checkbox'} ${resizing ? 'is-resizing' : ''}`}
>
{scrollParent && (
<TableVirtuoso
ref={virtuosoRef}
className="table-container"
customScrollParent={scrollParent}
data={rowsWithEmpty}
components={{ TableRow }}
context={virtuosoContext}
defaultItemHeight={ROW_HEIGHT}
initialTopMostItemIndex={initialTopMostItemIndex}
totalListHeightChanged={handleTotalHeightChanged}
computeItemKey={(_, item) => item.uid}
fixedHeaderContent={fixedHeaderContent}
itemContent={itemContent}
/>
)}
<StyledWrapper className={`${showCheckbox ? 'has-checkbox' : 'no-checkbox'} ${resizing ? 'is-resizing' : ''}`}>
<div className="table-container" ref={tableRef} data-testid={testId}>
<table>
<thead>
<tr>
{showCheckbox && (
<td className="text-center">{checkboxLabel}</td>
)}
{columns.map((column, colIndex) => (
<td
key={column.key}
style={{ width: getColumnWidth(column) }}
>
<span className="column-name">{column.name}</span>
{colIndex < columns.length - 1 && (
<div
className={`resize-handle ${resizing === column.key ? 'resizing' : ''}`}
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
onMouseDown={(e) => handleResizeStart(e, column.key)}
/>
)}
</td>
))}
{showDelete && (
<td style={{ width: '60px' }}></td>
)}
</tr>
</thead>
<tbody>
{rowsWithEmpty.map((row, rowIndex) => {
const isEmpty = isLastEmptyRow(row, rowIndex);
const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount;
return (
<tr
key={row.uid}
draggable={canDrag}
onDragStart={canDrag ? (e) => handleDragStart(e, rowIndex) : undefined}
onDragOver={canDrag ? (e) => handleDragOver(e, rowIndex) : undefined}
onDrop={canDrag ? (e) => handleDrop(e, rowIndex) : undefined}
onDragEnd={canDrag ? handleDragEnd : undefined}
onMouseEnter={() => setHoveredRow(rowIndex)}
onMouseLeave={() => setHoveredRow(null)}
>
{showCheckbox && (
<td className="text-center relative">
{reorderable && canDrag && (
<div
draggable
className="drag-handle group absolute z-10 left-[-8px] top-1/2 -translate-y-1/2 p-1 cursor-grab"
>
{hoveredRow === rowIndex && (
<>
<IconGripVertical
size={14}
className="icon-grip hidden group-hover:block"
/>
<IconMinusVertical
size={14}
className="icon-minus block group-hover:hidden"
/>
</>
)}
</div>
)}
{!isEmpty && (
<input
type="checkbox"
className="mousetrap"
data-testid="column-checkbox"
checked={row[checkboxKey] ?? true}
disabled={disableCheckbox}
onChange={(e) => handleCheckboxChange(row.uid, e.target.checked)}
/>
)}
</td>
)}
{columns.map((column) => (
<td key={column.key} data-testid={`column-${column.key}`}>
{renderCell(column, row, rowIndex)}
</td>
))}
{showDelete && (
<td>
{!isEmpty && (
<button
data-testid="column-delete"
onClick={() => handleRemoveRow(row.uid)}
>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</StyledWrapper>
);
};

View File

@@ -15,7 +15,6 @@ const Wrapper = styled.div`
overflow-y: auto;
border-radius: 8px;
border: solid 1px ${(props) => props.theme.border.border0};
transition: height 75ms cubic-bezier(0,1.12,.84,.64);
}
table {

View File

@@ -15,16 +15,13 @@ import toast from 'react-hot-toast';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables } from 'utils/collections';
import { stripEnvVarUid } from 'utils/environments';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const MIN_H = 35 * 2;
const MIN_COLUMN_WIDTH = 80;
const MIN_ROW_HEIGHT = 35;
const TableRow = React.memo(
({ children, item, style, ...rest }) => (
<tr key={item.uid} style={style} {...rest} data-testid={`env-var-row-${item?.name}`}>
({ children, item }) => (
<tr key={item.uid} data-testid={`env-var-row-${item.name}`}>
{children}
</tr>
),
@@ -59,19 +56,7 @@ const EnvironmentVariablesTable = ({
const hasDraftForThisEnv = draft?.environmentUid === environment.uid;
const rowCount = (environment.variables?.length || 0) + 1;
const [tableHeight, setTableHeight] = useState(rowCount * MIN_ROW_HEIGHT);
// We need to add <EditableTable/> component for env table
const [scroll, setScroll] = usePersistedState({
key: `persisted::${activeTabUid}::collection-envs-scroll-${environment.uid}`,
default: 0
});
const scrollerRef = useRef(null);
const [scrollerEl, setScrollerEl] = useState(null);
scrollerRef.current = scrollerEl;
const initialTopMostItemIndex = useRef(Math.max(0, Math.floor(scroll / MIN_ROW_HEIGHT))).current;
useTrackScroll({ ref: scrollerRef, onChange: setScroll, initialValue: scroll, enabled: !!scrollerEl });
const [tableHeight, setTableHeight] = useState(MIN_H);
// Use environment UID as part of tableId so each environment has its own column widths
const tableId = `env-vars-table-${environment.uid}`;
@@ -151,21 +136,17 @@ const EnvironmentVariablesTable = ({
const prevEnvVariablesRef = useRef(environment.variables);
const mountedRef = useRef(false);
let _collection = collection ? cloneDeep(collection) : {};
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
const workspaceProcessEnvVariables = activeWorkspace?.processEnvVariables;
// `_collection` flows into every row's MultiLineEditor as the variable-resolution
// context. Without memoization, `cloneDeep(collection)` runs on every render —
// and Formik triggers a re-render on every keystroke, so a single env edit
// session can deep-clone the entire collection 100+ times. That's the
// dominant cost behind the test-budget flake.
const _collection = useMemo(() => {
const c = collection ? cloneDeep(collection) : {};
c.globalEnvironmentVariables = globalEnvironmentVariables;
if (!collection && workspaceProcessEnvVariables) {
c.workspaceProcessEnvVariables = workspaceProcessEnvVariables;
}
return c;
}, [collection, globalEnvironmentVariables, workspaceProcessEnvVariables]);
if (_collection) {
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
}
// 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 initialValues = useMemo(() => {
const vars = environment.variables || [];
@@ -502,9 +483,6 @@ const EnvironmentVariablesTable = ({
<TableVirtuoso
className="table-container"
style={{ height: tableHeight }}
scrollerRef={setScrollerEl}
initialTopMostItemIndex={initialTopMostItemIndex}
overscan={Math.min(30, filteredVariables.length)}
components={{ TableRow }}
data={filteredVariables}
totalListHeightChanged={handleTotalHeightChanged}
@@ -524,6 +502,7 @@ const EnvironmentVariablesTable = ({
<td></td>
</tr>
)}
fixedItemHeight={35}
computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`}
itemContent={(virtualIndex, { variable, index: actualIndex }) => {
const isLastRow = actualIndex === formik.values.length - 1;
@@ -556,7 +535,7 @@ const EnvironmentVariablesTable = ({
id={`${actualIndex}.name`}
name={`${actualIndex}.name`}
value={variable.name}
placeholder={!variable.name || (typeof variable.name === 'string' && variable.name.trim() === '') ? 'Name' : ''}
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
onChange={(e) => handleNameChange(actualIndex, e)}
onFocus={() => handleRowFocus(variable.uid)}
onBlur={() => {
@@ -581,7 +560,7 @@ const EnvironmentVariablesTable = ({
collection={_collection}
name={`${actualIndex}.value`}
value={variable.value}
placeholder={variable.value == null || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Value' : ''}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => {
@@ -591,19 +570,6 @@ const EnvironmentVariablesTable = ({
formik.setFieldValue(`${actualIndex}.ephemeral`, undefined, false);
formik.setFieldValue(`${actualIndex}.persistedValue`, undefined, false);
}
// Append a new empty row when editing value on the last row
if (isLastRow) {
setTimeout(() => {
formik.setFieldValue(formik.values.length, {
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}, false);
}, 0);
}
}}
onSave={handleSave}
/>
@@ -644,8 +610,6 @@ const EnvironmentVariablesTable = ({
/>
)}
{/* We should re-think of these buttons placement in component as we use TableVirtuoso which because of
these buttons renders at some transition: height 0.1s ease` */}
<div className="button-container">
<div className="flex items-center">
<button type="button" className="submit" onClick={handleSave} data-testid="save-env">

View File

@@ -17,11 +17,7 @@ const EnvironmentListContent = ({
{environments && environments.length > 0 ? (
<>
<div className="environment-list">
<div
className={`dropdown-item no-environment ${!activeEnvironmentUid ? 'dropdown-item-active' : ''}`}
onClick={() => onEnvironmentSelect(null)}
>
<span className="w-2 shrink-0" />
<div className="dropdown-item no-environment" onClick={() => onEnvironmentSelect(null)}>
<span>No Environment</span>
</div>
<ToolHint

View File

@@ -117,10 +117,6 @@ const Wrapper = styled.div`
overflow: hidden;
}
.no-environment {
color: ${(props) => props.theme.colors.text.subtext0};
}
.environment-list {
flex: 1;
overflow-y: auto;

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { getRelativePathWithinBasePath } from 'utils/common/path';
import path 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,7 +48,13 @@ 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) => {
return getRelativePathWithinBasePath(collection.pathname, filePath);
const collectionDir = collection.pathname;
if (filePath.startsWith(collectionDir)) {
return path.relative(collectionDir, filePath);
}
return filePath;
});
onChange(isSingleFilePicker ? filePaths[0] : filePaths);

View File

@@ -2,6 +2,7 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 100%;
overflow-y: auto;
position: relative;
.editing-mode {

View File

@@ -1,35 +1,24 @@
import 'github-markdown-css/github-markdown.css';
import get from 'lodash/get';
import find from 'lodash/find';
import { updateFolderDocs } from 'providers/ReduxStore/slices/collections';
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { useRef } from 'react';
import { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import Markdown from 'components/MarkDown';
import CodeEditor from 'components/CodeEditor';
import Button from 'ui/Button';
import StyledWrapper from './StyledWrapper';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const Documentation = ({ collection, folder }) => {
const dispatch = useDispatch();
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const isEditing = focusedTab?.docsEditing || false;
const [isEditing, setIsEditing] = useState(false);
const docs = folder.draft ? get(folder, 'draft.docs', '') : get(folder, 'root.docs', '');
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `folder-docs-scroll-${folder.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.folder-settings-content', onChange: setScroll, enabled: !isEditing, initialValue: scroll });
const toggleViewMode = () => {
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
setIsEditing((prev) => !prev);
};
const onEdit = (value) => {
@@ -49,7 +38,7 @@ const Documentation = ({ collection, folder }) => {
}
return (
<StyledWrapper className="w-full relative flex flex-col" ref={wrapperRef}>
<StyledWrapper className="w-full relative flex flex-col">
<div className="editing-mode flex justify-between items-center flex-shrink-0" role="tab" onClick={toggleViewMode}>
{isEditing ? 'Preview' : 'Edit'}
</div>
@@ -66,8 +55,6 @@ const Documentation = ({ collection, folder }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
mode="application/text"
initialScroll={scroll}
onScroll={setScroll}
/>
</div>
<div className="mt-6 flex-shrink-0">

View File

@@ -1,6 +1,6 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
const Wrapper = styled.div`
table {
width: 100%;
border-collapse: collapse;
@@ -53,4 +53,4 @@ const StyledWrapper = styled.div`
}
`;
export default StyledWrapper;
export default Wrapper;

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback, useRef } from 'react';
import React, { useState, useCallback } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
@@ -13,8 +13,6 @@ import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import BulkEditor from 'components/BulkEditor/index';
import Button from 'ui/Button';
import { headerNameRegex, headerValueRegex } from 'utils/common/regex';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
@@ -27,9 +25,6 @@ const Headers = ({ collection, folder }) => {
? get(folder, 'draft.request.headers', [])
: get(folder, 'root.request.headers', []);
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `folder-headers-scroll-${folder.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.folder-settings-content', onChange: setScroll, initialValue: scroll });
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
@@ -130,7 +125,7 @@ const Headers = ({ collection, folder }) => {
}
return (
<StyledWrapper className="w-full" ref={wrapperRef}>
<StyledWrapper className="w-full">
<div className="text-xs mb-4 text-muted">
Request headers that will be sent with every request inside this folder.
</div>
@@ -143,10 +138,9 @@ const Headers = ({ collection, folder }) => {
getRowError={getRowError}
columnWidths={folderHeadersWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('folder-headers', widths)}
initialScroll={scroll}
/>
<div className="flex justify-end mt-2">
<button className="text-link select-none" data-testid="bulk-edit-toggle" onClick={toggleBulkEditMode}>
<button className="text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
</div>

View File

@@ -12,7 +12,6 @@ import StatusDot from 'components/StatusDot';
import { flattenItems, isItemARequest } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
const Script = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -22,9 +21,7 @@ const Script = ({ collection, folder }) => {
const responseScript = folder.draft ? get(folder, 'draft.request.script.res', '') : get(folder, 'root.request.script.res', '');
const tabs = useSelector((state) => state.tabs.tabs);
const focusedTab = find(tabs, (tab) => tab.type === 'folder-settings' && (tab.uid === folder.uid || tab.folderUid === folder.uid))
|| find(tabs, (tab) => tab.type === 'folder-settings' && tab.pathname === folder.pathname);
const tabUid = focusedTab?.uid || folder.uid;
const focusedTab = find(tabs, (t) => t.uid === folder.uid);
const scriptPaneTab = focusedTab?.scriptPaneTab;
// Default to post-response if pre-request script is empty (only when scriptPaneTab is null/undefined)
@@ -36,26 +33,19 @@ const Script = ({ collection, folder }) => {
const activeTab = scriptPaneTab || getDefaultTab();
const setActiveTab = (tab) => {
dispatch(updateScriptPaneTab({ uid: tabUid, scriptPaneTab: tab }));
dispatch(updateScriptPaneTab({ uid: folder.uid, scriptPaneTab: tab }));
};
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [preReqScroll, setPreReqScroll] = usePersistedState({ key: `folder-pre-req-scroll-${folder.uid}`, default: 0 });
const [postResScroll, setPostResScroll] = usePersistedState({ key: `folder-post-res-scroll-${folder.uid}`, default: 0 });
// Refresh CodeMirror when tab becomes visible and restore scroll position.
// CodeMirror's scrollTo() is silently ignored when the editor is inside a display:none container
// (TabsContent hides inactive tabs via display:none). After refresh() recalculates layout, we re-apply scrollTo().
// Refresh CodeMirror when tab becomes visible
useEffect(() => {
const timer = setTimeout(() => {
if (activeTab === 'pre-request' && preRequestEditorRef.current?.editor) {
preRequestEditorRef.current.editor.refresh();
preRequestEditorRef.current.editor.scrollTo(null, preReqScroll);
} else if (activeTab === 'post-response' && postResponseEditorRef.current?.editor) {
postResponseEditorRef.current.editor.refresh();
postResponseEditorRef.current.editor.scrollTo(null, postResScroll);
}
}, 0);
@@ -112,11 +102,10 @@ const Script = ({ collection, folder }) => {
</TabsTrigger>
</TabsList>
<TabsContent value="pre-request" className="mt-2" dataTestId="folder-pre-request-script-editor">
<TabsContent value="pre-request" className="mt-2">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
docKey="folder-script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
onEdit={onRequestScriptEdit}
@@ -125,16 +114,13 @@ const Script = ({ collection, folder }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
</TabsContent>
<TabsContent value="post-response" className="mt-2" dataTestId="folder-post-response-script-editor">
<TabsContent value="post-response" className="mt-2">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
docKey="folder-script:post-response"
value={responseScript || ''}
theme={displayedTheme}
onEdit={onResponseScriptEdit}
@@ -143,8 +129,6 @@ const Script = ({ collection, folder }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
</TabsContent>
</Tabs>

View File

@@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
@@ -7,16 +7,13 @@ import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
const Tests = ({ collection, folder }) => {
const dispatch = useDispatch();
const testsEditorRef = useRef(null);
const tests = folder.draft ? get(folder, 'draft.request.tests', '') : get(folder, 'root.request.tests', '');
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [testsScroll, setTestsScroll] = usePersistedState({ key: `folder-tests-scroll-${folder.uid}`, default: 0 });
const onEdit = (value) => {
dispatch(
@@ -34,9 +31,7 @@ const Tests = ({ collection, folder }) => {
<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}
@@ -45,8 +40,6 @@ const Tests = ({ collection, folder }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={testsScroll}
onScroll={setTestsScroll}
/>
<div className="mt-6">

View File

@@ -11,7 +11,7 @@ import toast from 'react-hot-toast';
import { variableNameRegex } from 'utils/common/regex';
import { setFolderVars } from 'providers/ReduxStore/slices/collections/index';
const VarsTable = ({ folder, collection, vars, varType, initialScroll = 0 }) => {
const VarsTable = ({ folder, collection, vars, varType }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
@@ -93,7 +93,6 @@ const VarsTable = ({ folder, collection, vars, varType, initialScroll = 0 }) =>
getRowError={getRowError}
columnWidths={folderVarsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('folder-vars', widths)}
initialScroll={initialScroll}
/>
</StyledWrapper>
);

View File

@@ -1,12 +1,10 @@
import React, { useRef } from 'react';
import React from 'react';
import get from 'lodash/get';
import VarsTable from './VarsTable';
import StyledWrapper from './StyledWrapper';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const Vars = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -14,19 +12,15 @@ const Vars = ({ collection, folder }) => {
const responseVars = folder.draft ? get(folder, 'draft.request.vars.res', []) : get(folder, 'root.request.vars.res', []);
const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `folder-vars-scroll-${folder.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.folder-settings-content', onChange: setScroll, initialValue: scroll });
return (
<StyledWrapper className="w-full flex flex-col" ref={wrapperRef}>
<StyledWrapper className="w-full flex flex-col">
<div>
<div className="mb-3 title text-xs">Pre Request</div>
<VarsTable folder={folder} collection={collection} vars={requestVars} varType="request" initialScroll={scroll} />
<VarsTable folder={folder} collection={collection} vars={requestVars} varType="request" />
</div>
<div>
<div className="mt-3 mb-3 title text-xs">Post Response</div>
<VarsTable folder={folder} collection={collection} vars={responseVars} varType="response" initialScroll={scroll} />
<VarsTable folder={folder} collection={collection} vars={responseVars} varType="response" />
</div>
<div className="mt-6">
<Button type="submit" size="sm" onClick={handleSave}>

View File

@@ -101,7 +101,7 @@ const FolderSettings = ({ collection, folder }) => {
Docs
</div>
</div>
<section className="folder-settings-content flex mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
<section className="flex mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
</div>
</StyledWrapper>
);

View File

@@ -267,16 +267,14 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
uid: result.item.uid,
collectionUid: result.collectionUid,
requestPaneTab: getDefaultRequestPaneTab(result.item),
type: result.item.type,
pathname: result.item.pathname
type: 'request'
}));
}
} else if (result.type === SEARCH_TYPES.FOLDER) {
dispatch(addTab({
uid: result.item.uid,
collectionUid: result.collectionUid,
type: 'folder-settings',
pathname: result.item.pathname
type: 'folder-settings'
}));
} else if (result.type === SEARCH_TYPES.COLLECTION) {
dispatch(addTab({

View File

@@ -16,7 +16,6 @@ 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();
@@ -158,7 +157,6 @@ 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) }
]}

View File

@@ -208,35 +208,21 @@ const Wrapper = styled.div`
outline-offset: 2px;
}
&:checked,
&:indeterminate {
&:checked {
background: ${(props) => props.theme.button2.color.primary.bg};
border-color: ${(props) => props.theme.button2.color.primary.border};
}
&:checked::after,
&:indeterminate::after {
content: '';
position: absolute;
}
&:checked::after {
left: 4px;
top: 1px;
width: 5px;
height: 9px;
border: solid ${(props) => props.theme.button2.color.primary.text};
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
&:indeterminate::after {
left: 2px;
top: 6px;
width: 10px;
height: 2px;
background: ${(props) => props.theme.button2.color.primary.text};
border-radius: 2px;
&::after {
content: '';
position: absolute;
left: 4px;
top: 1px;
width: 5px;
height: 9px;
border: solid ${(props) => props.theme.button2.color.primary.text};
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
}
}
`;

View File

@@ -28,9 +28,7 @@ const ModalFooter = ({
confirmDisabled,
hideCancel,
hideFooter,
footerLeft,
confirmButtonColor = 'primary',
dataTestId = 'modal'
confirmButtonColor = 'primary'
}) => {
confirmText = confirmText || 'Save';
cancelText = cancelText || 'Cancel';
@@ -40,27 +38,23 @@ const ModalFooter = ({
}
return (
<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 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>
);
};
@@ -78,7 +72,6 @@ const Modal = ({
hideCancel,
hideFooter,
hideClose,
footerLeft,
disableCloseOnOutsideClick,
disableEscapeKey,
onClick,
@@ -157,9 +150,7 @@ const Modal = ({
confirmDisabled={confirmDisabled}
hideCancel={hideCancel}
hideFooter={hideFooter}
footerLeft={footerLeft}
confirmButtonColor={confirmButtonColor}
dataTestId={dataTestId}
/>
</div>

View File

@@ -30,16 +30,6 @@ class MultiLineEditor extends Component {
// Initialize CodeMirror as a single line editor
/** @type {import("codemirror").Editor} */
const variables = getAllVariables(this.props.collection, this.props.item);
/**
* No-op. We claim Cmd-Enter / Ctrl-Enter here only to suppress CodeMirror's
* sublime keymap default (insertLineAfter), which would otherwise insert a
* newline. sendRequest dispatch is owned by Mousetrap — the editor input has
* the `mousetrap` class (added below) so the global
* useKeybinding('sendRequest', …) in RequestTabPanel handles it, and only
* in request tabs. Falling through with CodeMirror.Pass when onRun is absent
* would re-introduce the newline in collection/folder-level editors.
*/
const runShortcut = () => {};
this.editor = CodeMirror(this.editorRef.current, {
lineWrapping: false,
@@ -57,8 +47,6 @@ class MultiLineEditor extends Component {
extraKeys: {
'Cmd-F': () => {},
'Ctrl-F': () => {},
'Cmd-Enter': runShortcut,
'Ctrl-Enter': runShortcut,
// Tabbing disabled to make tabindex work
'Tab': false,
'Shift-Tab': false

View File

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

View File

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

View File

@@ -1,11 +1,8 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import find from 'lodash/find';
import React, { useState, useEffect, useCallback } from 'react';
import { IconLoader2, IconCloud } from '@tabler/icons';
import fastJsonFormat from 'fast-json-format';
import SpecViewer from 'components/ApiSpecPanel/SpecViewer';
import StyledWrapper from 'components/ApiSpecPanel/StyledWrapper';
import { updateApiSpecTabLeftPaneWidth } from 'providers/ReduxStore/slices/tabs';
/**
* Pretty-print JSON content for readable display. YAML content is returned as-is.
@@ -20,17 +17,7 @@ const prettyPrintSpec = (content) => {
}
};
const OpenAPISpecTab = ({ collection, tabUid }) => {
const dispatch = useDispatch();
const leftPaneWidth = useSelector((state) => {
const tab = find(state.tabs.tabs, (t) => t.uid === tabUid);
return tab?.apiSpecLeftPaneWidth ?? null;
});
const handleLeftPaneWidthChange = useCallback(
(w) => dispatch(updateApiSpecTabLeftPaneWidth({ uid: tabUid, apiSpecLeftPaneWidth: w })),
[dispatch, tabUid]
);
const OpenAPISpecTab = ({ collection }) => {
const [specContent, setSpecContent] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
@@ -39,16 +26,6 @@ const OpenAPISpecTab = ({ collection, tabUid }) => {
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
const sourceUrl = openApiSyncConfig?.sourceUrl;
// Latest env context for loadSpec's remote-fetch fallback. Kept out of
// loadSpec's deps so toggling a variable doesn't refire the spec load.
const envContextRef = useRef({});
envContextRef.current = {
activeEnvironmentUid: collection?.activeEnvironmentUid,
environments: collection?.environments,
runtimeVariables: collection?.runtimeVariables,
globalEnvironmentVariables: collection?.globalEnvironmentVariables
};
const loadSpec = useCallback(async () => {
setIsLoading(true);
setError(null);
@@ -65,7 +42,12 @@ const OpenAPISpecTab = ({ collection, tabUid }) => {
collectionUid: collection.uid,
collectionPath: collection.pathname,
sourceUrl,
environmentContext: envContextRef.current
environmentContext: {
activeEnvironmentUid: collection.activeEnvironmentUid,
environments: collection.environments,
runtimeVariables: collection.runtimeVariables,
globalEnvironmentVariables: collection.globalEnvironmentVariables
}
});
if (fetchResult.content) {
setSpecContent(prettyPrintSpec(fetchResult.content));
@@ -82,7 +64,7 @@ const OpenAPISpecTab = ({ collection, tabUid }) => {
} finally {
setIsLoading(false);
}
}, [collection?.pathname, collection?.uid, sourceUrl]);
}, [collection?.pathname, collection?.uid, collection?.activeEnvironmentUid, collection?.environments, collection?.runtimeVariables, collection?.globalEnvironmentVariables, sourceUrl]);
useEffect(() => {
if (collection?.pathname) {
@@ -115,12 +97,7 @@ const OpenAPISpecTab = ({ collection, tabUid }) => {
<span>Showing spec file from {sourceUrl}.</span>
</div>
)}
<SpecViewer
content={specContent}
readOnly
leftPaneWidth={leftPaneWidth}
onLeftPaneWidthChange={handleLeftPaneWidthChange}
/>
<SpecViewer content={specContent} readOnly />
</StyledWrapper>
);
};

View File

@@ -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() : 'Select File'}
{sourceUrl ? sourceUrl.split(/[\\/]/).pop() : 'Choose file...'}
</button>
</>
)}

View File

@@ -101,7 +101,7 @@ const ConnectionSettingsModal = ({ collection, sourceUrl, onSave, onDisconnect,
className="settings-input file-pick-btn"
onClick={() => fileInputRef.current?.click()}
>
{filePath ? filePath.split(/[\\/]/).pop() : 'Select File'}
{filePath ? filePath.split(/[\\/]/).pop() : 'Choose file...'}
</button>
</>
)}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { selectStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
import {
IconCopy,
IconDotsVertical,
@@ -36,7 +37,7 @@ const OpenAPISyncHeader = ({
}
}, [sourceUrl, sourceIsLocal, collection.pathname]);
const specMeta = useSelector((state) => state.openapiSync?.storedSpecMeta?.[collection.uid] || null);
const specMeta = useSelector(selectStoredSpecMeta(collection.uid));
const title = specMeta?.title || spec?.info?.title || 'Unknown API';
const copyUrl = async () => {

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { selectStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
import { getTotalRequestCountInCollection } from 'utils/collections/';
import { countEndpoints } from '../utils';
import moment from 'moment';
@@ -42,7 +43,7 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
const reduxError = useSelector((state) => state.openapiSync?.collectionUpdates?.[collection.uid]?.error);
const specMeta = useSelector((state) => state.openapiSync?.storedSpecMeta?.[collection.uid] || null);
const specMeta = useSelector(selectStoredSpecMeta(collection.uid));
const activeError = error || reduxError;
const version = specMeta?.version;

View File

@@ -1,46 +0,0 @@
import React from 'react';
/**
* One virtualized row in the spec diff. Renders the side-by-side cells
* (left line number, left code, right line number, right code) for a normal
* row, or a single full-width cell for a hunk header.
*
* Paired del+ins rows render via dangerouslySetInnerHTML so the <del>/<ins>
* markup from the word-level diff cache shows through. Solo rows render as
* React text children and let React handle escaping.
*/
const DiffRow = ({ row, active, cache }) => {
if (!row) return null; // guard: Virtuoso race on rapid open/close or theme switch
if (row.leftKind === 'hunk') {
return (
<div className="diff-row diff-row-hunk">
<div className="diff-cell-hunk">{row.leftText}</div>
</div>
);
}
const isChange = row.leftKind === 'del' && row.rightKind === 'ins';
const wd = isChange ? cache.getWordDiff(row.leftText, row.rightText) : null;
const renderContent = (text, html) =>
html !== null
? <span className="diff-content" dangerouslySetInnerHTML={{ __html: html }} />
: <span className="diff-content">{text}</span>;
return (
<div className={`diff-row ${active ? 'diff-row-focused' : ''}`}>
<div className={`diff-cell-num diff-kind-${row.leftKind}`}>{row.leftNum ?? ''}</div>
<div className={`diff-cell-code diff-kind-${row.leftKind}`}>
<span className="diff-prefix">{row.leftKind === 'del' ? '-' : ' '}</span>
{renderContent(row.leftText, wd ? wd.left : null)}
</div>
<div className={`diff-cell-num diff-kind-${row.rightKind}`}>{row.rightNum ?? ''}</div>
<div className={`diff-cell-code diff-kind-${row.rightKind}`}>
<span className="diff-prefix">{row.rightKind === 'ins' ? '+' : ' '}</span>
{renderContent(row.rightText, wd ? wd.right : null)}
</div>
</div>
);
};
export default React.memo(DiffRow);

View File

@@ -1,160 +0,0 @@
import { buildRows, wrapIndex } from '../buildRows';
// Helpers to construct fixture "parsed" data in the shape Diff2Html.parse()
// actually returns. Line types come from the LineType enum
// ('context' | 'insert' | 'delete'), NOT the CSSLineClass enum
// ('d2h-cntx' | 'd2h-ins' | 'd2h-del'). Verified at
// packages/bruno-app/public/static/diff2Html.js:3172.
const ctx = (text, oldNum, newNum) => ({
type: 'context',
content: ` ${text}`,
oldNumber: oldNum,
newNumber: newNum
});
const del = (text, oldNum) => ({ type: 'delete', content: `-${text}`, oldNumber: oldNum });
const ins = (text, newNum) => ({ type: 'insert', content: `+${text}`, newNumber: newNum });
const block = (header, lines) => ({ header, lines });
const file = (...blocks) => [{ blocks }];
describe('buildRows', () => {
test('1. empty/missing input → empty rows and changeBlocks', () => {
expect(buildRows(null)).toEqual({ rows: [], changeBlocks: [] });
expect(buildRows(undefined)).toEqual({ rows: [], changeBlocks: [] });
expect(buildRows([])).toEqual({ rows: [], changeBlocks: [] });
expect(buildRows([{ blocks: [] }])).toEqual({ rows: [], changeBlocks: [] });
});
test('2. all-context hunk → 0 change blocks, only ctx + hunk rows', () => {
const parsed = file(block('@@ -1,3 +1,3 @@', [ctx('a', 1, 1), ctx('b', 2, 2), ctx('c', 3, 3)]));
const { rows, changeBlocks } = buildRows(parsed);
expect(changeBlocks).toEqual([]);
expect(rows).toHaveLength(4); // 1 hunk + 3 ctx
expect(rows[0].leftKind).toBe('hunk');
expect(rows[1].leftKind).toBe('ctx');
expect(rows[1].leftText).toBe('a');
expect(rows[1].rightText).toBe('a');
expect(rows[1].leftNum).toBe(1);
expect(rows[1].rightNum).toBe(1);
});
test('3. pure-deletion run → del rows with empty placeholders on right', () => {
const parsed = file(
block('@@ -1,3 +1,1 @@', [ctx('keep', 1, 1), del('gone1', 2), del('gone2', 3)])
);
const { rows, changeBlocks } = buildRows(parsed);
expect(rows).toHaveLength(4); // 1 hunk + 1 ctx + 2 del rows
expect(rows[2].leftKind).toBe('del');
expect(rows[2].rightKind).toBe('empty');
expect(rows[2].leftText).toBe('gone1');
expect(rows[2].rightText).toBe('');
expect(rows[2].leftNum).toBe(2);
expect(rows[2].rightNum).toBeNull();
expect(rows[3].leftKind).toBe('del');
expect(rows[3].leftText).toBe('gone2');
// Two consecutive deletions form one block
expect(changeBlocks).toEqual([{ startIdx: 2, endIdx: 3 }]);
});
test('4. pure-insertion run → empty placeholders on left, ins on right', () => {
const parsed = file(
block('@@ -1,1 +1,3 @@', [ctx('keep', 1, 1), ins('new1', 2), ins('new2', 3)])
);
const { rows, changeBlocks } = buildRows(parsed);
expect(rows).toHaveLength(4);
expect(rows[2].leftKind).toBe('empty');
expect(rows[2].rightKind).toBe('ins');
expect(rows[2].leftText).toBe('');
expect(rows[2].rightText).toBe('new1');
expect(rows[2].leftNum).toBeNull();
expect(rows[2].rightNum).toBe(2);
expect(changeBlocks).toEqual([{ startIdx: 2, endIdx: 3 }]);
});
test('matched del+ins pair → paired row with leftKind=del, rightKind=ins', () => {
const parsed = file(block('@@ -1,1 +1,1 @@', [del('old', 1), ins('new', 1)]));
const { rows, changeBlocks } = buildRows(parsed);
expect(rows).toHaveLength(2); // hunk + 1 paired change row
// Paired row wears natural del/ins kinds — DiffRow detects this combo
// to run word-level diff. Matches GitHub's side-by-side convention
// (red left = deleted content, green right = inserted content).
expect(rows[1].leftKind).toBe('del');
expect(rows[1].rightKind).toBe('ins');
expect(rows[1].leftText).toBe('old');
expect(rows[1].rightText).toBe('new');
expect(rows[1].leftNum).toBe(1);
expect(rows[1].rightNum).toBe(1);
expect(changeBlocks).toEqual([{ startIdx: 1, endIdx: 1 }]);
});
test('5. multi-hunk diff → hunk rows insert correctly + blocks segment per change region', () => {
const parsed = file(
block('@@ -1,2 +1,2 @@', [ctx('a', 1, 1), del('b', 2), ins('B', 2)]),
block('@@ -10,2 +10,2 @@', [ctx('x', 10, 10), del('y', 11), ins('Y', 11)])
);
const { rows, changeBlocks } = buildRows(parsed);
// Block 1: hunk + ctx + 1 paired change = 3 rows
// Block 2: hunk + ctx + 1 paired change = 3 rows
expect(rows).toHaveLength(6);
expect(rows[0].leftKind).toBe('hunk');
expect(rows[3].leftKind).toBe('hunk');
// Two distinct change blocks (separated by hunk header reset)
expect(changeBlocks).toEqual([
{ startIdx: 2, endIdx: 2 },
{ startIdx: 5, endIdx: 5 }
]);
});
test('6. REGRESSION: change-block count matches expected counts for 3 fixture shapes', () => {
// The old DOM walker counted contiguous DOM rows containing
// .d2h-ins/.d2h-del/.d2h-change as one block. The new row-list walker
// must produce the same count for the same diff shape.
// Fixture A: small diff, one contiguous change region
const fixtureA = file(
block('@@ -1,4 +1,4 @@', [ctx('a', 1, 1), del('b', 2), ins('B', 2), ctx('c', 3, 3)])
);
expect(buildRows(fixtureA).changeBlocks).toHaveLength(1);
// Fixture B: medium, two separate change regions in one hunk
const fixtureB = file(
block('@@ -1,7 +1,7 @@', [
ctx('a', 1, 1),
del('b', 2),
ins('B', 2),
ctx('c', 3, 3),
ctx('d', 4, 4),
del('e', 5),
ins('E', 5),
ctx('f', 6, 6)
])
);
expect(buildRows(fixtureB).changeBlocks).toHaveLength(2);
// Fixture C: multi-hunk with adjacent del+ins runs that form a single
// contiguous change region per hunk
const fixtureC = file(
block('@@ -1,3 +1,4 @@', [ctx('a', 1, 1), del('b', 2), ins('B', 2), ins('C', 3)]),
block('@@ -10,4 +11,4 @@', [
ctx('x', 10, 11),
del('y', 11),
del('z', 12),
ins('Y', 12),
ins('Z', 13)
])
);
expect(buildRows(fixtureC).changeBlocks).toHaveLength(2);
});
});
describe('wrapIndex', () => {
test('7. wrap-around modulo handles negative and overflow', () => {
expect(wrapIndex(0, 5)).toBe(0);
expect(wrapIndex(4, 5)).toBe(4);
expect(wrapIndex(5, 5)).toBe(0);
expect(wrapIndex(6, 5)).toBe(1);
expect(wrapIndex(-1, 5)).toBe(4);
expect(wrapIndex(-6, 5)).toBe(4);
expect(wrapIndex(0, 0)).toBe(0);
expect(wrapIndex(3, 0)).toBe(0);
});
});

View File

@@ -1,164 +0,0 @@
/**
* Flatten Diff2Html's parsed unified-diff output into what the virtualized
* renderer needs:
*
* rows[] — one entry per visual row in the side-by-side layout
* (exactly what Virtuoso renders)
* changeBlocks[] — index ranges into rows[], drives Next/Prev navigation
*
* Row shape:
* { leftNum, leftText, leftKind, rightNum, rightText, rightKind }
* *Kind ∈ 'ctx' | 'del' | 'ins' | 'empty' | 'hunk'
*
* When a row has leftKind='del' AND rightKind='ins', DiffRow recognises it
* as a matched change and renders word-level highlights.
*/
// Diff2Html's parse() leaves the leading '+' / '-' / ' ' on each line's
// content. DiffRow renders that marker in its own styled span, so we strip
// it from the displayed text.
const stripLeadingMarker = (content) => (content || '').replace(/^[+\- ]/, '');
// Row factories — keep the row object shape consistent in one place.
const hunkRow = (header) => ({
leftKind: 'hunk',
rightKind: 'hunk',
leftText: header,
rightText: header,
leftNum: null,
rightNum: null
});
const contextRow = (line) => ({
leftKind: 'ctx',
rightKind: 'ctx',
leftText: stripLeadingMarker(line.content),
rightText: stripLeadingMarker(line.content),
leftNum: line.oldNumber ?? null,
rightNum: line.newNumber ?? null
});
const pairedChangeRow = (deletion, insertion) => ({
leftKind: 'del',
rightKind: 'ins',
leftText: stripLeadingMarker(deletion.content),
rightText: stripLeadingMarker(insertion.content),
leftNum: deletion.oldNumber ?? null,
rightNum: insertion.newNumber ?? null
});
const soloDeletionRow = (deletion) => ({
leftKind: 'del',
rightKind: 'empty',
leftText: stripLeadingMarker(deletion.content),
rightText: '',
leftNum: deletion.oldNumber ?? null,
rightNum: null
});
const soloInsertionRow = (insertion) => ({
leftKind: 'empty',
rightKind: 'ins',
leftText: '',
rightText: stripLeadingMarker(insertion.content),
leftNum: null,
rightNum: insertion.newNumber ?? null
});
export function buildRows(parsed) {
const rows = [];
if (!parsed || !Array.isArray(parsed) || parsed.length === 0) {
return { rows, changeBlocks: [] };
}
// Spec sync always produces a single-file diff; ignore any others.
const hunks = parsed[0]?.blocks || [];
// ── Pass 1: flatten each hunk's lines into visual rows ──
for (const hunk of hunks) {
if (hunk.header) rows.push(hunkRow(hunk.header));
const lines = hunk.lines || [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.type === 'context') {
rows.push(contextRow(line));
i++;
continue;
}
// Collect the next run of deletions, then the run of insertions that
// immediately follows. Pair them 1:1 into side-by-side change rows;
// any leftovers spill as solo rows.
//
// e.g. del A, del B, del C, ins X, ins Y
// → (A ↔ X) (B ↔ Y) (C ↔ ∅)
const deletions = [];
while (i < lines.length && lines[i].type === 'delete') {
deletions.push(lines[i]);
i++;
}
const insertions = [];
while (i < lines.length && lines[i].type === 'insert') {
insertions.push(lines[i]);
i++;
}
const pairCount = Math.min(deletions.length, insertions.length);
for (let p = 0; p < pairCount; p++) {
rows.push(pairedChangeRow(deletions[p], insertions[p]));
}
for (let p = pairCount; p < deletions.length; p++) {
rows.push(soloDeletionRow(deletions[p]));
}
for (let p = pairCount; p < insertions.length; p++) {
rows.push(soloInsertionRow(insertions[p]));
}
// Safety: skip unknown line types so the outer loop can't stall.
if (
i < lines.length
&& lines[i].type !== 'context'
&& lines[i].type !== 'delete'
&& lines[i].type !== 'insert'
) {
i++;
}
}
}
// ── Pass 2: group consecutive changed rows into navigation blocks ──
// Hunk headers and context rows each close the currently-active block.
const changeBlocks = [];
let currentBlock = null;
rows.forEach((row, idx) => {
const isChanged = row.leftKind === 'del' || row.rightKind === 'ins';
if (row.leftKind === 'hunk' || !isChanged) {
currentBlock = null;
return;
}
if (currentBlock) {
currentBlock.endIdx = idx;
} else {
currentBlock = { startIdx: idx, endIdx: idx };
changeBlocks.push(currentBlock);
}
});
return { rows, changeBlocks };
}
// Wrap-around modulo so Prev at block 0 jumps to the last block. JS's
// native `%` returns -1 for `-1 % 5`; the double-mod gives 4. Clamp to 0
// when there are no blocks at all.
export function wrapIndex(idx, length) {
if (length <= 0) return 0;
return ((idx % length) + length) % length;
}

View File

@@ -1,55 +0,0 @@
import { escapeHtml } from 'utils/response';
// Skip word-level diff on lines longer than this (Diff2Html default is 10k).
const MAX_HIGHLIGHT_LENGTH = 5000;
export function createHighlightCache() {
// Map of `${left}\x00${right}` → { left, right } HTML. The null byte separator safely delimits the pair.
const cache = new Map();
return {
// Word-level diff for a paired del+ins row. Returns { left, right } HTML
// with <del>/<ins> around changed words.
getWordDiff(leftContent, rightContent) {
const key = `${leftContent}\x00${rightContent}`;
const hit = cache.get(key);
if (hit !== undefined) return hit; // cache hit → skip the ~1-3ms recomputation
// Diff2Html ships as a global UMD bundle loaded from /public/static.
const D2H = typeof window !== 'undefined' && window.Diff2Html;
let result;
if (D2H && typeof D2H.diffHighlight === 'function') {
try {
// diffHighlight's internal parser expects each line to start with a
// prefix char (-, +, space) and strips it. We prepend '-' / '+' here
// purely to satisfy that input shape.
const out = D2H.diffHighlight(
`-${leftContent}`,
`+${rightContent}`,
false, // isCombined: standard two-way diff, not a git combined diff
{ matching: 'words', maxLineLengthHighlight: MAX_HIGHLIGHT_LENGTH }
);
// out.oldLine/newLine.content already has the <del>/<ins> markup we want.
result = {
left: out?.oldLine?.content ?? escapeHtml(leftContent),
right: out?.newLine?.content ?? escapeHtml(rightContent)
};
} catch {
// Malformed input or Diff2Html internal error — fall back so the row still renders.
result = { left: escapeHtml(leftContent), right: escapeHtml(rightContent) };
}
} else {
// Diff2Html bundle hasn't loaded (test env, CSP, etc.) — escape only.
result = { left: escapeHtml(leftContent), right: escapeHtml(rightContent) };
}
cache.set(key, result); // stored so Virtuoso remounts of this same row hit cache
return result;
},
// Empties the cache when a fresh diff replaces the current one.
clear() {
cache.clear();
}
};
}

View File

@@ -1,21 +1,13 @@
import { useRef, useEffect, useState } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { IconLoader2, IconChevronUp, IconChevronDown } from '@tabler/icons';
import { useTheme } from 'providers/Theme/index';
import { IconLoader2 } from '@tabler/icons';
import Modal from 'components/Modal';
import StatusBadge from 'ui/StatusBadge';
import { buildRows, wrapIndex } from './buildRows';
import { createHighlightCache } from './highlightCache';
import DiffRow from './DiffRow';
const SpecDiffModal = ({ specDrift, onClose }) => {
const virtuosoRef = useRef(null);
const [cache] = useState(createHighlightCache);
const diffRef = useRef(null);
const { displayedTheme } = useTheme();
const [isRendering, setIsRendering] = useState(true);
const [parseError, setParseError] = useState(false);
const [rows, setRows] = useState([]);
const [changeBlocks, setChangeBlocks] = useState([]);
const [currentIndex, setCurrentIndex] = useState(0);
const addedCount = specDrift?.added?.length || 0;
const modifiedCount = specDrift?.modified?.length || 0;
@@ -25,119 +17,54 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
? `v${specDrift.storedVersion || '?'} → v${specDrift.newVersion}`
: null;
// Parse + build row list, deferred via setTimeout so the spinner paints first.
useEffect(() => {
const { Diff2Html } = window;
if (!Diff2Html || !specDrift?.unifiedDiff) {
if (!diffRef?.current || !Diff2Html || !specDrift?.unifiedDiff) {
setIsRendering(false);
return;
}
setIsRendering(true);
setParseError(false);
// setTimeout yields to the browser so the spinner paints before parse blocks.
const timer = setTimeout(() => {
try {
const parsed = Diff2Html.parse(specDrift.unifiedDiff, {
outputFormat: 'side-by-side',
matching: 'lines'
});
const built = buildRows(parsed);
setRows(built.rows);
setChangeBlocks(built.changeBlocks);
setCurrentIndex(0);
cache.clear();
} catch (err) {
console.error('SpecDiffModal: failed to parse unified diff', err);
setParseError(true);
}
setIsRendering(false);
}, 0);
return () => clearTimeout(timer);
}, [specDrift?.unifiedDiff, cache]);
const goToChange = (idx) => {
if (!changeBlocks.length) return;
const nextIndex = wrapIndex(idx, changeBlocks.length);
const targetBlock = changeBlocks[nextIndex];
const fromBlock = changeBlocks[currentIndex];
const gap = fromBlock ? Math.abs(targetBlock.startIdx - fromBlock.startIdx) : 0;
virtuosoRef.current?.scrollToIndex({
index: targetBlock.startIdx,
align: 'center',
behavior: gap > 500 ? 'auto' : 'smooth'
const diffHtml = Diff2Html.html(specDrift.unifiedDiff, {
drawFileList: false,
matching: 'lines',
outputFormat: 'side-by-side',
synchronisedScroll: true,
highlight: true,
renderNothingWhenEmpty: false,
colorScheme: displayedTheme
});
setCurrentIndex(nextIndex);
};
const activeBlock = changeBlocks[currentIndex] || null;
const renderItem = (index) => (
<DiffRow
row={rows[index]}
active={!!activeBlock && index >= activeBlock.startIdx && index <= activeBlock.endIdx}
cache={cache}
/>
);
const showNav = !!specDrift?.unifiedDiff && !parseError;
const changeCount = changeBlocks.length;
const counterLabel
= changeCount === 0 ? 'No changes' : `${currentIndex + 1} of ${changeCount} changes`;
// Safe: Diff2Html is loaded from a local static bundle (public/static/diff2Html.js)
diffRef.current.innerHTML = diffHtml;
setIsRendering(false);
}, [displayedTheme, specDrift?.unifiedDiff]);
return (
<Modal size="xl" title="Spec Diff" hideFooter handleCancel={onClose}>
<Modal
size="xl"
title="Spec Diff"
hideFooter
handleCancel={onClose}
>
<div className="spec-diff-modal">
<div className="spec-diff-header">
<div className="spec-diff-header-left">
<div className="spec-diff-badges">
<div>Endpoint Changes:</div>
{modifiedCount > 0 && <StatusBadge status="warning">Updated: {modifiedCount}</StatusBadge>}
{addedCount > 0 && <StatusBadge status="success">Added: {addedCount}</StatusBadge>}
{removedCount > 0 && <StatusBadge status="danger">Removed: {removedCount}</StatusBadge>}
{versionLabel && <StatusBadge>{versionLabel}</StatusBadge>}
</div>
<p className="spec-diff-subtitle">
{specDrift?.storedSpecMissing
? 'The current spec file is missing. The full remote spec is shown below.'
: 'Side-by-side diff of your current spec vs the updated spec from the spec URL.'}
</p>
</div>
{showNav && (
<div className="spec-diff-nav">
<span className="spec-diff-nav-counter">{counterLabel}</span>
<div className="spec-diff-nav-buttons">
<button
type="button"
className="spec-diff-nav-btn"
onClick={() => goToChange(currentIndex - 1)}
disabled={changeCount === 0}
title="Previous change"
>
<IconChevronUp size={14} strokeWidth={1.75} /> Previous
</button>
<button
type="button"
className="spec-diff-nav-btn"
onClick={() => goToChange(currentIndex + 1)}
disabled={changeCount === 0}
title="Next change"
>
<IconChevronDown size={14} strokeWidth={1.75} /> Next
</button>
</div>
</div>
)}
<div className="spec-diff-badges">
{modifiedCount > 0 && <StatusBadge status="warning">Updated: {modifiedCount}</StatusBadge>}
{addedCount > 0 && <StatusBadge status="success">Added: {addedCount}</StatusBadge>}
{removedCount > 0 && <StatusBadge status="danger">Removed: {removedCount}</StatusBadge>}
{versionLabel && <StatusBadge>{versionLabel}</StatusBadge>}
</div>
<p className="spec-diff-subtitle">
{specDrift?.storedSpecMissing
? 'The current spec file is missing. The full remote spec is shown below.'
: 'Side-by-side diff of your current spec vs the updated spec from the spec URL.'}
</p>
<div className="spec-diff-body">
<div className="text-diff-container">
{specDrift?.unifiedDiff ? (
<>
<div className="diff-column-headers">
<span className="diff-column-label">
{specDrift?.storedSpecMissing ? 'Current Spec (missing)' : 'Current Spec'}
</span>
<span className="diff-column-label">{specDrift?.storedSpecMissing ? 'Current Spec (missing)' : 'Current Spec'}</span>
<span className="diff-column-label">Updated Spec</span>
</div>
{isRendering && (
@@ -146,25 +73,7 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
<span>Loading diff...</span>
</div>
)}
{!isRendering && parseError && (
<div className="text-diff-empty">
Diff couldn&apos;t be rendered. Please file an issue with the spec.
</div>
)}
{!isRendering && !parseError && rows.length > 0 && (
<Virtuoso
ref={virtuosoRef}
totalCount={rows.length}
itemContent={renderItem}
// Must match .diff-row min-height in OpenAPISyncTab/StyledWrapper.js
fixedItemHeight={18}
increaseViewportBy={400}
style={{ height: '100%' }}
/>
)}
{!isRendering && !parseError && rows.length === 0 && (
<div className="text-diff-empty">No changes to display.</div>
)}
<div ref={diffRef} style={{ display: isRendering ? 'none' : 'block' }}></div>
</>
) : (
<div className="text-diff-empty">No text diff available.</div>

View File

@@ -1503,154 +1503,143 @@ const StyledWrapper = styled.div`
.text-diff-container {
border-radius: ${(props) => props.theme.border.radius.sm};
border: 1px solid ${(props) => props.theme.border.border1};
overflow: hidden;
display: flex;
flex-direction: column;
background: ${(props) => props.theme.bg};
overflow: auto;
.diff-column-headers {
display: grid;
grid-template-columns: 9ch 1fr 9ch 1fr;
display: flex;
border-bottom: 1px solid ${(props) => props.theme.border.border1};
position: sticky;
top: 0;
z-index: 2;
background: ${(props) => props.theme.bg};
flex-shrink: 0;
.diff-column-label {
flex: 1;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
color: ${(props) => props.theme.colors.text.muted};
grid-column: span 2;
&:last-child {
border-left: 1px solid ${(props) => props.theme.border.border1};
&:first-child {
border-right: 1px solid ${(props) => props.theme.border.border1};
}
}
}
/* The Virtuoso scroll container fills the rest of the modal body. */
> div[data-testid='virtuoso-scroller'],
> div:last-child {
flex: 1 1 auto;
min-height: 0;
}
/* Active block gets a persistent 3px yellow bar down the left edge. */
.diff-row {
display: grid;
grid-template-columns: 9ch 1fr 9ch 1fr;
.d2h-wrapper {
background-color: ${(props) => props.theme.bg} !important;
font-family: 'Fira Code', monospace;
font-size: 12px;
line-height: 1.5;
/* Must match Virtuoso's fixedItemHeight in SpecDiffModal/index.js */
min-height: 18px;
color: ${(props) => props.theme.text};
font-variant-ligatures: none;
font-feature-settings: 'liga' 0, 'calt' 0;
}
/* Vertical divider between the two side-by-side panels. Applied to the
third grid cell (right-side line number), aligned with the header's
existing border-right on the "Current Spec" label. */
.diff-row > *:nth-child(3) {
border-left: 1px solid ${(props) => props.theme.border.border1};
.d2h-file-wrapper {
border: none;
border-radius: 0;
margin-bottom: 0;
}
.diff-row.diff-row-focused > .diff-cell-num:first-child {
box-shadow: inset 3px 0 0 ${(props) => props.theme.colors.text.yellow};
.d2h-file-header {
display: none;
}
.diff-row.diff-row-focused > .diff-cell-num {
color: ${(props) => props.theme.text};
font-weight: 600;
}
.d2h-files-diff {
width: 100%;
.diff-cell-num {
padding: 0 0.5em;
text-align: right;
color: ${(props) => props.theme.colors.text.muted};
user-select: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.diff-kind-del {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 22%, transparent);
}
&.diff-kind-ins {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 15%, transparent);
}
&.diff-kind-empty {
background-color: ${(props) => rgba(props.theme.colors.text.muted, 0.05)};
.d2h-file-side-diff:first-child {
border-right: 1px solid ${(props) => props.theme.border.border1};
}
}
.diff-cell-code {
display: flex;
min-width: 0;
padding: 0 0.5em;
white-space: pre;
overflow: hidden;
&.diff-kind-del {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 22%, transparent);
}
&.diff-kind-ins {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 15%, transparent);
}
&.diff-kind-empty {
background-color: ${(props) => rgba(props.theme.colors.text.muted, 0.05)};
}
.d2h-code-side-linenumber {
background: transparent !important;
position: static !important;
}
.diff-prefix {
width: 1em;
flex-shrink: 0;
color: ${(props) => props.theme.colors.text.muted};
user-select: none;
.d2h-diff-tbody {
tr td { border: none !important; }
}
.diff-content {
flex: 1 1 auto;
min-width: 0;
overflow-x: auto;
scrollbar-width: thin;
del {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 40%, transparent);
text-decoration: none;
}
ins {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 40%, transparent);
text-decoration: none;
}
.d2h-ins {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 15%, transparent) !important;
border-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 40%, transparent) !important;
}
/* Hunk row must be exactly 18px so Virtuoso's fixedItemHeight is
accurate. Borders would add 2px; we use inset box-shadow to get the
visual top/bottom rule without consuming layout space. Vertical
padding removed for the same reason. */
.diff-row-hunk {
grid-template-columns: 1fr;
background-color: ${(props) => rgba(props.theme.colors.text.muted, 0.08)};
color: ${(props) => props.theme.colors.text.muted};
box-shadow:
inset 0 1px 0 ${(props) => props.theme.border.border1},
inset 0 -1px 0 ${(props) => props.theme.border.border1};
.d2h-del {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 15%, transparent) !important;
border-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 40%, transparent) !important;
}
.diff-cell-hunk {
padding: 0 0.75em;
font-family: 'Fira Code', monospace;
font-size: 11px;
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
}
.d2h-file-diff .d2h-ins.d2h-change {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 25%, transparent) !important;
}
.d2h-file-diff .d2h-del.d2h-change {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.warning} 20%, transparent) !important;
}
.d2h-code-line ins,
.d2h-code-side-line ins {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 40%, transparent) !important;
text-decoration: none;
}
.d2h-code-line del,
.d2h-code-side-line del {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 40%, transparent) !important;
text-decoration: none;
}
.d2h-code-line,
.d2h-code-side-line {
color: ${(props) => props.theme.text} !important;
word-break: break-all;
}
.d2h-code-line-ctn {
word-break: break-all;
}
.d2h-tag {
font-size: 9px;
font-weight: 500;
padding: 1px 5px;
border-radius: ${(props) => props.theme.border.radius.sm};
text-transform: uppercase;
letter-spacing: 0.02em;
border: none;
}
.d2h-changed-tag {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.warning} 15%, transparent);
color: ${(props) => props.theme.colors.text.warning};
}
.d2h-added-tag {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 13%, transparent);
color: ${(props) => props.theme.colors.text.green};
}
.d2h-deleted-tag {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 13%, transparent);
color: ${(props) => props.theme.colors.text.danger};
}
.d2h-renamed-tag,
.d2h-moved-tag {
display: none;
}
.d2h-file-wrapper,
.d2h-file-diff,
.d2h-code-wrapper,
.d2h-diff-table,
.d2h-code-line,
.d2h-code-side-line,
.d2h-code-line-ctn,
.d2h-code-linenumber,
.d2h-code-side-linenumber {
font-family: 'Fira Code', monospace !important;
font-size: 12px !important;
}
}
@@ -1672,15 +1661,6 @@ const StyledWrapper = styled.div`
}
.spec-diff-modal {
.spec-diff-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 1rem;
}
.spec-diff-badges {
display: flex;
gap: 0.5rem;
@@ -1691,50 +1671,12 @@ const StyledWrapper = styled.div`
.spec-diff-subtitle {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
}
.spec-diff-nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
.spec-diff-nav-counter {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
}
.spec-diff-nav-buttons {
display: flex;
gap: 0.5rem;
}
.spec-diff-nav-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
font-size: ${(props) => props.theme.font.size.xs};
background: none;
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: ${(props) => props.theme.border.radius.sm};
color: ${(props) => props.theme.text};
cursor: pointer;
&:hover:not(:disabled) {
background: ${(props) => props.theme.background.surface1};
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
margin: 0 0 0.75rem 0;
}
.spec-diff-body {
.text-diff-container {
height: calc(80vh - 140px);
max-height: calc(80vh - 140px);
}
}
}

View File

@@ -15,7 +15,7 @@ import ExpandableEndpointRow from '../EndpointChangeSection/ExpandableEndpointRo
import ConfirmSyncModal from '../ConfirmSyncModal';
import SpecDiffModal from '../SpecDiffModal';
import Help from 'components/Help';
import { setReviewDecision, setReviewDecisions } from 'providers/ReduxStore/slices/openapi-sync';
import { setReviewDecision, setReviewDecisions, selectTabUiState } from 'providers/ReduxStore/slices/openapi-sync';
/**
* Categorize remoteDrift endpoints using three-way merge.
@@ -87,20 +87,9 @@ const SyncReviewPage = ({
onApplySync
}) => {
const dispatch = useDispatch();
const tabUiState = useSelector((state) => state.openapiSync?.tabUiState?.[collectionUid] || {});
const tabUiState = useSelector(selectTabUiState(collectionUid));
const [showConfirmation, setShowConfirmation] = useState(false);
const [showSpecDiffModal, setShowSpecDiffModal] = useState(false);
const [isOpeningSpecDiff, setIsOpeningSpecDiff] = useState(false);
// setTimeout lets the button's spinner paint before the modal mounts —
// without it, React batches both state updates and the spinner never shows.
const handleOpenSpecDiff = () => {
setIsOpeningSpecDiff(true);
setTimeout(() => {
setShowSpecDiffModal(true);
setIsOpeningSpecDiff(false);
}, 0);
};
const { specAddedEndpoints, specUpdatedEndpoints, localUpdatedEndpoints, specRemovedEndpoints } = useMemo(() => {
if (!remoteDrift) {
@@ -239,17 +228,8 @@ const SyncReviewPage = ({
{(specDrift?.unifiedDiff || decidableEndpoints.length > 0) && (
<div className="bulk-actions">
{specDrift?.unifiedDiff && (
<button
className="bulk-btn"
onClick={handleOpenSpecDiff}
disabled={isOpeningSpecDiff || showSpecDiffModal}
>
{isOpeningSpecDiff ? (
<IconLoader2 size={12} className="animate-spin" />
) : (
<IconArrowsDiff size={12} />
)}{' '}
View Spec Diff
<button className="bulk-btn" onClick={() => setShowSpecDiffModal(true)}>
<IconArrowsDiff size={12} /> View Spec Diff
</button>
)}
{decidableEndpoints.length > 0 && (

View File

@@ -1,16 +1,9 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useDispatch, useSelector, useStore } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import toast from 'react-hot-toast';
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { addTab, focusTab, closeTabs } from 'providers/ReduxStore/slices/tabs';
import { getDefaultRequestPaneTab } from 'utils/collections';
import {
clearCollectionState,
setCollectionUpdate,
setStoredSpec,
setStoredSpecMeta,
setDrift
} from 'providers/ReduxStore/slices/openapi-sync';
import { clearCollectionState, setCollectionUpdate, setStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
import { fetchAndValidateApiSpecFromUrl } from 'utils/importers/common';
import { isHttpUrl } from 'utils/url/index';
import { flattenItems } from 'utils/collections/index';
@@ -26,23 +19,19 @@ const useOpenAPISync = (collection) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [fileNotFound, setFileNotFound] = useState(false);
const [specDrift, setSpecDrift] = useState(null);
// Collection drift state
const [collectionDrift, setCollectionDrift] = useState(null);
const [remoteDrift, setRemoteDrift] = useState(null);
const [isDriftLoading, setIsDriftLoading] = useState(false);
const [storedSpec, setStoredSpec] = useState(null);
const drift = useSelector((state) => state.openapiSync?.drift?.[collection.uid] || null);
const specDrift = drift?.specDrift || null;
const collectionDrift = drift?.collectionDrift || null;
const remoteDrift = drift?.remoteDrift || null;
const storedSpec = useSelector((state) => state.openapiSync?.storedSpec?.[collection.uid] || null);
const updateDrift = (patch) => dispatch(setDrift({ collectionUid: collection.uid, patch }));
// useStore: tabs are read only inside handlers — useSelector would re-render on every tab change.
const store = useStore();
const tabs = useSelector((state) => state.tabs.tabs);
const isConfigured = !!openApiSyncConfig?.sourceUrl;
const updateStoredSpec = (spec) => {
dispatch(setStoredSpec({ collectionUid: collection.uid, spec }));
setStoredSpec(spec);
dispatch(setStoredSpecMeta({
collectionUid: collection.uid,
title: spec?.info?.title || null,
@@ -83,7 +72,6 @@ const useOpenAPISync = (collection) => {
const openEndpointInTab = (endpointId) => {
const itemUid = endpointUidMap[endpointId];
if (!itemUid) return;
const tabs = store.getState().tabs?.tabs || [];
const existingTab = tabs.find((t) => t.uid === itemUid);
if (existingTab) {
dispatch(focusTab({ uid: itemUid }));
@@ -93,18 +81,19 @@ const useOpenAPISync = (collection) => {
uid: itemUid,
collectionUid: collection.uid,
requestPaneTab: item ? getDefaultRequestPaneTab(item) : undefined,
type: item?.type ?? 'request'
type: 'request'
}));
}
};
const prevItemCountRef = useRef(httpItemCount);
const isDriftLoadingRef = useRef(false);
const specDriftRef = useRef(specDrift);
const loadCollectionDrift = async ({ clear = false } = {}) => {
if (isDriftLoadingRef.current && !clear) return;
isDriftLoadingRef.current = true;
if (clear) updateDrift({ collectionDrift: null });
if (clear) setCollectionDrift(null);
setIsDriftLoading(true);
try {
const { ipcRenderer } = window;
@@ -113,7 +102,7 @@ const useOpenAPISync = (collection) => {
});
if (!result.error) {
updateDrift({ collectionDrift: result, itemCountAtLastFetch: httpItemCount });
setCollectionDrift(result);
}
} catch (err) {
console.error('Error loading collection drift:', err);
@@ -133,7 +122,9 @@ const useOpenAPISync = (collection) => {
setIsLoading(true);
setError(null);
setFileNotFound(false);
updateDrift({ fetching: true });
setSpecDrift(null);
setRemoteDrift(null);
setCollectionDrift(null);
try {
const { ipcRenderer } = window;
@@ -155,13 +146,14 @@ const useOpenAPISync = (collection) => {
return;
}
updateDrift({ specDrift: result, lastChecked: Date.now() });
setSpecDrift(result);
updateStoredSpec(result.storedSpec || null);
// Update Redux store so toolbar status stays in sync
dispatch(setCollectionUpdate({
collectionUid: collection.uid,
hasUpdates: result.isValid !== false && result.hasChanges,
diff: result,
error: result.isValid === false ? result.error : null
}));
@@ -175,7 +167,7 @@ const useOpenAPISync = (collection) => {
console.error('Error computing remote drift:', remoteComparison.error);
setError(remoteComparison.error);
} else {
updateDrift({ remoteDrift: remoteComparison });
setRemoteDrift(remoteComparison);
}
}
@@ -189,25 +181,24 @@ const useOpenAPISync = (collection) => {
dispatch(setCollectionUpdate({
collectionUid: collection.uid,
hasUpdates: false,
diff: null,
error: formatIpcError(err) || 'Failed to check for updates'
}));
} finally {
updateDrift({ fetching: false });
setIsLoading(false);
}
};
useEffect(() => {
if (isConfigured && !drift?.specDrift && !drift?.fetching) {
if (isConfigured) {
checkForUpdates();
}
}, [isConfigured]);
// Reload drift when the collection's HTTP item count differs from what was recorded at the last fetch.
// Reload drift when collection items change (e.g., endpoint deleted from sidebar)
useEffect(() => {
if (!isConfigured) return;
const cachedCount = drift?.itemCountAtLastFetch;
if (cachedCount !== undefined && cachedCount !== httpItemCount && !drift?.fetching) {
if (prevItemCountRef.current !== httpItemCount && isConfigured) {
prevItemCountRef.current = httpItemCount;
loadCollectionDrift();
}
}, [httpItemCount, isConfigured]);
@@ -254,7 +245,7 @@ const useOpenAPISync = (collection) => {
});
if (result.isValid === false) {
updateDrift({ specDrift: result });
setSpecDrift(result);
setError(result.error);
return;
}
@@ -272,15 +263,15 @@ const useOpenAPISync = (collection) => {
// Check if collection already matches the spec
if (result.newSpec) {
const initialDrift = await ipcRenderer.invoke('renderer:get-collection-drift', {
const drift = await ipcRenderer.invoke('renderer:get-collection-drift', {
collectionPath: collection.pathname,
compareSpec: result.newSpec
});
const isInSync = !initialDrift.error
&& (!initialDrift.missing || initialDrift.missing.length === 0)
&& (!initialDrift.modified || initialDrift.modified.length === 0)
&& (!initialDrift.localOnly || initialDrift.localOnly.length === 0);
const isInSync = !drift.error
&& (!drift.missing || drift.missing.length === 0)
&& (!drift.modified || drift.modified.length === 0)
&& (!drift.localOnly || drift.localOnly.length === 0);
if (isInSync) {
// Collection matches — save spec file silently to complete setup
@@ -308,12 +299,15 @@ const useOpenAPISync = (collection) => {
deleteSpecFile: true
});
setSourceUrl('');
setSpecDrift(null);
setCollectionDrift(null);
setRemoteDrift(null);
setStoredSpec(null);
// Clear Redux state for this collection
dispatch(clearCollectionState({ collectionUid: collection.uid }));
// Close the openapi-spec tab if open (spec file no longer exists)
const tabs = store.getState().tabs?.tabs || [];
const specTab = tabs.find((t) => t.collectionUid === collection.uid && t.type === 'openapi-spec');
if (specTab) {
dispatch(closeTabs({ tabUids: [specTab.uid] }));
@@ -343,7 +337,7 @@ const useOpenAPISync = (collection) => {
compareSpec: currentSpecDrift.newSpec
});
if (!remoteComparison.error) {
updateDrift({ remoteDrift: remoteComparison });
setRemoteDrift(remoteComparison);
}
} catch (err) {
console.error('Error reloading remote drift:', err);

View File

@@ -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"

View File

@@ -39,6 +39,7 @@ const StyledWrapper = styled.div`
.section-divider {
height: 1px;
background: ${(props) => props.theme.input.border};
margin: 10px 0;
}
.tables-container {
@@ -74,7 +75,7 @@ const StyledWrapper = styled.div`
thead {
color: ${(props) => props.theme.table.thead.color} !important;
background: ${(props) => props.theme.table.striped};
background: ${(props) => props.theme.sidebar.bg};
user-select: none;
td {
@@ -99,8 +100,9 @@ const StyledWrapper = styled.div`
tr {
transition: background 0.1s ease;
height: 30px;
td {
padding: 0px 10px !important;
padding: 0 10px !important;
border: none !important;
vertical-align: middle;
background: transparent;
@@ -109,7 +111,7 @@ const StyledWrapper = styled.div`
}
tr:hover:not(.row-editing) td {
background: ${(props) => props.theme.background.surface0};
background: ${(props) => props.theme.sidebar.bg};
cursor: pointer;
}
@@ -118,7 +120,7 @@ const StyledWrapper = styled.div`
}
tr.section-heading-row td {
font-weight: 700;
font-weight: 600;
padding: 6px 10px !important;
user-select: none;
}
@@ -129,28 +131,8 @@ const StyledWrapper = styled.div`
}
tr.section-last-row td {
border-bottom: none !important;
}
tr.section-spacer-row {
height: 8px;
pointer-events: none;
}
tr.section-spacer-row td {
padding: 0 !important;
height: 8px;
line-height: 8px;
font-size: 0;
background: transparent !important;
border: none !important;
border-bottom: solid 1px ${(props) => props.theme.border.border0} !important;
}
tr.section-spacer-row:hover td {
background: transparent !important;
cursor: default;
}
}
.keybinding-row {
@@ -198,7 +180,7 @@ const StyledWrapper = styled.div`
}
.shortcut-input--editing {
outline: 1px solid ${(props) => props.theme.status.warning.border};
outline: 1px solid #E4AE49;
border-radius: 4px;
min-width: 100%;
max-width: 100%;
@@ -207,7 +189,7 @@ const StyledWrapper = styled.div`
}
.shortcut-input--error.shortcut-input--editing {
outline: 1px solid ${(props) => props.theme.status.danger.border};
outline: 1px solid #CE4F3B;
min-width: 100%;
max-width: 100%;
}
@@ -238,41 +220,39 @@ const StyledWrapper = styled.div`
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
height: 22px;
padding: 2px;
border-radius: 3px;
border: 1px solid ${(props) => props.theme.input.border};
background: ${(props) => props.theme.background.base};
color: ${(props) => props.theme.table.input.color};
font-size: 10px;
font-size: 12px;
font-weight: 500;
line-height: 1;
}
tbody tr.row-success td,
tbody tr.row-success:hover td {
background: ${(props) => props.theme.status.success.background} !important;
tbody tr.row-success td {
background: #2E8A540F;
}
tbody tr.row-error td,
tbody tr.row-error:hover td {
background: ${(props) => props.theme.status.danger.background} !important;
tbody tr.row-error td {
background: #D32F2F0F;
}
.success-icon {
color: ${(props) => props.theme.status.success.text};
color: #2E8A54;
display: inline-flex;
align-items: center;
}
.error-icon {
color: ${(props) => props.theme.status.danger.text};
color: #CE4F3B;
display: inline-flex;
align-items: center;
}
.input-error-icon {
color: ${(props) => props.theme.status.danger.text};
color: #CE4F3B;
display: inline-flex;
align-items: center;
margin-left: auto;
@@ -314,11 +294,6 @@ const StyledWrapper = styled.div`
border-radius: 6px;
padding: 0px 6px;
cursor: pointer;
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.action-btn {

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useRef, useState, useEffect, Fragment } from 'react';
import React, { useMemo, useRef, useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
@@ -10,7 +10,6 @@ import { savePreferences } from 'providers/ReduxStore/slices/app';
import { KEY_BINDING_SECTIONS } from 'providers/Hotkeys/keyMappings.js';
import { Tooltip } from 'react-tooltip';
import ToggleSwitch from 'components/ToggleSwitch/index';
import toast from 'react-hot-toast';
const SEP = '+bind+';
const getOS = () => (isMacOS() ? 'mac' : 'windows');
@@ -83,10 +82,10 @@ const renderDisplayValue = (displayValue, os) => {
return (
<span className="shortcut-pills">
{parsed.map((keysArr, index) => (
<Fragment key={index}>
<React.Fragment key={index}>
{index > 0 && <span className="shortcut-separator"> - </span>}
{renderKeycaps(keysArr, os)}
</Fragment>
</React.Fragment>
))}
</span>
);
@@ -219,21 +218,23 @@ const RESERVED_BY_OS = {
comboSignature(['f12']) // Dashboard (older macOS)
]),
windows: new Set([
// System-level shortcuts (intercepted by Windows before reaching the app)
comboSignature(['alt', 'tab']),
comboSignature(['alt', 'shift', 'tab']),
comboSignature(['alt', 'f4']),
comboSignature(['alt', 'esc']),
comboSignature(['alt', 'space']),
comboSignature(['ctrl', 'alt', 'delete']),
comboSignature(['ctrl', 'shift', 'esc']),
// Function keys
comboSignature(['f1']), // Windows Help
comboSignature(['f11']), // Fullscreen toggle
comboSignature(['f12']), // DevTools
comboSignature(['ctrl', 'alt', 'delete']),
comboSignature(['command', 'l']),
comboSignature(['command', 'd']),
comboSignature(['command', 'e']),
comboSignature(['command', 'r']),
comboSignature(['command', 'i']),
comboSignature(['command', 's']),
comboSignature(['command', 'a']),
comboSignature(['command', 'x']),
comboSignature(['command', 'm']),
comboSignature(['command', 'tab']),
comboSignature(['ctrl', 'shift', 'esc']),
// Undo/Redo - standard text editing shortcuts that browsers handle natively
comboSignature(['ctrl', 'z']),
comboSignature(['ctrl', 'y']),
comboSignature(['ctrl', 'shift', 'z']),
// Toggle Developer Tools
comboSignature(['ctrl', 'shift', 'i'])
@@ -492,7 +493,7 @@ const Keybindings = () => {
if (buildUsedSignatures(action).has(sig)) {
return {
code: ERROR.DUPLICATE,
message: 'This shortcut is already in use.'
message: 'That shortcut is already in use.'
};
}
@@ -561,24 +562,9 @@ const Keybindings = () => {
return next;
});
// Remove the entry from user preferences entirely so falls back to default.
// This also keeps `hasCustomizedKeybindings` accurate.
const nextKeyBindings = { ...(preferences?.keyBindings || {}) };
delete nextKeyBindings[action];
const updatedPreferences = {
...preferences,
keyBindings: nextKeyBindings
};
dispatch(savePreferences(updatedPreferences));
persistToPreferences(action, def);
};
const hasCustomizedKeybindings = useMemo(() => {
const userKeyBindings = preferences?.keyBindings || {};
return Object.keys(userKeyBindings).length > 0;
}, [preferences?.keyBindings]);
const resetAllKeybindings = () => {
const updatedPreferences = {
...preferences,
@@ -586,7 +572,6 @@ const Keybindings = () => {
};
dispatch(savePreferences(updatedPreferences));
toast.success('All shortcuts have been reset to default');
};
const startEditing = (action) => {
@@ -814,7 +799,6 @@ const Keybindings = () => {
onClick={resetAllKeybindings}
className="reset-btn"
data-testid="reset-all-keybindings-btn"
disabled={!hasCustomizedKeybindings}
>
Reset Default
</button>
@@ -833,7 +817,7 @@ const Keybindings = () => {
</thead>
<tbody>
{groupedKeyMappings.map((section, sectionIndex) => (
<Fragment key={section.heading}>
<React.Fragment key={section.heading}>
<tr className="section-heading-row">
<td colSpan={2}>{section.heading}</td>
</tr>
@@ -962,12 +946,7 @@ const Keybindings = () => {
</tr>
);
})}
{sectionIndex < groupedKeyMappings.length - 1 && (
<tr className="section-spacer-row" aria-hidden="true">
<td colSpan={2}>&nbsp;</td>
</tr>
)}
</Fragment>
</React.Fragment>
))}
</tbody>
</table>

View 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, pac_url } = systemProxyVariables || {};
const { source, http_proxy, https_proxy, no_proxy } = systemProxyVariables || {};
const [isFetching, setIsFetching] = useState(true);
const [error, setError] = useState(null);
@@ -85,12 +85,6 @@ 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"

View File

@@ -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, refreshPacCache } from 'providers/ReduxStore/slices/app';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
import { useDispatch, useSelector } from 'react-redux';
import { IconEye, IconEyeOff, IconRefresh } from '@tabler/icons';
import { IconEye, IconEyeOff } from '@tabler/icons';
import { useState } from 'react';
import SystemProxy from './SystemProxy';
@@ -103,12 +103,6 @@ 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';
@@ -445,7 +439,7 @@ const ProxySettings = ({ close }) => {
>
{formik.values.pac.source
? decodeURIComponent(formik.values.pac.source.split('/').pop())
: 'Select File'}
: 'Choose file...'}
</button>
)}
{formik.touched.pac?.source && formik.errors.pac?.source ? (
@@ -457,15 +451,6 @@ 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}

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useRef } from 'react';
import React, { useCallback } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
@@ -9,8 +9,6 @@ import SingleLineEditor from 'components/SingleLineEditor';
import AssertionOperator from './AssertionOperator';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const unaryOperators = [
'isEmpty',
@@ -57,9 +55,6 @@ const isUnaryOperator = (operator) => unaryOperators.includes(operator);
const Assertions = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `request-assert-scroll-${item.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll });
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const assertions = item.draft ? get(item, 'draft.request.assertions') : get(item, 'request.assertions');
@@ -171,7 +166,7 @@ const Assertions = ({ item, collection }) => {
};
return (
<StyledWrapper className="w-full" ref={wrapperRef}>
<StyledWrapper className="w-full">
<EditableTable
tableId="assertions"
columns={columns}
@@ -183,7 +178,6 @@ const Assertions = ({ item, collection }) => {
testId="assertions-table"
columnWidths={assertionsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('assertions', widths)}
initialScroll={scroll}
/>
</StyledWrapper>
);

View File

@@ -25,7 +25,7 @@ const ApiKeyAuth = ({ item, collection, updateAuth, request, save }) => {
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} data-testid="auth-placement-label" className="flex items-center justify-end auth-type-label select-none">
<div ref={ref} 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 data-testid="auth-placement-selector" className="inline-flex items-center cursor-pointer auth-placement-selector w-fit">
<div className="inline-flex items-center cursor-pointer auth-placement-selector w-fit">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"

View File

@@ -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} />

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useRef } from 'react';
import React, { useCallback } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
@@ -11,15 +11,10 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const FormUrlEncodedParams = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `request-body-formUrlEncoded-scroll-${item.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll });
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const params = item.draft ? get(item, 'draft.request.body.formUrlEncoded') : get(item, 'request.body.formUrlEncoded');
@@ -86,7 +81,7 @@ const FormUrlEncodedParams = ({ item, collection }) => {
};
return (
<StyledWrapper className="w-full" ref={wrapperRef}>
<StyledWrapper className="w-full">
<EditableTable
tableId="form-url-encoded"
columns={columns}
@@ -97,7 +92,6 @@ const FormUrlEncodedParams = ({ item, collection }) => {
onReorder={handleParamDrag}
columnWidths={formUrlEncodedWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('form-url-encoded', widths)}
initialScroll={scroll}
/>
</StyledWrapper>
);

View File

@@ -10,7 +10,6 @@ import useLocalStorage from 'hooks/useLocalStorage';
import CodeEditor from 'components/CodeEditor/index';
import Button from 'ui/Button';
import StyledWrapper from './StyledWrapper';
import { usePersistedState } from 'hooks/usePersistedState';
import { IconSend, IconRefresh, IconWand, IconPlus, IconTrash } from '@tabler/icons';
import ToolHint from 'components/ToolHint/index';
import { toastError } from 'utils/common/error';
@@ -71,10 +70,8 @@ const MessageToolbar = ({
const SingleGrpcMessage = ({ message, item, collection, index, methodType, handleRun, canClientSendMultipleMessages, isLast }) => {
const dispatch = useDispatch();
const editorRef = useRef(null);
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [grpcScroll, setGrpcScroll] = usePersistedState({ key: `request-grpc-msg-scroll-${item.uid}-${index}`, default: 0 });
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const isConnectionActive = useSelector((state) => state.collections.activeConnections.includes(item.uid));
@@ -202,7 +199,6 @@ const SingleGrpcMessage = ({ message, item, collection, index, methodType, handl
/>
<div className="editor-container">
<CodeEditor
ref={editorRef}
collection={collection}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
@@ -213,8 +209,6 @@ const SingleGrpcMessage = ({ message, item, collection, index, methodType, handl
onSave={onSave}
mode="application/ld+json"
enableVariableHighlighting={true}
initialScroll={grpcScroll}
onScroll={setGrpcScroll}
/>
</div>
</div>

View File

@@ -111,7 +111,7 @@ const HttpRequestPane = ({ item, collection }) => {
const tabPanel = useMemo(() => {
const Component = TAB_PANELS[requestPaneTab];
return Component ? <Component key={item.uid} item={item} collection={collection} /> : <div className="mt-4">404 | Not found</div>;
return Component ? <Component item={item} collection={collection} /> : <div className="mt-4">404 | Not found</div>;
}, [requestPaneTab, item, collection]);
if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) {

View File

@@ -13,7 +13,6 @@ const Wrapper = styled.div`
cursor: pointer;
border-radius: 4px;
transition: color 0.15s ease;
flex: 0 0 auto;
&:hover {
color: ${(props) => props.theme.text};
@@ -24,6 +23,15 @@ 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%;

View File

@@ -1,9 +1,8 @@
import React, { useCallback, useRef } from 'react';
import React, { 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 } from '@tabler/icons';
import { IconUpload, IconX, IconFile } from '@tabler/icons';
import {
moveMultipartFormParam,
setMultipartFormParams
@@ -11,25 +10,16 @@ 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, { getRelativePathWithinBasePath, normalizePath } from 'utils/common/path';
import { getMultipartAutoContentType } from 'utils/common/multipartContentType';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const fileBasename = (filePath) =>
filePath ? path.basename(normalizePath(String(filePath))) : '';
import path from 'utils/common/path';
import { isWindowsOS } from 'utils/common/platform';
const MultipartFormParams = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `request-body-multipartForm-scroll-${item.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll });
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const params = item.draft ? get(item, 'draft.request.body.multipartForm') : get(item, 'request.body.multipartForm');
@@ -62,53 +52,32 @@ const MultipartFormParams = ({ item, collection }) => {
}, [dispatch, collection.uid, item.uid]);
const handleBrowseFiles = useCallback((row, onChange) => {
dispatch(browseFiles([], ['multiSelections']))
dispatch(browseFiles())
.then((filePaths) => {
if (!Array.isArray(filePaths) || filePaths.length === 0) return;
const processedPaths = filePaths.map((filePath) => {
return getRelativePathWithinBasePath(collection.pathname, filePath);
const collectionDir = collection.pathname;
if (filePath.startsWith(collectionDir)) {
return path.relative(collectionDir, filePath);
}
return filePath;
});
const currentParams = item.draft
? get(item, 'draft.request.body.multipartForm')
: get(item, 'request.body.multipartForm');
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);
const existsInParams = (currentParams || []).some((p) => p.uid === row.uid);
let updatedParams;
if (existingParam) {
if (existsInParams) {
updatedParams = currentParams.map((p) => {
if (p.uid === row.uid) {
return { ...p, type: 'file', value: merged, contentType: autoContentType };
return { ...p, type: 'file', value: processedPaths };
}
return p;
});
} else {
updatedParams = [
...(currentParams || []),
{ uid: row.uid, name: row.name || '', enabled: true, type: 'file', value: merged, contentType: autoContentType }
{ uid: row.uid, name: row.name || '', enabled: true, type: 'file', value: processedPaths, contentType: '' }
];
}
handleParamsChange(updatedParams);
@@ -118,21 +87,13 @@ const MultipartFormParams = ({ item, collection }) => {
});
}, [dispatch, collection.pathname, item, handleParamsChange]);
const handleRemoveFile = useCallback((row, filePathToRemove) => {
const handleClearFile = useCallback((row) => {
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;
if (nextValue.length === 0) {
return { ...p, type: 'text', value: '', contentType: '' };
if (p.uid === row.uid) {
return { ...p, type: 'text', value: '' };
}
return { ...p, type: 'file', value: nextValue, contentType: getMultipartAutoContentType(nextValue) };
return p;
});
handleParamsChange(updatedParams);
}, [params, handleParamsChange]);
@@ -153,12 +114,19 @@ const MultipartFormParams = ({ item, collection }) => {
}
}, [params, handleParamsChange]);
const getFileList = (filePaths) => {
const getFileName = (filePaths) => {
if (!filePaths || (Array.isArray(filePaths) && filePaths.length === 0)) {
return [];
return null;
}
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
return paths.filter((v) => v != null && v !== '');
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)`;
};
const columns = [
@@ -175,14 +143,29 @@ const MultipartFormParams = ({ item, collection }) => {
placeholder: 'Value',
width: '35%',
render: ({ row, value, onChange }) => {
const files = row.type === 'file' ? getFileList(value) : [];
if (files.length > 0) {
const isFile = row.type === 'file';
const fileName = isFile ? getFileName(value) : null;
if (fileName) {
return (
<MultipartFileChipsCell
files={files}
onRemove={(filePath) => handleRemoveFile(row, filePath)}
onAdd={() => handleBrowseFiles(row, onChange)}
/>
<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>
);
}
@@ -202,10 +185,9 @@ 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>
@@ -240,7 +222,7 @@ const MultipartFormParams = ({ item, collection }) => {
};
return (
<StyledWrapper className="w-full" ref={wrapperRef}>
<StyledWrapper className="w-full">
<EditableTable
tableId="multipart-form"
columns={columns}
@@ -251,7 +233,6 @@ const MultipartFormParams = ({ item, collection }) => {
onReorder={handleParamDrag}
columnWidths={multipartFormWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('multipart-form', widths)}
initialScroll={scroll}
/>
</StyledWrapper>
);

View File

@@ -94,7 +94,6 @@ const ArgValueInput = ({ value, onChange, field }) => {
onChange={(e) => onChange(e.target.value)}
onClick={(e) => e.stopPropagation()}
placeholder="Enter value"
className="mousetrap"
/>
);
};
@@ -140,7 +139,7 @@ const InputObjectFields = ({ namedType, parentKey, fieldPath, indent, argValues,
)}
<input
type="checkbox"
className="field-checkbox mousetrap"
className="field-checkbox"
checked={isEnabled}
onChange={(e) => {
e.stopPropagation();
@@ -231,6 +230,12 @@ const FieldNode = ({
role="treeitem"
aria-expanded={isExpanded}
onClick={handleExpand}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleExpand(e);
}
}}
tabIndex={0}
>
<span className="field-indent" style={{ width: indent }} />
@@ -243,7 +248,7 @@ const FieldNode = ({
</span>
<input
type="checkbox"
className="field-checkbox mousetrap"
className="field-checkbox"
checked={isChecked}
onChange={handleCheck}
onClick={(e) => e.stopPropagation()}
@@ -263,6 +268,12 @@ const FieldNode = ({
role="treeitem"
aria-expanded={canExpand ? isExpanded : undefined}
onClick={handleExpand}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleExpand(e);
}
}}
tabIndex={0}
>
<span className="field-indent" style={{ width: indent }} />
@@ -277,7 +288,7 @@ const FieldNode = ({
</span>
<input
type="checkbox"
className="field-checkbox mousetrap"
className="field-checkbox"
checked={isChecked}
onChange={handleCheck}
onClick={(e) => e.stopPropagation()}
@@ -304,7 +315,7 @@ const FieldNode = ({
<span className="input-object-chevron-spacer" />
<input
type="checkbox"
className="field-checkbox mousetrap"
className="field-checkbox"
checked={isArgEnabled}
onChange={() => onToggleArg && onToggleArg(field.path, arg.name)}
onClick={(e) => e.stopPropagation()}
@@ -358,7 +369,7 @@ const FieldNode = ({
<span className="input-object-chevron-spacer" />
<input
type="checkbox"
className="field-checkbox mousetrap"
className="field-checkbox"
checked={isArgEnabled}
onChange={() => onToggleArg && onToggleArg(field.path, arg.name)}
onClick={(e) => e.stopPropagation()}
@@ -408,6 +419,12 @@ const InputObjectArgRow = ({ arg, argKey, fieldPath, isArgEnabled, sectionIndent
className="arg-row"
style={{ paddingLeft: sectionIndent + 8 }}
onClick={toggleExpand}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleExpand(e);
}
}}
tabIndex={0}
role="button"
aria-expanded={isExpanded}
@@ -421,7 +438,7 @@ const InputObjectArgRow = ({ arg, argKey, fieldPath, isArgEnabled, sectionIndent
</span>
<input
type="checkbox"
className="field-checkbox mousetrap"
className="field-checkbox"
checked={isArgEnabled}
onChange={handleCheck}
onClick={(e) => e.stopPropagation()}
@@ -469,6 +486,12 @@ const ListArgRow = ({ arg, fieldPath, isArgEnabled, argValue, sectionIndent, onT
className="arg-row"
style={{ paddingLeft: sectionIndent + 8 }}
onClick={toggleExpand}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleExpand(e);
}
}}
tabIndex={0}
role="button"
aria-expanded={isExpanded}
@@ -482,7 +505,7 @@ const ListArgRow = ({ arg, fieldPath, isArgEnabled, argValue, sectionIndent, onT
</span>
<input
type="checkbox"
className="field-checkbox mousetrap"
className="field-checkbox"
checked={isArgEnabled}
onChange={handleCheck}
onClick={(e) => e.stopPropagation()}

View File

@@ -211,12 +211,7 @@ const StyledWrapper = styled.div`
padding: 3px 8px;
font-size: 13px;
min-width: 0;
cursor: pointer;
&:hover,
&:focus-visible {
background: ${(props) => props.theme.background.surface0};
}
cursor: default;
.input-object-chevron {
width: 14px;

View File

@@ -175,7 +175,6 @@ const QueryBuilder = ({ schema, onQueryChange, editorValue, onVariablesChange, v
type="text"
placeholder="Search operations..."
value={searchText}
className="mousetrap"
onChange={(e) => setSearchText(e.target.value)}
/>
</div>

View File

@@ -16,7 +16,6 @@ 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');
@@ -54,16 +53,6 @@ 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,
@@ -136,9 +125,7 @@ export default class QueryEditor extends React.Component {
}
},
'Cmd-F': 'findPersistent',
'Ctrl-F': 'findPersistent',
'Cmd-Enter': runShortcut,
'Ctrl-Enter': runShortcut
'Ctrl-F': 'findPersistent'
}
}));
if (editor) {
@@ -150,13 +137,6 @@ 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();
if (cmInput) {
cmInput.classList.add('mousetrap');
}
}
componentDidUpdate(prevProps) {
@@ -194,7 +174,6 @@ 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);

View File

@@ -38,14 +38,6 @@ const Wrapper = styled.div`
}
}
.bulk-edit-bar {
position: sticky;
bottom: 0;
background: ${(props) => props.theme.bg};
padding-top: 8px;
padding-bottom: 4px;
}
input[type='text'] {
width: 100%;
border: solid 1px transparent;

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback, useRef } from 'react';
import React, { useState, useCallback } from 'react';
import get from 'lodash/get';
import InfoTip from 'components/InfoTip';
import { useDispatch, useSelector } from 'react-redux';
@@ -14,8 +14,6 @@ import MultiLineEditor from 'components/MultiLineEditor';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import BulkEditor from '../../BulkEditor';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const QueryParams = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -27,9 +25,6 @@ const QueryParams = ({ item, collection }) => {
const pathParams = params.filter((param) => param.type === 'path');
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `request-params-scroll-${item.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll });
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
@@ -151,7 +146,7 @@ const QueryParams = ({ item, collection }) => {
}
return (
<StyledWrapper className="w-full flex flex-col" ref={wrapperRef}>
<StyledWrapper className="w-full flex flex-col">
<div className="flex-1">
<div className="mb-3 title text-xs">Query</div>
<EditableTable
@@ -164,9 +159,8 @@ const QueryParams = ({ item, collection }) => {
onReorder={handleQueryParamDrag}
columnWidths={queryParamsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('query-params', widths)}
initialScroll={scroll}
/>
<div className="bulk-edit-bar flex justify-end mt-2">
<div className="flex justify-end mt-2">
<button className="btn-action text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
@@ -197,7 +191,6 @@ const QueryParams = ({ item, collection }) => {
showAddRow={false}
columnWidths={pathParamsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('path-params', widths)}
initialScroll={scroll}
/>
) : (
<div className="title pr-2 py-3 mt-2 text-xs"></div>

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