Compare commits

..

1 Commits

Author SHA1 Message Date
Cmarvin1
148d3f0e7d fix: Code Generation for Basic Auth (#6474)
* Adding interpolation utilities

* Refactor interpolation

* Refactor interpolation

* updating tests

* updating tests

* minor refinements to interpolation logic

* update snippet generator to handle basic auth credentials

* move interpolation upstream
2026-01-21 18:37:49 +05:30
1277 changed files with 15978 additions and 123487 deletions

View File

@@ -23,19 +23,6 @@ reviews:
drafts: false
base_branches: ['main', 'release/*']
path_instructions:
- path: '**/*'
instructions: |
Bruno is a cross-platform Electron desktop app that runs on macOS, Windows, and Linux. Ensure that all code is OS-agnostic:
- File paths must use `path.join()` or `path.resolve()` instead of hardcoded `/` or `\\` separators
- Never assume case-sensitive or case-insensitive filesystems
- Use `os.homedir()`, `app.getPath()`, or environment-appropriate APIs instead of hardcoded paths like `/home/`, `C:\\Users\\`, or `~/`
- Line endings should be handled consistently (be aware of CRLF vs LF issues)
- Use `path.sep` or `path.posix`/`path.win32` when platform-specific separators are needed
- Shell commands or child_process calls must account for platform differences (e.g., `which` vs `where`, `/bin/sh` vs `cmd.exe`)
- File permissions (e.g., `fs.chmod`, `fs.access`) should account for Windows not supporting Unix-style permission bits
- Avoid relying on Unix-only signals (e.g., `SIGKILL`) without Windows fallbacks
- Use `os.tmpdir()` instead of hardcoding `/tmp`
- Environment variable access should handle platform differences (e.g., `HOME` vs `USERPROFILE`)
- path: 'tests/**/**.*'
instructions: |
Review the following e2e test code written using the Playwright test library. Ensure that:

2
.github/CODEOWNERS vendored
View File

@@ -1 +1 @@
* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno @sid-bruno
* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno

View File

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

View File

@@ -1,30 +0,0 @@
name: 'Run OAuth1 CLI Tests - Linux'
description: 'Run OAuth1 CLI tests on Linux'
runs:
using: 'composite'
steps:
- name: Run BRU format CLI tests
shell: bash
run: |
set -euo pipefail
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
# navigate to BRU test collection directory
cd tests/auth/oauth1/fixtures/collections/bru
echo "=== BRU Format Collection Run ==="
node $BRU_CLI run --env Local --output junit-bru.xml --format junit
- name: Run YML format CLI tests
shell: bash
run: |
set -euo pipefail
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
# navigate to YML test collection directory
cd tests/auth/oauth1/fixtures/collections/yml
echo "=== YML Format Collection Run ==="
node $BRU_CLI run --env Local --output junit-yml.xml --format junit

View File

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

View File

@@ -1,16 +0,0 @@
name: 'Start Test Server - Linux'
description: 'Start the bruno-tests mock server for OAuth1 CLI tests on Linux'
runs:
using: 'composite'
steps:
- name: Start test server
shell: bash
run: |
set -euo pipefail
cd packages/bruno-tests
echo "starting test server in background"
node src/index.js &
echo "server started with PID: $!"

View File

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

View File

@@ -1,30 +0,0 @@
name: 'Run OAuth1 CLI Tests - macOS'
description: 'Run OAuth1 CLI tests on macOS'
runs:
using: 'composite'
steps:
- name: Run BRU format CLI tests
shell: bash
run: |
set -euo pipefail
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
# navigate to BRU test collection directory
cd tests/auth/oauth1/fixtures/collections/bru
echo "=== BRU Format Collection Run ==="
node $BRU_CLI run --env Local --output junit-bru.xml --format junit
- name: Run YML format CLI tests
shell: bash
run: |
set -euo pipefail
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
# navigate to YML test collection directory
cd tests/auth/oauth1/fixtures/collections/yml
echo "=== YML Format Collection Run ==="
node $BRU_CLI run --env Local --output junit-yml.xml --format junit

View File

@@ -1,16 +0,0 @@
name: 'Start Test Server - macOS'
description: 'Start the bruno-tests mock server for OAuth1 CLI tests on macOS'
runs:
using: 'composite'
steps:
- name: Start test server
shell: bash
run: |
set -euo pipefail
cd packages/bruno-tests
echo "starting test server in background"
node src/index.js &
echo "server started with PID: $!"

View File

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

View File

@@ -1,34 +0,0 @@
name: 'Run OAuth1 CLI Tests - Windows'
description: 'Run OAuth1 CLI tests on Windows'
runs:
using: 'composite'
steps:
- name: Run BRU format CLI tests
shell: pwsh
run: |
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$BRU_CLI = "..\..\..\..\..\..\packages\bruno-cli\bin\bru.js"
# navigate to BRU test collection directory
Set-Location tests\auth\oauth1\fixtures\collections\bru
Write-Host "=== BRU Format Collection Run ==="
$process = Start-Process -FilePath "node" -ArgumentList "$BRU_CLI run --env Local --output junit-bru.xml --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
if ($process.ExitCode -ne 0) { exit 1 }
- name: Run YML format CLI tests
shell: pwsh
run: |
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$BRU_CLI = "..\..\..\..\..\..\packages\bruno-cli\bin\bru.js"
# navigate to YML test collection directory
Set-Location tests\auth\oauth1\fixtures\collections\yml
Write-Host "=== YML Format Collection Run ==="
$process = Start-Process -FilePath "node" -ArgumentList "$BRU_CLI run --env Local --output junit-yml.xml --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
if ($process.ExitCode -ne 0) { exit 1 }

View File

@@ -1,14 +0,0 @@
name: 'Start Test Server - Windows'
description: 'Start the bruno-tests mock server for OAuth1 CLI tests on Windows'
runs:
using: 'composite'
steps:
- name: Start test server
shell: pwsh
run: |
Set-StrictMode -Version Latest
Set-Location packages\bruno-tests
Write-Host "starting test server in background"
Start-Process -FilePath "node" -ArgumentList "src\index.js" -PassThru -WindowStyle Hidden

View File

@@ -1,10 +1,5 @@
name: 'Setup Node Dependencies'
description: 'Install Node.js and npm dependencies'
inputs:
skip-build:
description: 'Skip building libraries'
required: false
default: 'false'
runs:
using: 'composite'
steps:
@@ -14,13 +9,12 @@ runs:
node-version: v22.17.0
cache: 'npm'
cache-dependency-path: './package-lock.json'
- name: Install node dependencies
shell: bash
run: npm ci --legacy-peer-deps
- name: Build libraries
if: inputs.skip-build != 'true'
shell: bash
run: |
npm run build:graphql-docs

View File

@@ -1,20 +0,0 @@
name: 'Run CLI Tests'
description: 'Setup dependencies, start local testbench and run CLI tests'
runs:
using: 'composite'
steps:
- name: Run Local Testbench
shell: bash
run: |
npm start --workspace=packages/bruno-tests &
sleep 5
- name: Install Test Collection Dependencies
shell: bash
run: npm ci --prefix packages/bruno-tests/collection
- name: Run CLI Tests
shell: bash
run: |
cd packages/bruno-tests/collection
node ../../bruno-cli/bin/bru.js run --env Prod --output junit.xml --format junit --sandbox developer

View File

@@ -1,22 +0,0 @@
name: 'Run E2E Tests'
description: 'Setup dependencies, configure environment, and run Playwright E2E tests'
inputs:
os:
description: 'Operating system (ubuntu, macos, windows)'
default: 'ubuntu'
runs:
using: 'composite'
steps:
- name: Install Test Collection Dependencies
shell: bash
run: npm ci --prefix packages/bruno-tests/collection
- name: Run Playwright Tests (Ubuntu)
if: inputs.os == 'ubuntu'
shell: bash
run: xvfb-run npm run test:e2e
- name: Run Playwright Tests
if: inputs.os != 'ubuntu'
shell: bash
run: npm run test:e2e

View File

@@ -1,48 +0,0 @@
name: 'Run Unit Tests'
description: 'Setup dependencies and run unit tests for all packages'
runs:
using: 'composite'
steps:
- name: Test Package bruno-js
shell: bash
run: npm run test --workspace=packages/bruno-js
- name: Test Package bruno-cli
shell: bash
run: npm run test --workspace=packages/bruno-cli
- name: Test Package bruno-query
shell: bash
run: npm run test --workspace=packages/bruno-query
- name: Test Package bruno-lang
shell: bash
run: npm run test --workspace=packages/bruno-lang
- name: Test Package bruno-schema
shell: bash
run: npm run test --workspace=packages/bruno-schema
- name: Test Package bruno-app
shell: bash
run: npm run test --workspace=packages/bruno-app
- name: Test Package bruno-common
shell: bash
run: npm run test --workspace=packages/bruno-common
- name: Test Package bruno-converters
shell: bash
run: npm run test --workspace=packages/bruno-converters
- name: Test Package bruno-electron
shell: bash
run: npm run test --workspace=packages/bruno-electron
- name: Test Package bruno-requests
shell: bash
run: npm run test --workspace=packages/bruno-requests
- name: Test Package bruno-filestore
shell: bash
run: npm run test --workspace=packages/bruno-filestore

View File

@@ -1,70 +0,0 @@
const fs = require('fs');
const { execSync } = require('child_process');
// Check if flaky-tests.json exists
if (!fs.existsSync('flaky-tests.json')) {
console.log('No flaky-tests.json found');
process.exit(0);
}
// Get changed files in PR
let changedFiles = [];
try {
changedFiles = execSync('git diff --name-only origin/main...HEAD')
.toString()
.split('\n')
.filter(f => f.endsWith('.spec.ts'));
} catch (error) {
console.log('Could not determine changed files:', error.message);
process.exit(0);
}
if (changedFiles.length === 0) {
console.log('No test files were modified in this PR');
process.exit(0);
}
// Read flaky tests
const flakyTests = JSON.parse(fs.readFileSync('flaky-tests.json', 'utf8'));
if (flakyTests.length === 0) {
console.log('No flaky/failed tests found');
process.exit(0);
}
// Find modified flaky tests
const modifiedFlakyTests = flakyTests.filter(test =>
changedFiles.some(file => test.file.includes(file))
);
if (modifiedFlakyTests.length === 0) {
console.log('No modified test files are flaky');
process.exit(0);
}
// Generate comment markdown
let comment = '## ⚠️ Warning: You modified flaky/failed test files\n\n';
comment += 'The following test files you modified have reliability issues:\n\n';
modifiedFlakyTests.forEach(test => {
const testType = test.status === 'failed' ? '❌ Failed' : '⚠️ Flaky';
comment += `### ${testType}: \`${test.file}\`\n`;
comment += `**Test:** ${test.testTitle}\n`;
comment += `**Status:** ${test.status}\n`;
if (test.retryAttempt > 0) {
comment += `**Retry Attempt:** ${test.retryAttempt}\n`;
}
comment += '\n**To debug locally, run:**\n';
comment += '```bash\n';
comment += `npx playwright test ${test.file} --repeat-each=5 --workers=1\n`;
comment += '```\n\n';
});
comment += '---\n';
comment += '**Note:** Flaky tests passed after retrying, failed tests did not pass. ';
comment += 'Please investigate and fix the root cause before merging.\n';
// Save comment to file for GitHub Action to post
fs.writeFileSync('pr-comment.md', comment);
console.log(`Found ${modifiedFlakyTests.length} modified flaky tests`);

View File

@@ -1,78 +0,0 @@
const fs = require('fs');
// Read Playwright JSON report
const resultsPath = 'playwright-report/results.json';
if (!fs.existsSync(resultsPath)) {
console.log('No Playwright results found at', resultsPath);
process.exit(0);
}
const results = JSON.parse(fs.readFileSync(resultsPath, 'utf8'));
// Extract flaky tests
// A test is flaky if: status === "passed" AND retry > 0
// A test is failed if: status === "failed"
// This means it failed initially but passed on retry OR failed completely
const flakyTests = [];
function traverseSuites(suites) {
for (const suite of suites) {
// Process specs in this suite
for (const spec of suite.specs || []) {
for (const test of spec.tests || []) {
// Check each test result
for (const result of test.results || []) {
// Track two types of problematic tests:
// 1. Flaky: passed on a retry attempt (retry > 0)
// 2. Failed: failed on all attempts
if ((result.status === 'passed' && result.retry > 0) || result.status === 'failed') {
flakyTests.push({
file: spec.file,
title: spec.title,
testTitle: spec.title,
line: spec.line,
status: result.status,
retryAttempt: result.retry
});
break; // Only record once per test
}
}
}
}
// Recursively process nested suites
if (suite.suites && suite.suites.length > 0) {
traverseSuites(suite.suites);
}
}
}
traverseSuites(results.suites || []);
// Save flaky tests to JSON
fs.writeFileSync('flaky-tests.json', JSON.stringify(flakyTests, null, 2));
// Generate markdown report
let markdown = '## ⚠️ Flaky/Failed Tests Detected\n\n';
markdown += 'The following tests are problematic:\n\n';
flakyTests.forEach(test => {
const testType = test.status === 'failed' ? '❌ Failed' : '⚠️ Flaky';
markdown += `### ${testType}: \`${test.file}\`\n`;
markdown += `- **Test:** ${test.testTitle}\n`;
markdown += `- **Status:** ${test.status}\n`;
if (test.retryAttempt > 0) {
markdown += `- **Retry Attempt:** ${test.retryAttempt}\n`;
}
markdown += `- **Debug command:**\n`;
markdown += '```bash\n';
markdown += `npx playwright test ${test.file} --repeat-each=5 --workers=1\n`;
markdown += '```\n\n';
});
fs.writeFileSync('flaky-report.md', markdown);
console.log(`Found ${flakyTests.length} flaky/failed tests`);
process.exit(flakyTests.length > 0 ? 1 : 0);

View File

@@ -1,79 +0,0 @@
name: Auth Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
oauth1-tests-for-linux:
name: OAuth 1.0 Auth Tests - Linux
timeout-minutes: 60
runs-on: ubuntu-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Setup Feature Dependencies
uses: ./.github/actions/auth/oauth1/linux/setup-feature-specific-deps
- name: Run Auth E2E Tests
uses: ./.github/actions/auth/oauth1/linux/run-auth-e2e-tests
- name: Start Test Server
uses: ./.github/actions/auth/oauth1/linux/start-test-server
- name: Run OAuth1 CLI Tests
uses: ./.github/actions/auth/oauth1/linux/run-oauth1-cli-tests
oauth1-tests-for-macos:
name: OAuth 1.0 Auth Tests - macOS
timeout-minutes: 60
runs-on: macos-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Run Auth E2E Tests
uses: ./.github/actions/auth/oauth1/macos/run-auth-e2e-tests
- name: Start Test Server
uses: ./.github/actions/auth/oauth1/macos/start-test-server
- name: Run OAuth1 CLI Tests
uses: ./.github/actions/auth/oauth1/macos/run-oauth1-cli-tests
oauth1-tests-for-windows:
name: OAuth 1.0 Auth Tests - Windows
timeout-minutes: 60
runs-on: windows-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Run Auth E2E Tests
uses: ./.github/actions/auth/oauth1/windows/run-auth-e2e-tests
- name: Start Test Server
uses: ./.github/actions/auth/oauth1/windows/start-test-server
- name: Run OAuth1 CLI Tests
uses: ./.github/actions/auth/oauth1/windows/run-oauth1-cli-tests

View File

@@ -1,120 +0,0 @@
name: Flaky Test Detector
on:
pull_request:
branches: [main]
paths:
- 'tests/**/*.spec.ts'
permissions:
contents: read
pull-requests: write
issues: write
checks: write
jobs:
detect-flaky-tests:
name: Detect Flaky Tests
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0 # Need full history to compare with main
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: Install system dependencies
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: Install npm dependencies
run: |
npm ci --legacy-peer-deps
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
- name: Install test collection dependencies
run: npm ci --prefix packages/bruno-tests/collection
- name: Build libraries
run: |
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build:bruno-converters
npm run build:bruno-requests
npm run build:schema-types
npm run build:bruno-filestore
- name: Run Playwright tests
run: xvfb-run npm run test:e2e
continue-on-error: true # Continue even if tests fail
- name: Detect flaky tests
id: detect
run: node .github/scripts/detect-flaky-tests.js
continue-on-error: true # Don't fail workflow if flaky tests found
- name: Check modified flaky tests
id: check-modified
run: node .github/scripts/comment-on-flaky-tests.js
continue-on-error: true
- name: Post PR comment
if: hashFiles('pr-comment.md') != ''
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const comment = fs.readFileSync('pr-comment.md', 'utf8');
// Check if we already commented
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number
});
const botComment = comments.find(c =>
c.user.type === 'Bot' && c.body.includes('Warning: You modified flaky/failed test files')
);
if (botComment) {
// Update existing comment
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: comment
});
} else {
// Create new comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: comment
});
}
- name: Upload flaky test artifacts
if: always()
uses: actions/upload-artifact@v6
with:
name: flaky-test-results
path: |
flaky-tests.json
flaky-report.md
playwright-report/
retention-days: 30

View File

@@ -1,26 +0,0 @@
name: Lint Checks
on:
workflow_dispatch:
push:
branches: [main, 'release/v*']
pull_request:
branches: [main, 'release/v*']
jobs:
lint:
name: Lint Check
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
with:
skip-build: 'true'
- name: Lint Check
run: npm run lint
env:
ESLINT_PLUGIN_DIFF_COMMIT: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || 'main' }}

View File

@@ -43,7 +43,7 @@ jobs:
bru run --env Prod --output junit.xml --format junit --sandbox developer
- name: Publish Test Report
uses: dorny/test-reporter@v3
uses: dorny/test-reporter@v2
if: success() || failure()
with:
name: Test Report

View File

@@ -1,10 +1,9 @@
name: Tests
on:
workflow_dispatch:
push:
branches: [main, 'release/v*']
branches: [main]
pull_request:
branches: [main, 'release/v*']
branches: [main]
jobs:
unit-test:
@@ -15,12 +14,52 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v5
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: './package-lock.json'
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
# build libraries
- name: Build libraries
run: |
npm run build --workspace=packages/bruno-common
npm run build --workspace=packages/bruno-query
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build --workspace=packages/bruno-converters
npm run build --workspace=packages/bruno-requests
npm run build --workspace=packages/bruno-schema-types
npm run build --workspace=packages/bruno-filestore
- name: Run Unit Tests
uses: ./.github/actions/tests/run-unit-tests
- name: Lint Check
run: npm run lint
env:
ESLINT_PLUGIN_DIFF_COMMIT: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || 'main' }}
# tests
- name: Test Package bruno-js
run: npm run test --workspace=packages/bruno-js
- name: Test Package bruno-cli
run: npm run test --workspace=packages/bruno-cli
- name: Test Package bruno-query
run: npm run test --workspace=packages/bruno-query
- name: Test Package bruno-lang
run: npm run test --workspace=packages/bruno-lang
- name: Test Package bruno-schema
run: npm run test --workspace=packages/bruno-schema
- name: Test Package bruno-app
run: npm run test --workspace=packages/bruno-app
- name: Test Package bruno-common
run: npm run test --workspace=packages/bruno-common
- name: Test Package bruno-converters
run: npm run test --workspace=packages/bruno-converters
- name: Test Package bruno-electron
run: npm run test --workspace=packages/bruno-electron
- name: Test Package bruno-requests
run: npm run test --workspace=packages/bruno-requests
cli-test:
name: CLI Tests
@@ -31,12 +70,35 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v5
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: './package-lock.json'
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Run CLI Tests
uses: ./.github/actions/tests/run-cli-tests
- name: Build Libraries
run: |
npm run build --workspace=packages/bruno-query
npm run build --workspace=packages/bruno-common
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build --workspace=packages/bruno-converters
npm run build --workspace=packages/bruno-requests
npm run build --workspace=packages/bruno-schema-types
npm run build --workspace=packages/bruno-filestore
- name: Run Local Testbench
run: |
npm start --workspace=packages/bruno-tests &
sleep 5
- name: Run tests
run: |
cd packages/bruno-tests/collection
npm install
node ../../bruno-cli/bin/bru.js run --env Prod --output junit.xml --format junit --sandbox developer
- name: Publish Test Report
uses: EnricoMi/publish-unit-test-result-action@v2
@@ -45,38 +107,46 @@ jobs:
check_name: CLI Test Results
files: packages/bruno-tests/collection/junit.xml
comment_mode: always
e2e-test:
name: Playwright E2E Tests
timeout-minutes: 60
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v6
- uses: actions/setup-node@v5
with:
node-version: v22.11.x
- name: Install dependencies
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
npm ci --legacy-peer-deps
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
- name: Install System Dependencies (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: Install dependencies for test collection environment
run: |
npm ci --prefix packages/bruno-tests/collection
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Build libraries
run: |
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build:bruno-converters
npm run build:bruno-requests
npm run build:schema-types
npm run build:bruno-filestore
- name: Configure Chrome Sandbox
run: |
sudo chown root node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 node_modules/electron/dist/chrome-sandbox
- name: Run playwright Tests
uses: ./.github/actions/tests/run-e2e-tests
with:
os: ubuntu
- name: Upload Playwright Report
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30
- name: Run Playwright tests
run: |
xvfb-run npm run test:e2e
- uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30

8
.gitignore vendored
View File

@@ -49,22 +49,16 @@ bruno.iml
.idea
.vscode
.cursor
.claude
.codex
.agents
.agent
skills-lock.json
# Playwright
/blob-report/
# Development plan files
CLAUDE.md
AGENTS.md
*.plan.md
# packages dist
packages/bruno-filestore/dist
packages/bruno-requests/dist
packages/bruno-schema-types/dist
packages/bruno-converters/dist
packages/bruno-converters/dist

1
.npmrc
View File

@@ -1 +0,0 @@
min-release-age=10

7
.prettierrc.json Normal file
View File

@@ -0,0 +1,7 @@
{
"trailingComma": "none",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"printWidth": 120
}

View File

@@ -66,18 +66,7 @@ Remember, these rules are here to make our codebase harmonious. If something doe
- Use styled component's theme prop to manage CSS colors and not CSS variables when in the context of a styled component or any react component using the styled component
- Styled Components are used as wrappers to define both self and children components style, tailwind classes are used specifically for layout based styles.
- Styled Component CSS might also change layout but tailwind classes shouldn't define colors.
- MUST: Prefer custom hooks for business logic, data fetching, and side-effects.
- MUST: Avoid `useEffect` unless absolutely needed. Prefer derived state, event handlers.
- SHOULD: Memoize only when necessary (`useMemo`/`useCallback`), and prefer moving logic into hooks first.
- MUST: Do not use namespace access for hooks in app code (e.g., `React.useCallback`, `React.useMemo`, `React.useState`). Import hooks directly.
- Correct: `import { useCallback, useMemo, useState } from "react";`
- Avoid: `import * as React from "react";` then `React.useCallback(...)`
- Add `data-testid` to testable elements for Playwright
- Co-locate utilities that are truly component-specific next to the component, otherwise place shared items under a common folder
- Avoid mixed controlled and uncontrolled state in React components. A component is either controlled or uncontrolled. State needs a single source of truth instead of being computed by props and then recomputed internally.
- SHOULD: Use derived state variables instead of adding unneeded `React.useState` / `useState` hooks.
- Styled Component CSS might also change layout but tailwind classes shouldn't define colors.
## Readability and Abstractions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 615 KiB

After

Width:  |  Height:  |  Size: 153 KiB

View File

@@ -324,7 +324,7 @@ test('should create and execute HTTP request', async ({ page, createTmpDir }) =>
await page.getByRole('button', { name: 'Create' }).click();
// Execute request
await page.getByTestId('send-arrow-icon').click();
await page.locator('#send-request').getByRole('img').nth(2).click();
// Verify response
await expect(page.getByRole('main')).toContainText('200 OK');

View File

@@ -178,8 +178,7 @@ module.exports = runESMImports().then(() => defineConfig([
}
},
rules: {
'no-undef': 'error',
'no-case-declarations': 'error'
'no-undef': 'error'
}
},
{

11787
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@opencollection/types": "0.9.1",
"@opencollection/types": "~0.7.0",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
@@ -36,8 +36,7 @@
"@types/node": "^22.14.1",
"@typescript-eslint/parser": "^8.39.0",
"concurrently": "^8.2.2",
"cross-env": "10.1.0",
"eslint": "^9.39.4",
"eslint": "^9.26.0",
"eslint-plugin-diff": "^2.0.3",
"fs-extra": "^11.1.1",
"globals": "^16.1.0",
@@ -60,6 +59,7 @@
"dev:watch": "node ./scripts/dev-hot-reload.js",
"dev:web": "npm run dev --workspace=packages/bruno-app",
"build:web": "npm run build --workspace=packages/bruno-app",
"prettier:web": "npm run prettier --workspace=packages/bruno-app",
"dev:electron": "npm run dev --workspace=packages/bruno-electron",
"dev:electron:debug": "npm run debug --workspace=packages/bruno-electron",
"storybook": "npm run storybook --workspace=packages/bruno-app",
@@ -82,9 +82,9 @@
"test:codegen": "node playwright/codegen.ts",
"test:e2e": "playwright test --project=default",
"test:e2e:ssl": "playwright test --project=ssl",
"test:e2e:auth": "playwright test --project=auth",
"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",
"test:prettier:web": "npm run test:prettier --workspace=packages/bruno-app",
"lint": "node --max_old_space_size=4096 $(npx which eslint)",
"lint:fix": "node --max_old_space_size=4096 $(npx which eslint) --fix",
"prepare": "husky"
},
"nano-staged": {
@@ -93,9 +93,7 @@
]
},
"overrides": {
"axios":"1.13.6",
"rollup": "3.30.0",
"pbkdf2":"3.1.5",
"rollup": "3.29.5",
"electron-store": {
"conf": {
"json-schema-typed": "8.0.1"
@@ -103,7 +101,6 @@
}
},
"dependencies": {
"ajv": "^8.17.1",
"git-url-parse": "^14.1.0"
"ajv": "^8.17.1"
}
}

View File

@@ -1,4 +1,4 @@
{
"presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"],
"presets": ["@babel/preset-env", "@babel/preset-react"],
"plugins": [["styled-components", { "ssr": true }]]
}

View File

@@ -0,0 +1,7 @@
{
"trailingComma": "none",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"printWidth": 120
}

View File

@@ -8,6 +8,8 @@
"build": "rsbuild build -m production",
"preview": "rsbuild preview",
"test": "jest",
"test:prettier": "prettier --check \"./src/**/*.{js,jsx,json,ts,tsx}\"",
"prettier": "prettier --write \"./src/**/*.{js,jsx,json,ts,tsx}\"",
"storybook": "storybook dev -p 6006 --config-dir storybook",
"build-storybook": "storybook build --config-dir storybook"
},
@@ -27,7 +29,6 @@
"codemirror": "5.65.2",
"codemirror-graphql": "2.1.1",
"cookie": "0.7.1",
"diff2html": "^3.4.47",
"dompurify": "^3.2.4",
"escape-html": "^1.0.3",
"fast-fuzzy": "^1.12.0",
@@ -39,7 +40,7 @@
"github-markdown-css": "^5.2.0",
"graphiql": "3.7.1",
"graphql": "^16.6.0",
"graphql-request": "4.2.0",
"graphql-request": "^3.7.0",
"hexy": "^0.3.5",
"httpsnippet": "^3.0.9",
"i18next": "24.1.2",
@@ -89,7 +90,7 @@
"shell-quote": "^1.8.3",
"strip-json-comments": "^5.0.1",
"styled-components": "^5.3.3",
"swagger-ui-react": "^5.31.0",
"swagger-ui-react": "5.17.12",
"system": "^2.0.1",
"url": "^0.11.3",
"xml-formatter": "^3.5.0",
@@ -100,10 +101,9 @@
"@babel/core": "^7.27.1",
"@babel/preset-env": "^7.27.2",
"@babel/preset-react": "^7.27.1",
"@babel/preset-typescript": "^7.22.0",
"@rsbuild/core": "^1.1.2",
"@rsbuild/plugin-babel": "^1.0.3",
"@rsbuild/plugin-node-polyfill": "1.2.0",
"@rsbuild/plugin-node-polyfill": "^1.2.0",
"@rsbuild/plugin-react": "^1.0.7",
"@rsbuild/plugin-sass": "^1.1.0",
"@rsbuild/plugin-styled-components": "1.1.0",

File diff suppressed because it is too large Load Diff

View File

@@ -1,713 +0,0 @@
:host,
:root {
--d2h-bg-color: #fff;
--d2h-border-color: #ddd;
--d2h-dim-color: rgba(0, 0, 0, 0.3);
--d2h-line-border-color: #eee;
--d2h-file-header-bg-color: #f7f7f7;
--d2h-file-header-border-color: #d8d8d8;
--d2h-empty-placeholder-bg-color: #f1f1f1;
--d2h-empty-placeholder-border-color: #e1e1e1;
--d2h-selected-color: #c8e1ff;
--d2h-ins-bg-color: #dfd;
--d2h-ins-border-color: #b4e2b4;
--d2h-ins-highlight-bg-color: #97f295;
--d2h-ins-label-color: #399839;
--d2h-del-bg-color: #fee8e9;
--d2h-del-border-color: #e9aeae;
--d2h-del-highlight-bg-color: #ffb6ba;
--d2h-del-label-color: #c33;
--d2h-change-del-color: #fdf2d0;
--d2h-change-ins-color: #ded;
--d2h-info-bg-color: #f8fafd;
--d2h-info-border-color: #d5e4f2;
--d2h-change-label-color: #d0b44c;
--d2h-moved-label-color: #3572b0;
--d2h-dark-color: #e6edf3;
--d2h-dark-bg-color: #0d1117;
--d2h-dark-border-color: #30363d;
--d2h-dark-dim-color: #6e7681;
--d2h-dark-line-border-color: #21262d;
--d2h-dark-file-header-bg-color: #161b22;
--d2h-dark-file-header-border-color: #30363d;
--d2h-dark-empty-placeholder-bg-color: hsla(215, 8%, 47%, 0.1);
--d2h-dark-empty-placeholder-border-color: #30363d;
--d2h-dark-selected-color: rgba(56, 139, 253, 0.1);
--d2h-dark-ins-bg-color: rgba(46, 160, 67, 0.15);
--d2h-dark-ins-border-color: rgba(46, 160, 67, 0.4);
--d2h-dark-ins-highlight-bg-color: rgba(46, 160, 67, 0.4);
--d2h-dark-ins-label-color: #3fb950;
--d2h-dark-del-bg-color: rgba(248, 81, 73, 0.1);
--d2h-dark-del-border-color: rgba(248, 81, 73, 0.4);
--d2h-dark-del-highlight-bg-color: rgba(248, 81, 73, 0.4);
--d2h-dark-del-label-color: #f85149;
--d2h-dark-change-del-color: rgba(210, 153, 34, 0.2);
--d2h-dark-change-ins-color: rgba(46, 160, 67, 0.25);
--d2h-dark-info-bg-color: rgba(56, 139, 253, 0.1);
--d2h-dark-info-border-color: rgba(56, 139, 253, 0.4);
--d2h-dark-change-label-color: #d29922;
--d2h-dark-moved-label-color: #3572b0;
}
.d2h-wrapper {
text-align: left;
}
.d2h-file-header {
background-color: #f7f7f7;
background-color: var(--d2h-file-header-bg-color);
border-bottom: 1px solid #d8d8d8;
border-bottom: 1px solid var(--d2h-file-header-border-color);
display: -webkit-box;
display: -ms-flexbox;
display: flex;
font-family: Source Sans Pro, Helvetica Neue, Helvetica, Arial, sans-serif;
height: 35px;
padding: 5px 10px;
}
.d2h-file-header.d2h-sticky-header {
position: sticky;
top: 0;
z-index: 1;
}
.d2h-file-stats {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
font-size: 14px;
margin-left: auto;
}
.d2h-lines-added {
border: 1px solid #b4e2b4;
border: 1px solid var(--d2h-ins-border-color);
border-radius: 5px 0 0 5px;
color: #399839;
color: var(--d2h-ins-label-color);
padding: 2px;
text-align: right;
vertical-align: middle;
}
.d2h-lines-deleted {
border: 1px solid #e9aeae;
border: 1px solid var(--d2h-del-border-color);
border-radius: 0 5px 5px 0;
color: #c33;
color: var(--d2h-del-label-color);
margin-left: 1px;
padding: 2px;
text-align: left;
vertical-align: middle;
}
.d2h-file-name-wrapper {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
font-size: 15px;
width: 100%;
}
.d2h-file-name {
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.d2h-file-wrapper {
border: 1px solid #ddd;
border: 1px solid var(--d2h-border-color);
border-radius: 3px;
margin-bottom: 1em;
}
.d2h-file-collapse {
-webkit-box-pack: end;
-ms-flex-pack: end;
cursor: pointer;
display: none;
font-size: 12px;
justify-content: flex-end;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
border: 1px solid #ddd;
border: 1px solid var(--d2h-border-color);
border-radius: 3px;
padding: 4px 8px;
}
.d2h-file-collapse.d2h-selected {
background-color: #c8e1ff;
background-color: var(--d2h-selected-color);
}
.d2h-file-collapse-input {
margin: 0 4px 0 0;
}
.d2h-diff-table {
border-collapse: collapse;
font-family: Menlo, Consolas, monospace;
font-size: 13px;
width: 100%;
}
.d2h-files-diff {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
width: 100%;
}
.d2h-file-diff {
overflow-y: hidden;
}
.d2h-file-diff.d2h-d-none,
.d2h-files-diff.d2h-d-none {
display: none;
}
.d2h-file-side-diff {
display: inline-block;
overflow-x: scroll;
overflow-y: hidden;
width: 50%;
}
.d2h-code-line {
padding: 0 8em;
width: calc(100% - 16em);
}
.d2h-code-line,
.d2h-code-side-line {
display: inline-block;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
white-space: nowrap;
}
.d2h-code-side-line {
padding: 0 4.5em;
width: calc(100% - 9em);
}
.d2h-code-line-ctn {
background: none;
display: inline-block;
padding: 0;
word-wrap: normal;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
vertical-align: middle;
white-space: pre;
width: 100%;
}
.d2h-code-line del,
.d2h-code-side-line del {
background-color: #ffb6ba;
background-color: var(--d2h-del-highlight-bg-color);
}
.d2h-code-line del,
.d2h-code-line ins,
.d2h-code-side-line del,
.d2h-code-side-line ins {
border-radius: 0.2em;
display: inline-block;
margin-top: -1px;
-webkit-text-decoration: none;
text-decoration: none;
}
.d2h-code-line ins,
.d2h-code-side-line ins {
background-color: #97f295;
background-color: var(--d2h-ins-highlight-bg-color);
text-align: left;
}
.d2h-code-line-prefix {
background: none;
display: inline;
padding: 0;
word-wrap: normal;
white-space: pre;
}
.line-num1 {
float: left;
}
.line-num1,
.line-num2 {
-webkit-box-sizing: border-box;
box-sizing: border-box;
overflow: hidden;
padding: 0 0.5em;
text-overflow: ellipsis;
width: 3.5em;
}
.line-num2 {
float: right;
}
.d2h-code-linenumber {
background-color: #fff;
background-color: var(--d2h-bg-color);
border: solid #eee;
border: solid var(--d2h-line-border-color);
border-width: 0 1px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: rgba(0, 0, 0, 0.3);
color: var(--d2h-dim-color);
cursor: pointer;
display: inline-block;
position: absolute;
text-align: right;
width: 7.5em;
}
.d2h-code-linenumber:after {
content: '\200b';
}
.d2h-code-side-linenumber {
background-color: #fff;
background-color: var(--d2h-bg-color);
border: solid #eee;
border: solid var(--d2h-line-border-color);
border-width: 0 1px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: rgba(0, 0, 0, 0.3);
color: var(--d2h-dim-color);
cursor: pointer;
display: inline-block;
overflow: hidden;
padding: 0 0.5em;
position: absolute;
text-align: right;
text-overflow: ellipsis;
width: 4em;
}
.d2h-code-side-linenumber:after {
content: '\200b';
}
.d2h-code-side-emptyplaceholder,
.d2h-emptyplaceholder {
background-color: #f1f1f1;
background-color: var(--d2h-empty-placeholder-bg-color);
border-color: #e1e1e1;
border-color: var(--d2h-empty-placeholder-border-color);
}
.d2h-code-line-prefix,
.d2h-code-linenumber,
.d2h-code-side-linenumber,
.d2h-emptyplaceholder {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.d2h-code-linenumber,
.d2h-code-side-linenumber {
direction: rtl;
}
.d2h-del {
background-color: #fee8e9;
background-color: var(--d2h-del-bg-color);
border-color: #e9aeae;
border-color: var(--d2h-del-border-color);
}
.d2h-ins {
background-color: #dfd;
background-color: var(--d2h-ins-bg-color);
border-color: #b4e2b4;
border-color: var(--d2h-ins-border-color);
}
.d2h-info {
background-color: #f8fafd;
background-color: var(--d2h-info-bg-color);
border-color: #d5e4f2;
border-color: var(--d2h-info-border-color);
color: rgba(0, 0, 0, 0.3);
color: var(--d2h-dim-color);
}
.d2h-file-diff .d2h-del.d2h-change {
background-color: #fdf2d0;
background-color: var(--d2h-change-del-color);
}
.d2h-file-diff .d2h-ins.d2h-change {
background-color: #ded;
background-color: var(--d2h-change-ins-color);
}
.d2h-file-list-wrapper {
margin-bottom: 10px;
}
.d2h-file-list-wrapper a {
-webkit-text-decoration: none;
text-decoration: none;
}
.d2h-file-list-wrapper a,
.d2h-file-list-wrapper a:visited {
color: #3572b0;
color: var(--d2h-moved-label-color);
}
.d2h-file-list-header {
text-align: left;
}
.d2h-file-list-title {
font-weight: 700;
}
.d2h-file-list-line {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
text-align: left;
}
.d2h-file-list {
display: block;
list-style: none;
margin: 0;
padding: 0;
}
.d2h-file-list > li {
border-bottom: 1px solid #ddd;
border-bottom: 1px solid var(--d2h-border-color);
margin: 0;
padding: 5px 10px;
}
.d2h-file-list > li:last-child {
border-bottom: none;
}
.d2h-file-switch {
cursor: pointer;
display: none;
font-size: 10px;
}
.d2h-icon {
margin-right: 10px;
vertical-align: middle;
fill: currentColor;
}
.d2h-deleted {
color: #c33;
color: var(--d2h-del-label-color);
}
.d2h-added {
color: #399839;
color: var(--d2h-ins-label-color);
}
.d2h-changed {
color: #d0b44c;
color: var(--d2h-change-label-color);
}
.d2h-moved {
color: #3572b0;
color: var(--d2h-moved-label-color);
}
.d2h-tag {
background-color: #fff;
background-color: var(--d2h-bg-color);
display: -webkit-box;
display: -ms-flexbox;
display: flex;
font-size: 10px;
margin-left: 5px;
padding: 0 2px;
}
.d2h-deleted-tag {
border: 1px solid #c33;
border: 1px solid var(--d2h-del-label-color);
}
.d2h-added-tag {
border: 1px solid #399839;
border: 1px solid var(--d2h-ins-label-color);
}
.d2h-changed-tag {
border: 1px solid #d0b44c;
border: 1px solid var(--d2h-change-label-color);
}
.d2h-moved-tag {
border: 1px solid #3572b0;
border: 1px solid var(--d2h-moved-label-color);
}
.d2h-dark-color-scheme {
background-color: #0d1117;
background-color: var(--d2h-dark-bg-color);
color: #e6edf3;
color: var(--d2h-dark-color);
}
.d2h-dark-color-scheme .d2h-file-header {
background-color: #161b22;
background-color: var(--d2h-dark-file-header-bg-color);
border-bottom: #30363d;
border-bottom: var(--d2h-dark-file-header-border-color);
}
.d2h-dark-color-scheme .d2h-lines-added {
border: 1px solid rgba(46, 160, 67, 0.4);
border: 1px solid var(--d2h-dark-ins-border-color);
color: #3fb950;
color: var(--d2h-dark-ins-label-color);
}
.d2h-dark-color-scheme .d2h-lines-deleted {
border: 1px solid rgba(248, 81, 73, 0.4);
border: 1px solid var(--d2h-dark-del-border-color);
color: #f85149;
color: var(--d2h-dark-del-label-color);
}
.d2h-dark-color-scheme .d2h-code-line del,
.d2h-dark-color-scheme .d2h-code-side-line del {
background-color: rgba(248, 81, 73, 0.4);
background-color: var(--d2h-dark-del-highlight-bg-color);
}
.d2h-dark-color-scheme .d2h-code-line ins,
.d2h-dark-color-scheme .d2h-code-side-line ins {
background-color: rgba(46, 160, 67, 0.4);
background-color: var(--d2h-dark-ins-highlight-bg-color);
}
.d2h-dark-color-scheme .d2h-diff-tbody {
border-color: #30363d;
border-color: var(--d2h-dark-border-color);
}
.d2h-dark-color-scheme .d2h-code-side-linenumber {
background-color: #0d1117;
background-color: var(--d2h-dark-bg-color);
border-color: #21262d;
border-color: var(--d2h-dark-line-border-color);
color: #6e7681;
color: var(--d2h-dark-dim-color);
}
.d2h-dark-color-scheme .d2h-files-diff .d2h-code-side-emptyplaceholder,
.d2h-dark-color-scheme .d2h-files-diff .d2h-emptyplaceholder {
background-color: hsla(215, 8%, 47%, 0.1);
background-color: var(--d2h-dark-empty-placeholder-bg-color);
border-color: #30363d;
border-color: var(--d2h-dark-empty-placeholder-border-color);
}
.d2h-dark-color-scheme .d2h-code-linenumber {
background-color: #0d1117;
background-color: var(--d2h-dark-bg-color);
border-color: #21262d;
border-color: var(--d2h-dark-line-border-color);
color: #6e7681;
color: var(--d2h-dark-dim-color);
}
.d2h-dark-color-scheme .d2h-del {
background-color: rgba(248, 81, 73, 0.1);
background-color: var(--d2h-dark-del-bg-color);
border-color: rgba(248, 81, 73, 0.4);
border-color: var(--d2h-dark-del-border-color);
}
.d2h-dark-color-scheme .d2h-ins {
background-color: rgba(46, 160, 67, 0.15);
background-color: var(--d2h-dark-ins-bg-color);
border-color: rgba(46, 160, 67, 0.4);
border-color: var(--d2h-dark-ins-border-color);
}
.d2h-dark-color-scheme .d2h-info {
background-color: rgba(56, 139, 253, 0.1);
background-color: var(--d2h-dark-info-bg-color);
border-color: rgba(56, 139, 253, 0.4);
border-color: var(--d2h-dark-info-border-color);
color: #6e7681;
color: var(--d2h-dark-dim-color);
}
.d2h-dark-color-scheme .d2h-file-diff .d2h-del.d2h-change {
background-color: rgba(210, 153, 34, 0.2);
background-color: var(--d2h-dark-change-del-color);
}
.d2h-dark-color-scheme .d2h-file-diff .d2h-ins.d2h-change {
background-color: rgba(46, 160, 67, 0.25);
background-color: var(--d2h-dark-change-ins-color);
}
.d2h-dark-color-scheme .d2h-file-wrapper {
border: 1px solid #30363d;
border: 1px solid var(--d2h-dark-border-color);
}
.d2h-dark-color-scheme .d2h-file-collapse {
border: 1px solid #0d1117;
border: 1px solid var(--d2h-dark-bg-color);
}
.d2h-dark-color-scheme .d2h-file-collapse.d2h-selected {
background-color: rgba(56, 139, 253, 0.1);
background-color: var(--d2h-dark-selected-color);
}
.d2h-dark-color-scheme .d2h-file-list-wrapper a,
.d2h-dark-color-scheme .d2h-file-list-wrapper a:visited {
color: #3572b0;
color: var(--d2h-dark-moved-label-color);
}
.d2h-dark-color-scheme .d2h-file-list > li {
border-bottom: 1px solid #0d1117;
border-bottom: 1px solid var(--d2h-dark-bg-color);
}
.d2h-dark-color-scheme .d2h-deleted {
color: #f85149;
color: var(--d2h-dark-del-label-color);
}
.d2h-dark-color-scheme .d2h-added {
color: #3fb950;
color: var(--d2h-dark-ins-label-color);
}
.d2h-dark-color-scheme .d2h-changed {
color: #d29922;
color: var(--d2h-dark-change-label-color);
}
.d2h-dark-color-scheme .d2h-moved {
color: #3572b0;
color: var(--d2h-dark-moved-label-color);
}
.d2h-dark-color-scheme .d2h-tag {
background-color: #0d1117;
background-color: var(--d2h-dark-bg-color);
}
.d2h-dark-color-scheme .d2h-deleted-tag {
border: 1px solid #f85149;
border: 1px solid var(--d2h-dark-del-label-color);
}
.d2h-dark-color-scheme .d2h-added-tag {
border: 1px solid #3fb950;
border: 1px solid var(--d2h-dark-ins-label-color);
}
.d2h-dark-color-scheme .d2h-changed-tag {
border: 1px solid #d29922;
border: 1px solid var(--d2h-dark-change-label-color);
}
.d2h-dark-color-scheme .d2h-moved-tag {
border: 1px solid #3572b0;
border: 1px solid var(--d2h-dark-moved-label-color);
}
@media (prefers-color-scheme: dark) {
.d2h-auto-color-scheme {
background-color: #0d1117;
background-color: var(--d2h-dark-bg-color);
color: #e6edf3;
color: var(--d2h-dark-color);
}
.d2h-auto-color-scheme .d2h-file-header {
background-color: #161b22;
background-color: var(--d2h-dark-file-header-bg-color);
border-bottom: #30363d;
border-bottom: var(--d2h-dark-file-header-border-color);
}
.d2h-auto-color-scheme .d2h-lines-added {
border: 1px solid rgba(46, 160, 67, 0.4);
border: 1px solid var(--d2h-dark-ins-border-color);
color: #3fb950;
color: var(--d2h-dark-ins-label-color);
}
.d2h-auto-color-scheme .d2h-lines-deleted {
border: 1px solid rgba(248, 81, 73, 0.4);
border: 1px solid var(--d2h-dark-del-border-color);
color: #f85149;
color: var(--d2h-dark-del-label-color);
}
.d2h-auto-color-scheme .d2h-code-line del,
.d2h-auto-color-scheme .d2h-code-side-line del {
background-color: rgba(248, 81, 73, 0.4);
background-color: var(--d2h-dark-del-highlight-bg-color);
}
.d2h-auto-color-scheme .d2h-code-line ins,
.d2h-auto-color-scheme .d2h-code-side-line ins {
background-color: rgba(46, 160, 67, 0.4);
background-color: var(--d2h-dark-ins-highlight-bg-color);
}
.d2h-auto-color-scheme .d2h-diff-tbody {
border-color: #30363d;
border-color: var(--d2h-dark-border-color);
}
.d2h-auto-color-scheme .d2h-code-side-linenumber {
background-color: #0d1117;
background-color: var(--d2h-dark-bg-color);
border-color: #21262d;
border-color: var(--d2h-dark-line-border-color);
color: #6e7681;
color: var(--d2h-dark-dim-color);
}
.d2h-auto-color-scheme .d2h-files-diff .d2h-code-side-emptyplaceholder,
.d2h-auto-color-scheme .d2h-files-diff .d2h-emptyplaceholder {
background-color: hsla(215, 8%, 47%, 0.1);
background-color: var(--d2h-dark-empty-placeholder-bg-color);
border-color: #30363d;
border-color: var(--d2h-dark-empty-placeholder-border-color);
}
.d2h-auto-color-scheme .d2h-code-linenumber {
background-color: #0d1117;
background-color: var(--d2h-dark-bg-color);
border-color: #21262d;
border-color: var(--d2h-dark-line-border-color);
color: #6e7681;
color: var(--d2h-dark-dim-color);
}
.d2h-auto-color-scheme .d2h-del {
background-color: rgba(248, 81, 73, 0.1);
background-color: var(--d2h-dark-del-bg-color);
border-color: rgba(248, 81, 73, 0.4);
border-color: var(--d2h-dark-del-border-color);
}
.d2h-auto-color-scheme .d2h-ins {
background-color: rgba(46, 160, 67, 0.15);
background-color: var(--d2h-dark-ins-bg-color);
border-color: rgba(46, 160, 67, 0.4);
border-color: var(--d2h-dark-ins-border-color);
}
.d2h-auto-color-scheme .d2h-info {
background-color: rgba(56, 139, 253, 0.1);
background-color: var(--d2h-dark-info-bg-color);
border-color: rgba(56, 139, 253, 0.4);
border-color: var(--d2h-dark-info-border-color);
color: #6e7681;
color: var(--d2h-dark-dim-color);
}
.d2h-auto-color-scheme .d2h-file-diff .d2h-del.d2h-change {
background-color: rgba(210, 153, 34, 0.2);
background-color: var(--d2h-dark-change-del-color);
}
.d2h-auto-color-scheme .d2h-file-diff .d2h-ins.d2h-change {
background-color: rgba(46, 160, 67, 0.25);
background-color: var(--d2h-dark-change-ins-color);
}
.d2h-auto-color-scheme .d2h-file-wrapper {
border: 1px solid #30363d;
border: 1px solid var(--d2h-dark-border-color);
}
.d2h-auto-color-scheme .d2h-file-collapse {
border: 1px solid #0d1117;
border: 1px solid var(--d2h-dark-bg-color);
}
.d2h-auto-color-scheme .d2h-file-collapse.d2h-selected {
background-color: rgba(56, 139, 253, 0.1);
background-color: var(--d2h-dark-selected-color);
}
.d2h-auto-color-scheme .d2h-file-list-wrapper a,
.d2h-auto-color-scheme .d2h-file-list-wrapper a:visited {
color: #3572b0;
color: var(--d2h-dark-moved-label-color);
}
.d2h-auto-color-scheme .d2h-file-list > li {
border-bottom: 1px solid #0d1117;
border-bottom: 1px solid var(--d2h-dark-bg-color);
}
.d2h-dark-color-scheme .d2h-deleted {
color: #f85149;
color: var(--d2h-dark-del-label-color);
}
.d2h-auto-color-scheme .d2h-added {
color: #3fb950;
color: var(--d2h-dark-ins-label-color);
}
.d2h-auto-color-scheme .d2h-changed {
color: #d29922;
color: var(--d2h-dark-change-label-color);
}
.d2h-auto-color-scheme .d2h-moved {
color: #3572b0;
color: var(--d2h-dark-moved-label-color);
}
.d2h-auto-color-scheme .d2h-tag {
background-color: #0d1117;
background-color: var(--d2h-dark-bg-color);
}
.d2h-auto-color-scheme .d2h-deleted-tag {
border: 1px solid #f85149;
border: 1px solid var(--d2h-dark-del-label-color);
}
.d2h-auto-color-scheme .d2h-added-tag {
border: 1px solid #3fb950;
border: 1px solid var(--d2h-dark-ins-label-color);
}
.d2h-auto-color-scheme .d2h-changed-tag {
border: 1px solid #d29922;
border: 1px solid var(--d2h-dark-change-label-color);
}
.d2h-auto-color-scheme .d2h-moved-tag {
border: 1px solid #3572b0;
border: 1px solid var(--d2h-dark-moved-label-color);
}
}

View File

@@ -2,11 +2,10 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
div.CodeMirror {
height: calc(100vh - 9rem);
height: calc(100vh - 4rem);
background: ${(props) => props.theme.codemirror.bg};
border: solid 1px ${(props) => props.theme.codemirror.border};
font-family: ${(props) => (props.font ? props.font : 'default')};
font-size: ${(props) => props.theme.font.size.base};
line-break: anywhere;
}
@@ -61,17 +60,6 @@ const StyledWrapper = styled.div`
.cm-variable-invalid {
color: ${(props) => props.theme.codemirror.variable.invalid};
}
.CodeMirror-matchingbracket {
background: ${(props) => props.theme.status.success.background} !important;
text-decoration: unset;
}
.CodeMirror-nonmatchingbracket {
color: ${(props) => props.theme.colors.text.danger} !important;
background: ${(props) => props.theme.status.danger.background} !important;
text-decoration: unset;
}
`;
export default StyledWrapper;

View File

@@ -57,6 +57,16 @@ export default class CodeEditor extends React.Component {
scrollbarStyle: 'overlay',
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
extraKeys: {
'Cmd-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Ctrl-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Cmd-F': 'findPersistent',
'Ctrl-F': 'findPersistent',
'Cmd-H': 'replace',

View File

@@ -0,0 +1,51 @@
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from './CodeEditor/index';
import { IconDeviceFloppy } from '@tabler/icons';
import { saveApiSpecToFile } from 'providers/ReduxStore/slices/apiSpec';
import { useState } from 'react';
const FileEditor = ({ apiSpec }) => {
const dispatch = useDispatch();
const { displayedTheme, theme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [content, setContent] = useState(apiSpec?.raw);
const onEdit = (value) => {
setContent(value);
};
const onSave = () => {
dispatch(saveApiSpecToFile({ uid: apiSpec?.uid, content }));
};
const hasChanges = Boolean(content != apiSpec?.raw);
const editorMode = 'yaml';
return (
<div className="flex flex-grow relative">
<CodeEditor
theme={displayedTheme}
value={content}
onEdit={onEdit}
onSave={onSave}
mode={editorMode}
font={get(preferences, 'font.codeFont', 'default')}
/>
<IconDeviceFloppy
onClick={onSave}
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={22}
className={`absolute right-0 top-0 m-4 ${
hasChanges ? 'cursor-pointer oapcity-100' : 'cursor-default opacity-50'
}`}
/>
</div>
);
};
export default FileEditor;

View File

@@ -2,868 +2,15 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
.swagger-root {
height: calc(100vh - 7rem);
border-left: solid 1px ${(props) => props.theme.border.border1};
overflow-y: auto;
background: ${(props) => props.theme.bg};
padding-bottom: 20px;
height: calc(100vh - 4rem);
border: solid 1px ${(props) => props.theme.codemirror.border};
/* ── Global reset ── */
.swagger-ui {
font-family: inherit;
font-size: ${(props) => props.theme.font.size.base};
color: ${(props) => props.theme.text};
* {
border-color: ${(props) => props.theme.border.border1};
&.dark {
.swagger-ui {
filter: invert(88%) hue-rotate(180deg);
}
.auth-container {
padding: 0;
}
select {
box-shadow: none !important;
}
.wrapper {
padding: 0 20px;
max-width: none;
}
/* ── Info section ── */
.info {
margin: 16px 0 12px;
hgroup.main {
margin: 0;
}
.title {
font-size: 16px;
font-weight: 600;
color: ${(props) => props.theme.text};
small {
padding: 2px 6px !important;
font-size: 10px;
vertical-align: middle;
border-radius: 3px;
pre {
color: ${(props) => props.theme.text} !important;
font-size: 10px;
}
}
}
.base-url {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
}
.description {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
p, li {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
margin: 3px 0;
line-height: 1.5;
}
h1, h2, h3, h4, h5, h6 {
color: ${(props) => props.theme.text};
}
a {
color: ${(props) => props.theme.textLink};
}
}
}
/* Version / OAS badges */
.version-stamp span.version {
background: ${(props) => props.theme.border.border1} !important;
border: 1px solid ${(props) => props.theme.colors.text.muted} !important;
color: ${(props) => props.theme.text} !important;
font-size: 9px;
padding: 2px 6px;
border-radius: 3px;
}
.version-pragma {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
}
/* ── Tag section headings ── */
.opblock-tag-section {
.opblock-tag {
font-size: ${(props) => props.theme.font.size.md};
color: ${(props) => props.theme.text};
border-bottom: none;
padding: 0;
&:hover {
background: ${(props) => props.theme.background.mantle};
}
a {
color: ${(props) => props.theme.text} !important;
}
small {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
padding: 0 10px;
}
}
}
/* ── Operation blocks (GET, POST, PUT, DELETE, PATCH) ── */
.opblock {
margin: 0 0 8px;
border-radius: 4px;
border: 1px solid ${(props) => props.theme.border.border1} !important;
background: ${(props) => props.theme.bg} !important;
box-shadow: none !important;
.opblock-summary {
padding: 6px 10px;
border: none !important;
background: transparent !important;
.opblock-summary-method {
font-size: 10px;
font-weight: 700;
padding: 3px 8px;
min-width: 50px;
text-align: center;
border-radius: 3px;
}
.opblock-summary-path {
font-size: ${(props) => props.theme.font.size.sm};
a, span {
color: ${(props) => props.theme.text} !important;
}
}
.opblock-summary-description {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
}
.opblock-summary-control {
svg {
fill: ${(props) => props.theme.colors.text.muted};
width: 14px;
height: 14px;
}
}
}
.opblock-body {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.bg};
border-top: 1px solid ${(props) => props.theme.border.border1};
.opblock-description-wrapper,
.opblock-section {
p {
color: ${(props) => props.theme.colors.text.muted};
font-size: ${(props) => props.theme.font.size.sm};
}
}
.tab-header .tab-item {
color: ${(props) => props.theme.colors.text.muted};
&.active {
color: ${(props) => props.theme.text};
}
}
select {
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.bg};
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 3px;
font-size: ${(props) => props.theme.font.size.xs};
padding: 2px 6px;
}
input[type="text"] {
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.bg};
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 3px;
font-size: ${(props) => props.theme.font.size.sm};
}
}
}
/* Method badge colors — keep them but tone down */
.opblock.opblock-get .opblock-summary-method { background: #61affe; color: #fff; }
.opblock.opblock-post .opblock-summary-method { background: #49cc90; color: #fff; }
.opblock.opblock-put .opblock-summary-method { background: #fca130; color: #fff; }
.opblock.opblock-delete .opblock-summary-method { background: #f93e3e; color: #fff; }
.opblock.opblock-patch .opblock-summary-method { background: #50e3c2; color: #000; }
/* Lock / authorization icons */
.authorization__btn {
svg {
fill: ${(props) => props.theme.colors.text.muted};
width: 14px;
height: 14px;
}
}
/* ── Tables ── */
table {
font-size: ${(props) => props.theme.font.size.sm};
thead {
tr {
th {
font-size: ${(props) => props.theme.font.size.xs} !important;
color: ${(props) => props.theme.colors.text.muted} !important;
border-bottom: 1px solid ${(props) => props.theme.border.border1} !important;
padding: 6px 0;
}
}
}
td {
padding: 6px 0;
border-bottom: 1px solid ${(props) => props.theme.border.border1};
color: ${(props) => props.theme.text};
}
}
.parameter__name {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.text};
&.required::after {
color: ${(props) => props.theme.colors.text.danger || '#c0392b'};
font-size: ${(props) => props.theme.font.size.xs};
}
}
.parameter__type {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
}
.parameter__in {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
}
/* ── Models / Schemas ── */
section.models {
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 4px;
background: ${(props) => props.theme.bg};
padding-bottom: 0px;
margin-bottom: 40px;
margin-top: 8px;
h4 {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.text};
border-bottom: none;
padding: 6px 10px;
margin: 0;
svg {
fill: ${(props) => props.theme.colors.text.muted};
width: 16px;
height: 16px;
}
}
.model-container {
background: ${(props) => props.theme.bg} !important;
margin: 0;
padding: 4px 8px;
border-bottom: 1px solid ${(props) => props.theme.border.border1};
&:last-child {
border-bottom: none;
}
.model-box {
background: ${(props) => props.theme.bg} !important;
padding: 2px 0;
}
}
}
.model {
font-size: 11px;
color: ${(props) => props.theme.text};
line-height: 1.4;
.prop-type {
color: ${(props) => props.theme.textLink};
font-size: 11px;
}
.prop-format {
color: ${(props) => props.theme.colors.text.muted};
font-size: 10px;
}
span.prop-enum {
display: block;
color: ${(props) => props.theme.colors.text.muted};
font-size: 10px;
}
}
.model-example {
.tab li {
color: ${(props) => props.theme.colors.text.muted} !important;
}
}
/* Model expand/collapse toggle */
.model-toggle {
cursor: pointer;
font-size: 10px;
color: ${(props) => props.theme.colors.text.muted};
&::after {
color: ${(props) => props.theme.colors.text.muted};
}
}
/* Model box inner styling */
.model-box {
background: ${(props) => props.theme.bg} !important;
color: ${(props) => props.theme.text};
}
/* Inner model details */
.inner-object {
color: ${(props) => props.theme.text};
}
/* Model title (schema name) */
.model-title {
color: ${(props) => props.theme.text};
font-size: 12px;
font-weight: 600;
}
/* ── JSON Schema 2020-12 (OpenAPI 3.1) schema overrides ── */
.json-schema-2020-12-accordion,
.json-schema-2020-12-expand-deep-button,
section.models h4 button,
.model-box button,
.models-control,
.opblock-summary,
.opblock-summary-control,
.opblock-tag {
outline: none !important;
box-shadow: none !important;
}
button:focus-visible,
.opblock-summary:focus-visible,
.opblock-tag:focus-visible,
.models-control:focus-visible {
outline: 2px solid ${(props) => props.theme.textLink} !important;
outline-offset: 2px;
}
.json-schema-2020-12__title {
font-size: 12px !important;
font-weight: 600;
color: ${(props) => props.theme.text} !important;
}
.json-schema-2020-12-head {
padding: 4px 8px !important;
background: ${(props) => props.theme.bg} !important;
.json-schema-2020-12-accordion {
padding: 0 !important;
color: ${(props) => props.theme.text} !important;
background: transparent !important;
}
/* chevron / arrow icon */
.json-schema-2020-12-accordion__icon {
fill: ${(props) => props.theme.colors.text.muted} !important;
}
button.json-schema-2020-12-expand-deep-button {
font-size: 10px !important;
color: ${(props) => props.theme.colors.text.muted} !important;
background: transparent !important;
padding: 0 4px !important;
}
strong.json-schema-2020-12__attribute--primary {
font-size: 11px !important;
color: ${(props) => props.theme.textLink} !important;
font-weight: normal;
}
}
.json-schema-2020-12-body {
font-size: 11px !important;
margin-left: 16px;
color: ${(props) => props.theme.text} !important;
.json-schema-2020-12-property {
margin-left: 8px;
color: ${(props) => props.theme.text} !important;
border-color: ${(props) => props.theme.border.border1} !important;
}
/* property names */
.json-schema-2020-12__title {
font-size: 11px !important;
font-weight: normal;
color: ${(props) => props.theme.text} !important;
}
/* type badges inside expanded schema */
strong.json-schema-2020-12__attribute--primary {
font-size: 10px !important;
color: ${(props) => props.theme.textLink} !important;
font-weight: normal;
}
strong.json-schema-2020-12__attribute {
font-size: 10px !important;
color: ${(props) => props.theme.colors.text.muted} !important;
font-weight: normal;
}
}
.json-schema-2020-12 {
font-size: 11px !important;
margin: 0 !important;
width: 100%;
height: 100%;
color: ${(props) => props.theme.text} !important;
background: ${(props) => props.theme.bg} !important;
}
/* JSON viewer (Examples section inside schema properties) */
.json-schema-2020-12-json-viewer {
background: transparent !important;
color: ${(props) => props.theme.text} !important;
}
.json-schema-2020-12-json-viewer__name {
color: ${(props) => props.theme.text} !important;
}
.json-schema-2020-12-json-viewer__name--secondary {
color: ${(props) => props.theme.colors.text.muted} !important;
font-weight: normal !important;
}
.json-schema-2020-12-json-viewer__value {
color: ${(props) => props.theme.text} !important;
}
.json-schema-2020-12-json-viewer__value--secondary {
color: ${(props) => props.theme.colors.text.subtext0} !important;
}
.json-schema-2020-12-json-viewer__value--string,
.json-schema-2020-12-json-viewer__value--string.json-schema-2020-12-json-viewer__value--secondary {
color: ${(props) => props.theme.colors.text.green} !important;
}
.json-schema-2020-12-json-viewer__value--number,
.json-schema-2020-12-json-viewer__value--bigint,
.json-schema-2020-12-json-viewer__value--number.json-schema-2020-12-json-viewer__value--secondary,
.json-schema-2020-12-json-viewer__value--bigint.json-schema-2020-12-json-viewer__value--secondary {
color: ${(props) => props.theme.textLink} !important;
}
.json-schema-2020-12-json-viewer__value--boolean,
.json-schema-2020-12-json-viewer__value--boolean.json-schema-2020-12-json-viewer__value--secondary {
color: ${(props) => props.theme.colors.text.warning} !important;
}
.json-schema-2020-12-json-viewer__value--null,
.json-schema-2020-12-json-viewer__value--undefined {
color: ${(props) => props.theme.colors.text.muted} !important;
}
/* enum/keyword example values container */
.json-schema-2020-12-keyword--examples,
[data-json-schema-keyword="examples"] {
color: ${(props) => props.theme.text} !important;
}
/* Model collapse/expand all link */
span.model-toggle {
color: ${(props) => props.theme.colors.text.muted};
font-size: 10px;
}
/* Brace styling in models */
.brace-open, .brace-close {
color: ${(props) => props.theme.colors.text.muted};
font-size: 11px;
}
/* ── Code / Response blocks ── */
.microlight {
background: ${(props) => props.theme.codemirror.bg} !important;
color: ${(props) => props.theme.text} !important;
font-size: ${(props) => props.theme.font.size.xs};
border-radius: 4px;
padding: 8px;
border: 1px solid ${(props) => props.theme.border.border1};
}
.highlight-code {
background: ${(props) => props.theme.codemirror.bg} !important;
> .microlight {
border: none;
}
}
pre {
color: ${(props) => props.theme.text};
font-size: ${(props) => props.theme.font.size.xs};
border-radius: 4px;
}
.response-col_status {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.text};
}
.response-col_description {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
}
.responses-inner {
h4, h5 {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.text};
}
}
/* ── Buttons ── */
.btn {
font-size: ${(props) => props.theme.font.size.xs};
border-radius: 4px;
box-shadow: none !important;
color: ${(props) => props.theme.text};
border-color: ${(props) => props.theme.border.border1};
background: transparent;
}
.btn.authorize {
color: ${(props) => props.theme.text};
border-color: ${(props) => props.theme.border.border1};
background: transparent;
svg {
fill: ${(props) => props.theme.text};
}
span {
color: ${(props) => props.theme.text};
}
}
.btn.execute {
background: ${(props) => props.theme.primary?.solid || props.theme.textLink};
color: #fff;
border-color: transparent;
}
.btn-group {
.btn {
background: ${(props) => props.theme.bg};
color: ${(props) => props.theme.text};
}
}
/* ── Links ── */
a {
color: ${(props) => props.theme.textLink};
}
/* ── Servers / Scheme container ── */
.scheme-container {
background: ${(props) => props.theme.background.mantle} !important;
border-top: 1px solid ${(props) => props.theme.border.border1};
border-bottom: 1px solid ${(props) => props.theme.border.border1};
padding: 10px;
box-shadow: none !important;
.schemes-title {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
}
label {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
}
select {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.bg};
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 4px;
padding: 4px 8px;
}
}
/* ── SVGs / icons ── */
svg {
fill: ${(props) => props.theme.colors.text.muted};
}
svg.arrow {
fill: ${(props) => props.theme.text};
width: 12px;
height: 12px;
margin-left: 4px;
}
.expand-operation svg {
fill: ${(props) => props.theme.colors.text.muted};
width: 14px;
height: 14px;
}
/* ── Misc / catch-all ── */
.loading-container .loading::after {
color: ${(props) => props.theme.colors.text.muted};
font-size: ${(props) => props.theme.font.size.sm};
}
.renderedMarkdown p {
color: ${(props) => props.theme.colors.text.muted};
font-size: ${(props) => props.theme.font.size.sm};
}
.opblock-section-header {
background: ${(props) => props.theme.background.mantle} !important;
box-shadow: none !important;
border-bottom: 1px solid ${(props) => props.theme.border.border1};
padding: 6px 10px;
h4 {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.text};
}
label {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
}
}
.copy-to-clipboard {
button {
background: ${(props) => props.theme.background.mantle};
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 3px;
}
}
/* Dialog / modal overrides */
.dialog-ux {
.modal-ux {
background: ${(props) => props.theme.bg};
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 6px;
color: ${(props) => props.theme.text};
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
.modal-ux-header {
border-bottom: 1px solid ${(props) => props.theme.border.border1};
padding: 12px 0px;
h3 {
font-size: ${(props) => props.theme.font.size.md};
font-weight: 600;
color: ${(props) => props.theme.text};
}
.close-modal {
opacity: 0.6;
&:hover { opacity: 1; }
svg { fill: ${(props) => props.theme.text}; }
}
}
.modal-ux-content {
color: ${(props) => props.theme.text};
padding: 12px 16px;
p {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
}
/* Section headings like "api_key (apiKey)" */
h4, h5, h6 {
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 600;
color: ${(props) => props.theme.textLink};
margin: 12px 0 6px;
}
/* Labels: "Name:", "In:", "Flow:", "Value:", etc. */
label {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.text};
> span {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
}
}
/* "Scopes:" heading */
.scopes h2 {
font-size: ${(props) => props.theme.font.size.sm} !important;
font-weight: 500;
color: ${(props) => props.theme.text} !important;
}
/* Scope item name + description */
.scopes .checkbox {
p.name {
font-size: ${(props) => props.theme.font.size.sm} !important;
color: ${(props) => props.theme.text} !important;
font-weight: 500;
margin: 0;
}
p.description {
font-size: ${(props) => props.theme.font.size.xs} !important;
color: ${(props) => props.theme.colors.text.muted} !important;
margin: 0;
}
}
/* Text inputs */
input[type="text"],
input[type="password"],
input[type="email"] {
background: ${(props) => props.theme.background.mantle} !important;
color: ${(props) => props.theme.text} !important;
border: 1px solid ${(props) => props.theme.border.border1} !important;
border-radius: 4px !important;
font-size: ${(props) => props.theme.font.size.sm} !important;
padding: 6px 10px !important;
outline: none !important;
box-shadow: none !important;
&:focus {
border-color: ${(props) => props.theme.textLink} !important;
outline: none !important;
box-shadow: none !important;
}
}
/* Checkboxes — custom styled to match theme */
input[type="checkbox"] {
appearance: none !important;
-webkit-appearance: none !important;
width: 14px !important;
height: 14px !important;
min-width: 14px;
border: 1px solid ${(props) => props.theme.border.border1} !important;
border-radius: 3px !important;
background: ${(props) => props.theme.background.mantle} !important;
cursor: pointer;
position: relative;
vertical-align: middle;
&:checked {
background: ${(props) => props.theme.textLink} !important;
border-color: ${(props) => props.theme.textLink} !important;
&::after {
content: '';
position: absolute;
left: 3px;
top: 1px;
width: 5px;
height: 8px;
border: 2px solid #fff;
border-top: none;
border-left: none;
transform: rotate(45deg);
}
}
}
/* "select all / select none" links */
a {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.textLink};
}
/* Dividers between auth sections */
hr {
border-color: ${(props) => props.theme.border.border1};
margin: 12px 0;
}
/* Authorize / Close buttons */
.btn-done,
.auth-btn-wrapper .btn {
font-size: ${(props) => props.theme.font.size.sm};
border-radius: 4px;
padding: 6px 16px;
border: 1px solid ${(props) => props.theme.border.border1};
background: transparent;
color: ${(props) => props.theme.text};
cursor: pointer;
outline: none !important;
box-shadow: none !important;
&:hover {
background: ${(props) => props.theme.background.mantle};
}
&.modal-btn-operation {
background: ${(props) => props.theme.textLink};
color: #fff;
border-color: transparent;
&:hover {
opacity: 0.9;
}
}
}
}
}
.backdrop-ux {
background: rgba(0, 0, 0, 0.5);
}
.swagger-ui .microlight {
filter: invert(100%) hue-rotate(180deg);
}
}
}

View File

@@ -1,15 +1,19 @@
import { memo } from 'react';
import SwaggerUI from 'swagger-ui-react';
import StyledWrapper from './StyledWrapper';
import { useTheme } from 'providers/Theme';
const Swagger = ({ string }) => {
const { displayedTheme } = useTheme();
console.log('string', string);
const Swagger = ({ spec, onComplete }) => {
return (
<StyledWrapper>
<div className="swagger-root w-full">
<SwaggerUI spec={spec} onComplete={onComplete} />
<div className={`swagger-root w-full overflow-y-scroll ${displayedTheme}`}>
<SwaggerUI spec={string} />
</div>
</StyledWrapper>
);
};
export default memo(Swagger);
export default Swagger;

View File

@@ -1,123 +0,0 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useSelector } from 'react-redux';
import { IconDeviceFloppy, IconLoader2 } 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)
*/
const SpecViewer = ({ content, readOnly, onSave, leftPaneWidth, onLeftPaneWidthChange }) => {
const { displayedTheme, theme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [editorContent, setEditorContent] = useState(content);
useEffect(() => {
setEditorContent(content);
}, [content]);
const hasChanges = !readOnly && editorContent !== content;
const handleSave = () => {
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>
</div>
)}
</div>
</section>
);
};
export default SpecViewer;

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,13 +1,15 @@
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 FileEditor from './FileEditor';
import Dropdown from 'components/Dropdown';
import { openApiSpec, saveApiSpecToFile, updateApiSpecPanelLeftPaneWidth } from 'providers/ReduxStore/slices/apiSpec';
import { openApiSpec } from 'providers/ReduxStore/slices/apiSpec';
import { useState } from 'react';
import CreateApiSpec from 'components/Sidebar/ApiSpecs/CreateApiSpec';
import { Suspense } from 'react';
import Swagger from './Renderers/Swagger';
import toast from 'react-hot-toast';
const ApiSpecPanel = () => {
@@ -21,16 +23,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>;
}
@@ -85,12 +78,18 @@ const ApiSpecPanel = () => {
</Dropdown>
</div>
</div>
<SpecViewer
content={raw}
onSave={(content) => dispatch(saveApiSpecToFile({ uid, content }))}
leftPaneWidth={leftPaneWidth ?? null}
onLeftPaneWidthChange={handleLeftPaneWidthChange}
/>
<section className="main flex flex-grow px-4 relative">
<div className="w-full grid grid-cols-2">
<div className="col-span-1">
<FileEditor apiSpec={apiSpec} />
</div>
<div className="col-span-1">
<Suspense fallback="">
<Swagger string={raw} />
</Suspense>
</div>
</div>
</section>
</StyledWrapper>
);
};

View File

@@ -1,15 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
align-items: center;
height: 100%;
-webkit-app-region: no-drag;
.shortcut {
font-size: 11px;
color: ${(props) => props.theme.dropdown.mutedText};
}
`;
export default StyledWrapper;

View File

@@ -1,154 +0,0 @@
import React, { useState } from 'react';
import { IconMenu2 } from '@tabler/icons';
import MenuDropdown from 'ui/MenuDropdown';
import ActionIcon from 'ui/ActionIcon';
import StyledWrapper from './StyledWrapper';
const AppMenu = () => {
const [isOpen, setIsOpen] = useState(false);
const { ipcRenderer } = window;
const menuItems = [
{
id: 'file',
label: 'File',
submenu: [
{
id: 'open-collection',
label: 'Open Collection',
onClick: () => ipcRenderer?.invoke('renderer:open-collection')
},
{ type: 'divider', id: 'file-div-1' },
{
id: 'preferences',
label: 'Preferences',
rightSection: <span className="shortcut">Ctrl+,</span>,
onClick: () => ipcRenderer?.invoke('renderer:open-preferences')
},
{ type: 'divider', id: 'file-div-2' },
{
id: 'quit',
label: 'Quit',
rightSection: <span className="shortcut">Alt+F4</span>,
onClick: () => ipcRenderer?.send('renderer:window-close')
}
]
},
{
id: 'edit',
label: 'Edit',
submenu: [
{
id: 'undo',
label: 'Undo',
rightSection: <span className="shortcut">Ctrl+Z</span>,
onClick: () => document.execCommand('undo')
},
{
id: 'redo',
label: 'Redo',
rightSection: <span className="shortcut">Ctrl+Y</span>,
onClick: () => document.execCommand('redo')
},
{ type: 'divider', id: 'edit-div-1' },
{
id: 'cut',
label: 'Cut',
rightSection: <span className="shortcut">Ctrl+X</span>,
onClick: () => document.execCommand('cut')
},
{
id: 'copy',
label: 'Copy',
rightSection: <span className="shortcut">Ctrl+C</span>,
onClick: () => document.execCommand('copy')
},
{
id: 'paste',
label: 'Paste',
rightSection: <span className="shortcut">Ctrl+V</span>,
onClick: () => document.execCommand('paste')
},
{ type: 'divider', id: 'edit-div-2' },
{
id: 'select-all',
label: 'Select All',
rightSection: <span className="shortcut">Ctrl+A</span>,
onClick: () => document.execCommand('selectAll')
}
]
},
{
id: 'view',
label: 'View',
submenu: [
{
id: 'toggle-devtools',
label: 'Developer Tools',
rightSection: <span className="shortcut">Ctrl+Shift+I</span>,
onClick: () => ipcRenderer?.invoke('renderer:toggle-devtools')
},
{ type: 'divider', id: 'view-div-1' },
{
id: 'reset-zoom',
label: 'Reset Zoom',
rightSection: <span className="shortcut">Ctrl+0</span>,
onClick: () => ipcRenderer?.invoke('renderer:reset-zoom')
},
{
id: 'zoom-in',
label: 'Zoom In',
rightSection: <span className="shortcut">Ctrl++</span>,
onClick: () => ipcRenderer?.invoke('renderer:zoom-in')
},
{
id: 'zoom-out',
label: 'Zoom Out',
rightSection: <span className="shortcut">Ctrl+-</span>,
onClick: () => ipcRenderer?.invoke('renderer:zoom-out')
},
{ type: 'divider', id: 'view-div-2' },
{
id: 'toggle-fullscreen',
label: 'Full Screen',
rightSection: <span className="shortcut">F11</span>,
onClick: () => ipcRenderer?.invoke('renderer:toggle-fullscreen')
}
]
},
{
id: 'help',
label: 'Help',
submenu: [
{
id: 'about',
label: 'About Bruno',
onClick: () => ipcRenderer?.invoke('renderer:open-about')
},
{
id: 'documentation',
label: 'Documentation',
onClick: () => ipcRenderer?.invoke('renderer:open-docs')
}
]
}
];
return (
<StyledWrapper>
<MenuDropdown
opened={isOpen}
onChange={setIsOpen}
placement="bottom-start"
showTickMark={false}
items={menuItems}
>
<ActionIcon label="Menu" size="lg">
<IconMenu2 size={16} stroke={1.5} />
</ActionIcon>
</MenuDropdown>
</StyledWrapper>
);
};
export default AppMenu;

View File

@@ -210,10 +210,6 @@ const Wrapper = styled.div`
margin-left: 6px;
}
.app-menu {
margin-left: 8px;
}
/* Custom window control buttons for Windows - always interactive, above modal overlay */
.window-controls {
display: flex;

View File

@@ -4,12 +4,10 @@ import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import { useDispatch, useSelector } from 'react-redux';
import { savePreferences, showManageWorkspacePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { savePreferences, showHomePage, showManageWorkspacePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { closeConsole, openConsole } from 'providers/ReduxStore/slices/logs';
import { createWorkspaceWithUniqueName, openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import { openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import { sortWorkspaces, toggleWorkspacePin } from 'utils/workspaces';
import { focusTab } from 'providers/ReduxStore/slices/tabs';
import get from 'lodash/get';
import Bruno from 'components/Bruno';
import MenuDropdown from 'ui/MenuDropdown';
@@ -19,7 +17,6 @@ import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace';
import ImportWorkspace from 'components/WorkspaceSidebar/ImportWorkspace';
import IconBottombarToggle from 'components/Icons/IconBottombarToggle/index';
import AppMenu from './AppMenu';
import StyledWrapper from './StyledWrapper';
import ResponseLayoutToggle from 'components/ResponsePane/ResponseLayoutToggle';
import { isMacOS, isWindowsOS, isLinuxOS } from 'utils/common/platform';
@@ -131,10 +128,7 @@ const AppTitleBar = () => {
});
const handleHomeClick = () => {
const scratchCollectionUid = activeWorkspace?.scratchCollectionUid;
if (scratchCollectionUid) {
dispatch(focusTab({ uid: `${scratchCollectionUid}-overview` }));
}
dispatch(showHomePage());
};
const handleWorkspaceSwitch = (workspaceUid) => {
@@ -151,19 +145,9 @@ const AppTitleBar = () => {
}
};
const handleCreateWorkspace = useCallback(async () => {
const defaultLocation = get(preferences, 'general.defaultLocation', '');
if (!defaultLocation) {
setCreateWorkspaceModalOpen(true);
return;
}
try {
await dispatch(createWorkspaceWithUniqueName(defaultLocation));
} catch (error) {
toast.error(error?.message || 'Failed to create workspace');
}
}, [preferences, dispatch]);
const handleCreateWorkspace = () => {
setCreateWorkspaceModalOpen(true);
};
const handleManageWorkspaces = () => {
dispatch(showManageWorkspacePage());
@@ -251,7 +235,7 @@ const AppTitleBar = () => {
);
return items;
}, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace, handleCreateWorkspace]);
}, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace]);
return (
<StyledWrapper className={`app-titlebar ${osClass} ${isFullScreen ? 'fullscreen' : ''}`}>
@@ -263,9 +247,8 @@ const AppTitleBar = () => {
)}
<div className="titlebar-content">
{/* Left section: Home + Workspace */}
<div className="titlebar-left">
{showWindowControls && <AppMenu />}
<ActionIcon onClick={handleHomeClick} label="Home" size="lg" className="home-button">
<IconHome size={16} stroke={1.5} />
</ActionIcon>

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

@@ -151,14 +151,8 @@ const StyledWrapper = styled.div`
//matching bracket fix
.CodeMirror-matchingbracket {
background: ${(props) => props.theme.status.success.background} !important;
text-decoration: unset;
}
.CodeMirror-nonmatchingbracket {
color: ${(props) => props.theme.colors.text.danger} !important;
background: ${(props) => props.theme.status.danger.background} !important;
text-decoration: unset;
background: #5cc0b48c !important;
text-decoration:unset;
}
.cm-search-line-highlight {

View File

@@ -5,8 +5,8 @@
* LICENSE file in the root directory of this source tree.
*/
import React, { createRef } from 'react';
import { debounce, isEqual } from 'lodash';
import React from 'react';
import { isEqual, escapeRegExp } from 'lodash';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { setupAutoComplete, showRootHints } from 'utils/codemirror/autocomplete';
import StyledWrapper from './StyledWrapper';
@@ -16,15 +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 CodeMirrorSearch from 'components/CodeMirrorSearch/index';
import {
applyEditorState,
captureEditorState,
getDocKey,
readPersistedEditorState,
writePersistedEditorState
} from './state-persistence';
import { usePersistenceScope } from 'hooks/usePersistedState/PersistedScopeProvider';
import CodeMirrorSearch from 'components/CodeMirrorSearch';
const CodeMirror = require('codemirror');
window.jsonlint = jsonlint;
@@ -32,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);
@@ -42,7 +34,6 @@ class CodeEditor extends React.Component {
this.cachedValue = props.value || '';
this.variables = {};
this.searchResultsCountElementId = 'search-results-count';
this.searchBarRef = createRef();
this.lintOptions = {
esversion: 11,
@@ -56,24 +47,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 || '',
@@ -98,20 +73,38 @@ class CodeEditor extends React.Component {
scrollbarStyle: 'overlay',
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
extraKeys: {
'Cmd-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Ctrl-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Cmd-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Ctrl-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Cmd-F': (cm) => {
this.setState({ searchBarVisible: true }, () => {
this.searchBarRef.current?.focus();
});
if (!this.state.searchBarVisible) {
this.setState({ searchBarVisible: true });
}
},
'Ctrl-F': (cm) => {
this.setState({ searchBarVisible: true }, () => {
this.searchBarRef.current?.focus();
});
if (!this.state.searchBarVisible) {
this.setState({ searchBarVisible: true });
}
},
'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')
@@ -201,49 +194,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);
@@ -263,12 +216,6 @@ class CodeEditor extends React.Component {
// Setup lint error tooltip on line number hover
this.cleanupLintErrorTooltip = setupLintErrorTooltip(editor);
// Add mousetrap class so Mousetrap captures shortcuts even when CodeMirror is focused
const cmInput = editor.getInputField();
if (cmInput) {
cmInput.classList.add('mousetrap');
}
}
}
@@ -284,52 +231,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 = this.props.value;
this.editor.setValue(this.props.value);
this.editor.setCursor(cursor);
}
if (this.editor) {
@@ -375,31 +281,12 @@ 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?.();
@@ -422,10 +309,6 @@ class CodeEditor extends React.Component {
fontSize={this.props.fontSize}
>
<CodeMirrorSearch
ref={(node) => {
if (!node) return;
this.searchBarRef.current = node;
}}
visible={this.state.searchBarVisible}
editor={this.editor}
onClose={() => this.setState({ searchBarVisible: false })}
@@ -463,12 +346,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,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { IconRegex, IconArrowUp, IconArrowDown, IconX, IconLetterCase, IconLetterW } from '@tabler/icons';
import ToolHint from 'components/ToolHint';
import StyledWrapper from './StyledWrapper';
@@ -8,45 +8,7 @@ function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');
}
const MAX_MATCHES = 99_999;
function findSearchMatches(editor, searchText, regex, caseSensitive, wholeWord) {
try {
let query, options = {};
if (regex) {
try {
query = new RegExp(searchText, caseSensitive ? 'g' : 'gi');
} catch (error) {
console.warn('Invalid regex provided in search!', error);
return [];
}
} else if (wholeWord) {
const escaped = escapeRegExp(searchText);
query = new RegExp(`\\b${escaped}\\b`, caseSensitive ? 'g' : 'gi');
} else {
query = searchText;
options = { caseFold: !caseSensitive };
}
const cursor = editor.getSearchCursor(query, { line: 0, ch: 0 }, options);
const out = [];
while (cursor.findNext()) {
out.push({ from: cursor.from(), to: cursor.to() });
if (out.length >= MAX_MATCHES) {
break;
}
}
return out;
} catch (e) {
console.error('Search error:', e);
return [];
}
}
function createCacheKey(editor, searchText, regex, caseSensitive, wholeWord) {
return `${editor.getValue().length}${searchText}${regex}${caseSensitive}${wholeWord}`;
}
const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
const CodeMirrorSearch = ({ visible, editor, onClose }) => {
const [searchText, setSearchText] = useState('');
const [regex, setRegex] = useState(false);
const [caseSensitive, setCaseSensitive] = useState(false);
@@ -57,15 +19,48 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
const searchMarks = useRef([]);
const searchLineHighlight = useRef(null);
const searchMatches = useRef([]);
const searchCacheKey = useRef('');
const inputRef = useRef(null);
const debouncedSearchText = useDebounce(searchText, 250);
const doSearch = useCallback((newIndex = 0) => {
if (!editor || !visible) {
return;
const debouncedSearchText = useDebounce(searchText, 150);
const memoizedMatches = useMemo(() => {
if (!editor || !visible) return [];
if (!debouncedSearchText) return [];
try {
let query, options = {};
if (regex) {
try {
query = new RegExp(debouncedSearchText, caseSensitive ? 'g' : 'gi');
} catch {
return [];
}
} else if (wholeWord) {
const escaped = escapeRegExp(debouncedSearchText);
query = new RegExp(`\\b${escaped}\\b`, caseSensitive ? 'g' : 'gi');
} else {
query = debouncedSearchText;
options = { caseFold: !caseSensitive };
}
const cursor = editor.getSearchCursor(query, { line: 0, ch: 0 }, options);
const out = [];
while (cursor.findNext()) {
out.push({ from: cursor.from(), to: cursor.to() });
}
return out;
} catch (e) {
console.error('Search error:', e);
return [];
}
}, [editor, visible, debouncedSearchText, regex, caseSensitive, wholeWord]);
const doSearch = useCallback((newIndex = 0) => {
if (!editor) return;
// Clear previous marks
searchMarks.current.forEach((mark) => mark.clear());
searchMarks.current = [];
// Clear previous line highlight
if (searchLineHighlight.current !== null) {
editor.removeLineClass(searchLineHighlight.current, 'wrap', 'cm-search-line-highlight');
searchLineHighlight.current = null;
@@ -75,100 +70,44 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
setMatchCount(0);
setMatchIndex(0);
searchMatches.current = [];
searchMarks.current.forEach((mark) => mark.clear());
searchMarks.current = [];
return;
}
try {
const newCacheKey = createCacheKey(editor, debouncedSearchText, regex, caseSensitive, wholeWord);
const isCacheHit = newCacheKey === searchCacheKey.current;
let matches = searchMatches.current;
if (!isCacheHit) {
matches = findSearchMatches(editor, debouncedSearchText, regex, caseSensitive, wholeWord);
searchMatches.current = matches;
searchCacheKey.current = newCacheKey;
setMatchCount(matches.length);
}
if (!matches.length) {
setMatchIndex(0);
// Clear previous marks
searchMarks.current.forEach((mark) => mark.clear());
searchMarks.current = [];
return;
}
const matchIndex = Math.max(0, Math.min(newIndex, matches.length - 1));
setMatchIndex(matchIndex);
if (isCacheHit) {
// Clear only old current mark
const oldIndex = searchMarks.current.findIndex((mark) => mark.className?.includes('cm-search-current'));
if (oldIndex !== -1) {
searchMarks.current[oldIndex].clear();
searchMarks.current.splice(oldIndex, 1);
}
// Add mark to the new current and remark the previous and next
const toMark = [
// Previous
matchIndex > 0 ? matchIndex - 1 : null,
// Current
matchIndex,
// Next
matchIndex < matches.length - 1 ? matchIndex + 1 : null
].filter((i) => i !== null);
toMark.forEach((i) => {
const mark = editor.markText(matches[i].from, matches[i].to, {
className: i === matchIndex ? 'cm-search-current' : 'cm-search-match',
clearOnEnter: true
});
searchMarks.current.push(mark);
const matches = memoizedMatches;
let matchIndex = matches.length ? Math.max(0, Math.min(newIndex, matches.length - 1)) : 0;
matches.forEach((m, i) => {
const mark = editor.markText(m.from, m.to, {
className: i === matchIndex ? 'cm-search-current' : 'cm-search-match',
clearOnEnter: true
});
searchMarks.current.push(mark);
});
if (matches.length) {
const currentLine = matches[matchIndex].from.line;
editor.addLineClass(currentLine, 'wrap', 'cm-search-line-highlight');
searchLineHighlight.current = currentLine;
editor.scrollIntoView(matches[matchIndex].from, 100);
editor.setSelection(matches[matchIndex].from, matches[matchIndex].to);
} else {
// Clear previous marks
searchMarks.current.forEach((mark) => mark.clear());
searchMarks.current = [];
// Mark all on new search
matches.forEach((m, i) => {
const mark = editor.markText(m.from, m.to, {
className: i === matchIndex ? 'cm-search-current' : 'cm-search-match',
clearOnEnter: true
});
searchMarks.current.push(mark);
});
searchLineHighlight.current = null;
}
const currentLine = matches[matchIndex].from.line;
editor.addLineClass(currentLine, 'wrap', 'cm-search-line-highlight');
searchLineHighlight.current = currentLine;
editor.scrollIntoView(matches[matchIndex].from, 100);
editor.setSelection(matches[matchIndex].from, matches[matchIndex].to);
setMatchCount(matches.length);
setMatchIndex(matchIndex);
searchMatches.current = matches;
} catch (e) {
console.error('Search error:', e);
setMatchCount(0);
setMatchIndex(0);
searchMatches.current = [];
searchCacheKey.current = '';
}
}, [debouncedSearchText, regex, caseSensitive, wholeWord, editor, visible]);
useImperativeHandle(ref, () => ({
focus: () => {
if (inputRef.current) {
inputRef.current.focus();
}
}
}));
}, [debouncedSearchText, regex, caseSensitive, wholeWord, editor, memoizedMatches]);
useEffect(() => {
doSearch(0);
doSearch(0, debouncedSearchText);
}, [debouncedSearchText, doSearch]);
const handleSearchBarClose = useCallback(() => {
@@ -179,7 +118,6 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
searchLineHighlight.current = null;
}
searchMatches.current = [];
searchCacheKey.current = '';
if (onClose) onClose();
// Focus the editor after closing the search bar
if (editor) {
@@ -195,27 +133,32 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
const handleToggleRegex = () => {
setRegex((prev) => !prev);
setMatchIndex(0);
doSearch(0);
};
const handleToggleCase = () => {
setCaseSensitive((prev) => !prev);
setMatchIndex(0);
doSearch(0);
};
const handleToggleWholeWord = () => {
setWholeWord((prev) => !prev);
setMatchIndex(0);
doSearch(0);
};
const handleNext = () => {
if (!searchMatches.current || !searchMatches.current.length) return;
const next = (matchIndex + 1) % searchMatches.current.length;
let next = (matchIndex + 1) % searchMatches.current.length;
setMatchIndex(next);
doSearch(next);
};
const handlePrev = () => {
if (!searchMatches.current || !searchMatches.current.length) return;
const prev = (matchIndex - 1 + searchMatches.current.length) % searchMatches.current.length;
let prev = (matchIndex - 1 + searchMatches.current.length) % searchMatches.current.length;
setMatchIndex(prev);
doSearch(prev);
};
@@ -225,7 +168,6 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
<StyledWrapper>
<div className="bruno-search-bar">
<input
ref={inputRef}
autoFocus
type="text"
value={searchText}
@@ -254,6 +196,6 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
</div>
</StyledWrapper>
);
});
};
export default CodeMirrorSearch;

View File

@@ -1,61 +0,0 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
.code-snippet {
font-family: monospace;
font-size: ${(props) => props.theme.font.size.xs};
line-height: 1.4;
overflow-x: auto;
border-radius: ${(props) => props.theme.border.radius.base};
background-color: ${(props) => props.theme.background.elevated};
border: 1px solid ${(props) => props.theme.border.border2};
}
.code-line {
display: flex;
align-items: stretch;
}
.code-line.highlighted-error {
background-color: ${(props) => rgba(props.theme.colors.text.danger, 0.1)};
border-left: 3px solid ${(props) => props.theme.colors.text.danger};
}
.code-line.highlighted-warning {
background-color: ${(props) => rgba(props.theme.colors.text.warning, 0.1)};
border-left: 3px solid ${(props) => props.theme.colors.text.warning};
}
.code-line:not(.highlighted-error):not(.highlighted-warning) {
border-left: 3px solid transparent;
}
.code-line-number {
min-width: 2.5rem;
text-align: right;
padding: 0 0.5rem;
color: ${(props) => props.theme.colors.text.muted};
user-select: none;
flex-shrink: 0;
}
.code-line-content {
white-space: pre;
padding: 0 0.5rem;
flex: 1;
min-width: 0;
}
.code-line-separator {
border-left: 3px solid transparent;
}
.separator-content {
color: ${(props) => props.theme.colors.text.muted};
user-select: none;
padding: 0 0.5rem;
}
`;
export default StyledWrapper;

View File

@@ -1,55 +0,0 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
const renderLine = (line, highlightClass, hunkIdx) => {
const isHighlighted = line.isHighlighted || line.isError;
const key = hunkIdx != null ? `${hunkIdx}-${line.lineNumber}` : line.lineNumber;
return (
<div
key={key}
className={`code-line ${isHighlighted ? highlightClass : ''}`}
data-testid={isHighlighted ? 'code-line-error' : 'code-line'}
>
<span className="code-line-number">{line.lineNumber}</span>
<span className="code-line-content">
{isHighlighted ? '> ' : ' '}{line.content}
</span>
</div>
);
};
const CodeSnippet = ({ lines, hunks, variant = 'error' }) => {
const highlightClass = variant === 'warning' ? 'highlighted-warning' : 'highlighted-error';
if (hunks?.length) {
return (
<StyledWrapper>
<div className="code-snippet" data-testid="code-snippet">
{hunks.map((hunk, idx) => (
<React.Fragment key={idx}>
{hunk.hasSeparatorBefore && (
<div className="code-line code-line-separator">
<span className="code-line-number"></span>
<span className="code-line-content separator-content">{'\u22EE'}</span>
</div>
)}
{hunk.lines.map((line) => renderLine(line, highlightClass, idx))}
</React.Fragment>
))}
</div>
</StyledWrapper>
);
}
if (!lines?.length) return null;
return (
<StyledWrapper>
<div className="code-snippet" data-testid="code-snippet">
{lines.map((line) => renderLine(line, highlightClass))}
</div>
</StyledWrapper>
);
};
export default CodeSnippet;

View File

@@ -1,140 +0,0 @@
import '@testing-library/jest-dom';
import React from 'react';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from 'styled-components';
import CodeSnippet from './index';
const theme = {
font: { size: { xs: '0.75rem' } },
background: { elevated: '#f5f5f5' },
border: { border2: '#e0e0e0', radius: { base: '4px' } },
colors: { text: { danger: '#ef4444', warning: '#f59e0b', muted: '#999' } }
};
const renderWithTheme = (component) => {
return render(
<ThemeProvider theme={theme}>
{component}
</ThemeProvider>
);
};
const sampleLines = [
{ lineNumber: 3, content: 'const a = 1;', isHighlighted: false },
{ lineNumber: 4, content: 'undefinedVar.foo();', isHighlighted: true },
{ lineNumber: 5, content: 'const b = 2;', isHighlighted: false }
];
describe('CodeSnippet', () => {
it('should render nothing when lines is empty', () => {
const { container } = renderWithTheme(<CodeSnippet lines={[]} />);
expect(container.firstChild).toBeNull();
});
it('should render nothing when lines is null', () => {
const { container } = renderWithTheme(<CodeSnippet lines={null} />);
expect(container.firstChild).toBeNull();
});
it('should render all lines with line numbers', () => {
renderWithTheme(<CodeSnippet lines={sampleLines} />);
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
});
it('should apply error highlight class by default', () => {
const { container } = renderWithTheme(<CodeSnippet lines={sampleLines} variant="error" />);
const highlightedLine = container.querySelector('.highlighted-error');
expect(highlightedLine).toBeInTheDocument();
});
it('should apply warning highlight class when variant is warning', () => {
const { container } = renderWithTheme(<CodeSnippet lines={sampleLines} variant="warning" />);
const highlightedLine = container.querySelector('.highlighted-warning');
expect(highlightedLine).toBeInTheDocument();
expect(container.querySelector('.highlighted-error')).not.toBeInTheDocument();
});
it('should show > prefix on highlighted line for accessibility', () => {
const { container } = renderWithTheme(<CodeSnippet lines={sampleLines} />);
const codeLineContents = container.querySelectorAll('.code-line-content');
// The highlighted line (index 1) should start with "> "
expect(codeLineContents[1].textContent).toContain('> ');
// Non-highlighted lines should not have ">"
expect(codeLineContents[0].textContent).not.toContain('>');
});
it('should also support isError property for backward compatibility', () => {
const linesWithIsError = [
{ lineNumber: 1, content: 'line 1', isError: false },
{ lineNumber: 2, content: 'error line', isError: true },
{ lineNumber: 3, content: 'line 3', isError: false }
];
const { container } = renderWithTheme(<CodeSnippet lines={linesWithIsError} />);
expect(container.querySelector('.highlighted-error')).toBeInTheDocument();
});
describe('hunks prop', () => {
const sampleHunks = [
{
hasSeparatorBefore: false,
lines: [
{ lineNumber: 1, content: 'const a = true;', isHighlighted: false },
{ lineNumber: 2, content: 'pm.vault.get();', isHighlighted: true },
{ lineNumber: 3, content: 'const b = false;', isHighlighted: false }
]
},
{
hasSeparatorBefore: true,
lines: [
{ lineNumber: 10, content: 'const x = null;', isHighlighted: false },
{ lineNumber: 11, content: 'pm.cookies.jar();', isHighlighted: true },
{ lineNumber: 12, content: 'const y = undefined;', isHighlighted: false }
]
}
];
it('should render all lines from all hunks', () => {
renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
// line numbers
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('10')).toBeInTheDocument();
expect(screen.getByText('11')).toBeInTheDocument();
// content
expect(screen.getByText(/const a = true;/)).toBeInTheDocument();
expect(screen.getByText(/pm\.vault\.get\(\);/)).toBeInTheDocument();
expect(screen.getByText(/const x = null;/)).toBeInTheDocument();
expect(screen.getByText(/pm\.cookies\.jar\(\);/)).toBeInTheDocument();
});
it('should render separator between hunks when hasSeparatorBefore is true', () => {
const { container } = renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
const separators = container.querySelectorAll('.code-line-separator');
expect(separators).toHaveLength(1);
// separator should appear between the two hunks, not before the first
const allRows = container.querySelectorAll('.code-line, .code-line-separator');
const separatorIndex = Array.from(allRows).findIndex((el) => el.classList.contains('code-line-separator'));
// first hunk has 3 lines (indices 0-2), separator should be at index 3
expect(separatorIndex).toBe(3);
});
it('should render the ellipsis character in separator', () => {
const { container } = renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
const separator = container.querySelector('.separator-content');
expect(separator.textContent).toBe('\u22EE');
});
it('should apply warning highlights within hunks', () => {
const { container } = renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
const highlighted = container.querySelectorAll('.highlighted-warning');
expect(highlighted).toHaveLength(2);
});
it('should render nothing when hunks is empty array', () => {
const { container } = renderWithTheme(<CodeSnippet hunks={[]} />);
expect(container.firstChild).toBeNull();
});
});
});

View File

@@ -51,11 +51,6 @@ const AuthMode = ({ collection }) => {
label: 'NTLM Auth',
onClick: () => onModeChange('ntlm')
},
{
id: 'oauth1',
label: 'OAuth 1.0',
onClick: () => onModeChange('oauth1')
},
{
id: 'oauth2',
label: 'OAuth 2.0',

View File

@@ -1,26 +0,0 @@
import React from 'react';
import get from 'lodash/get';
import { useDispatch } from 'react-redux';
import OAuth1 from 'components/RequestPane/Auth/OAuth1';
import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
const CollectionOAuth1 = ({ collection }) => {
const dispatch = useDispatch();
const request = collection.draft?.root
? get(collection, 'draft.root.request', {})
: get(collection, 'root.request', {});
const save = () => dispatch(saveCollectionSettings(collection.uid));
return (
<OAuth1
collection={collection}
request={request}
save={save}
updateAuth={updateCollectionAuth}
/>
);
};
export default CollectionOAuth1;

View File

@@ -12,7 +12,6 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/
import StyledWrapper from './StyledWrapper';
import OAuth2 from './OAuth2';
import NTLMAuth from './NTLMAuth';
import OAuth1 from './Oauth1';
import Button from 'ui/Button';
const Auth = ({ collection }) => {
@@ -38,9 +37,6 @@ const Auth = ({ collection }) => {
case 'ntlm': {
return <NTLMAuth collection={collection} />;
}
case 'oauth1': {
return <OAuth1 collection={collection} />;
}
case 'oauth2': {
return <OAuth2 collection={collection} />;
}

View File

@@ -1,25 +1,9 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
position: relative;
height: 100%;
overflow-y: auto;
.editing-mode {
cursor: pointer;
position: sticky;
top: 0;
z-index: 10;
background: ${(props) => props.theme.bg};
padding: 6px 0;
margin-bottom: 10px;
display: flex;
justify-content: flex-end;
}
.markdown-body {
height: auto !important;
overflow-y: visible !important;
}
`;

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,10 +1,9 @@
import React, { useState, useCallback, useRef } from 'react';
import React, { useState, useCallback } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { setCollectionHeaders } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import SingleLineEditor from 'components/SingleLineEditor';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
@@ -12,32 +11,16 @@ import { headers as StandardHTTPHeaders } from 'know-your-http-well';
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import BulkEditor from 'components/BulkEditor/index';
import Button from 'ui/Button';
import { headerNameRegex, headerValueRegex } from 'utils/common/regex';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const Headers = ({ collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const headers = collection.draft?.root
? get(collection, 'draft.root.request.headers', [])
: get(collection, 'root.request.headers', []);
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `collection-headers-scroll-${collection.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.collection-settings-content', onChange: setScroll, initialValue: scroll });
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const collectionHeadersWidths = focusedTab?.tableColumnWidths?.['collection-headers'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
@@ -49,22 +32,6 @@ const Headers = ({ collection }) => {
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
const getRowError = useCallback((row, index, key) => {
if (key === 'name') {
if (!row.name || row.name.trim() === '') return null;
if (!headerNameRegex.test(row.name)) {
return 'Header name cannot contain spaces or newlines';
}
}
if (key === 'value') {
if (!row.value) return null;
if (!headerValueRegex.test(row.value)) {
return 'Header value cannot contain newlines';
}
}
return null;
}, []);
const columns = [
{
key: 'name',
@@ -72,7 +39,7 @@ const Headers = ({ collection }) => {
isKeyField: true,
placeholder: 'Name',
width: '30%',
render: ({ value, onChange }) => (
render: ({ row, value, onChange, isLastEmptyRow }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
@@ -80,7 +47,7 @@ const Headers = ({ collection }) => {
onChange={(newValue) => onChange(newValue.replace(/[\r\n]/g, ''))}
autocomplete={headerAutoCompleteList}
collection={collection}
placeholder={!value ? 'Name' : ''}
placeholder={isLastEmptyRow ? 'Name' : ''}
/>
)
},
@@ -88,7 +55,7 @@ const Headers = ({ collection }) => {
key: 'value',
name: 'Value',
placeholder: 'Value',
render: ({ value, onChange }) => (
render: ({ row, value, onChange, isLastEmptyRow }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
@@ -96,7 +63,7 @@ const Headers = ({ collection }) => {
onChange={onChange}
collection={collection}
autocomplete={MimeTypes}
placeholder={!value ? 'Value' : ''}
placeholder={isLastEmptyRow ? 'Value' : ''}
/>
)
}
@@ -125,23 +92,18 @@ const Headers = ({ collection }) => {
}
return (
<StyledWrapper className="h-full w-full" ref={wrapperRef}>
<StyledWrapper className="h-full w-full">
<div className="text-xs mb-4 text-muted">
Add request headers that will be sent with every request in this collection.
</div>
<EditableTable
tableId="collection-headers"
columns={columns}
rows={headers}
onChange={handleHeadersChange}
defaultRow={defaultRow}
getRowError={getRowError}
columnWidths={collectionHeadersWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('collection-headers', widths)}
initialScroll={scroll}
/>
<div className="flex justify-end mt-2">
<button className="text-link select-none" data-testid="bulk-edit-toggle" onClick={toggleBulkEditMode}>
<button className="text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
</div>

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

@@ -1,58 +1,32 @@
import React, { useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import get from 'lodash/get';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import { updateCollectionRequestScript, updateCollectionResponseScript } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
import StatusDot from 'components/StatusDot';
import { flattenItems, isItemARequest } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
const Script = ({ collection }) => {
const dispatch = useDispatch();
const [activeTab, setActiveTab] = useState('pre-request');
const preRequestEditorRef = useRef(null);
const postResponseEditorRef = useRef(null);
const requestScript = collection.draft?.root ? get(collection, 'draft.root.request.script.req', '') : get(collection, 'root.request.script.req', '');
const responseScript = collection.draft?.root ? get(collection, 'draft.root.request.script.res', '') : get(collection, 'root.request.script.res', '');
const tabs = useSelector((state) => state.tabs.tabs);
const focusedTab = find(tabs, (t) => t.uid === collection.uid);
const scriptPaneTab = focusedTab?.scriptPaneTab;
const getDefaultTab = () => {
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
return hasPreRequestScript ? 'pre-request' : 'post-response';
};
const activeTab = scriptPaneTab || getDefaultTab();
const setActiveTab = (tab) => {
dispatch(updateScriptPaneTab({ uid: collection.uid, scriptPaneTab: tab }));
};
const { 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);
@@ -81,10 +55,6 @@ const Script = ({ collection }) => {
dispatch(saveCollectionSettings(collection.uid));
};
const items = flattenItems(collection.items || []);
const hasPreRequestScriptError = items.some((i) => isItemARequest(i) && i.preRequestScriptErrorMessage);
const hasPostResponseScriptError = items.some((i) => isItemARequest(i) && i.postResponseScriptErrorMessage);
return (
<StyledWrapper className="w-full flex flex-col h-full">
<div className="text-xs mb-4 text-muted">
@@ -93,25 +63,14 @@ const Script = ({ collection }) => {
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="pre-request">
Pre Request
{requestScript && requestScript.trim().length > 0 && (
<StatusDot type={hasPreRequestScriptError ? 'error' : 'default'} />
)}
</TabsTrigger>
<TabsTrigger value="post-response">
Post Response
{responseScript && responseScript.trim().length > 0 && (
<StatusDot type={hasPostResponseScriptError ? 'error' : 'default'} />
)}
</TabsTrigger>
<TabsTrigger value="pre-request">Pre Request</TabsTrigger>
<TabsTrigger value="post-response">Post Response</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 +79,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 +94,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,10 +1,6 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.markdown-body {
height: auto !important;
overflow-y: visible !important;
}
div.tabs {
div.tab {
padding: 6px 0px;
@@ -28,8 +24,7 @@ const StyledWrapper = styled.div`
}
&.active {
font-weight: ${(props) =>
props.theme.tabs.active.fontWeight} !important;
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}
@@ -50,7 +45,7 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.muted};
}
input[type="radio"] {
input[type='radio'] {
cursor: pointer;
accent-color: ${(props) => props.theme.primary.solid};
}

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

@@ -1,8 +1,7 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import MultiLineEditor from 'components/MultiLineEditor';
import InfoTip from 'components/InfoTip';
import EditableTable from 'components/EditableTable';
@@ -11,19 +10,9 @@ import toast from 'react-hot-toast';
import { variableNameRegex } from 'utils/common/regex';
import { setCollectionVars } from 'providers/ReduxStore/slices/collections/index';
const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => {
const VarsTable = ({ collection, vars, varType }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const collectionVarsWidths = focusedTab?.tableColumnWidths?.['collection-vars'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const onSave = () => dispatch(saveCollectionSettings(collection.uid));
@@ -57,14 +46,14 @@ const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => {
</div>
),
placeholder: varType === 'request' ? 'Value' : 'Expr',
render: ({ value, onChange }) => (
render: ({ row, value, onChange, isLastEmptyRow }) => (
<MultiLineEditor
value={value || ''}
theme={storedTheme}
onSave={onSave}
onChange={onChange}
collection={collection}
placeholder={!value ? (varType === 'request' ? 'Value' : 'Expr') : ''}
placeholder={isLastEmptyRow ? (varType === 'request' ? 'Value' : 'Expr') : ''}
/>
)
}
@@ -79,15 +68,11 @@ const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => {
return (
<StyledWrapper className="w-full">
<EditableTable
tableId="collection-vars"
columns={columns}
rows={vars}
onChange={handleVarsChange}
defaultRow={defaultRow}
getRowError={getRowError}
columnWidths={collectionVarsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('collection-vars', widths)}
initialScroll={initialScroll}
/>
</StyledWrapper>
);

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

@@ -106,47 +106,47 @@ const CollectionSettings = ({ collection }) => {
return (
<StyledWrapper className="flex flex-col h-full relative px-4 py-4 overflow-hidden">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('overview')} role="tab" data-testid="collection-settings-tab-overview" onClick={() => setTab('overview')}>
<div className={getTabClassname('overview')} role="tab" onClick={() => setTab('overview')}>
Overview
</div>
<div className={getTabClassname('headers')} role="tab" data-testid="collection-settings-tab-headers" onClick={() => setTab('headers')}>
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
Headers
{activeHeadersCount > 0 && <sup className="ml-1 font-medium">{activeHeadersCount}</sup>}
</div>
<div className={getTabClassname('vars')} role="tab" data-testid="collection-settings-tab-vars" onClick={() => setTab('vars')}>
<div className={getTabClassname('vars')} role="tab" onClick={() => setTab('vars')}>
Vars
{activeVarsCount > 0 && <sup className="ml-1 font-medium">{activeVarsCount}</sup>}
</div>
<div className={getTabClassname('auth')} role="tab" data-testid="collection-settings-tab-auth" onClick={() => setTab('auth')}>
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
Auth
{authMode !== 'none' && <StatusDot />}
</div>
<div className={getTabClassname('script')} role="tab" data-testid="collection-settings-tab-script" onClick={() => setTab('script')}>
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
Script
{hasScripts && <StatusDot />}
</div>
<div className={getTabClassname('tests')} role="tab" data-testid="collection-settings-tab-tests" onClick={() => setTab('tests')}>
<div className={getTabClassname('tests')} role="tab" onClick={() => setTab('tests')}>
Tests
{hasTests && <StatusDot />}
</div>
<div className={getTabClassname('presets')} role="tab" data-testid="collection-settings-tab-presets" onClick={() => setTab('presets')}>
<div className={getTabClassname('presets')} role="tab" onClick={() => setTab('presets')}>
Presets
{hasPresets && <StatusDot />}
</div>
<div className={getTabClassname('proxy')} role="tab" data-testid="collection-settings-tab-proxy" onClick={() => setTab('proxy')}>
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
Proxy
{Object.keys(proxyConfig).length > 0 && proxyEnabled && <StatusDot />}
</div>
<div className={getTabClassname('clientCert')} role="tab" data-testid="collection-settings-tab-clientCert" onClick={() => setTab('clientCert')}>
<div className={getTabClassname('clientCert')} role="tab" onClick={() => setTab('clientCert')}>
Client Certificates
{clientCertConfig.length > 0 && <StatusDot />}
</div>
<div className={getTabClassname('protobuf')} role="tab" data-testid="collection-settings-tab-protobuf" onClick={() => setTab('protobuf')}>
<div className={getTabClassname('protobuf')} role="tab" onClick={() => setTab('protobuf')}>
Protobuf
{protobufConfig.protoFiles && protobufConfig.protoFiles.length > 0 && <StatusDot />}
</div>
</div>
<section className="collection-settings-content mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
<section className="mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
</StyledWrapper>
);
};

View File

@@ -1,20 +0,0 @@
import React from 'react';
import { useTheme } from 'providers/Theme';
const ColorBadge = ({ color, size = 10 }) => {
const sizeValue = typeof size === 'string' ? size : `${size}px`;
const { theme } = useTheme();
return (
<div
className="flex-shrink-0 rounded-full"
style={{
width: sizeValue,
height: sizeValue,
backgroundColor: color || 'transparent'
}}
/>
);
};
export default ColorBadge;

View File

@@ -1,164 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { IconBan, IconBrush } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import ColorBadge from 'components/ColorBadge';
import StyledWrapper from './StyledWrapper';
import { parseToRgb, toColorString } from 'polished';
import ColorRangePicker from 'components/ColorRange/index';
const PRESET_COLORS = [
'#CE4F3B',
'#2E8A54',
'#346AB2',
'#C77A0F',
'#B83D7F',
'#8D44B2'
];
const COLOR_RANGE_SEQUENCE = ['#D85D43', '#F4BB74', '#61DCB1', '#7EBDF2', '#D48ADE', '#B491E5'];
/**
* @param {string} hex
* @returns {red:string,green:string,blue:string}
*/
const hexToRgb = (hex) => {
try {
return parseToRgb(hex);
} catch (err) {
return { red: 0, green: 0, blue: 0 };
}
};
const rgbToHex = (r, g, b) => {
return toColorString({ red: Math.round(r), green: Math.round(g), blue: Math.round(b) });
};
const interpolateColor = (position) => {
const numColors = COLOR_RANGE_SEQUENCE.length;
const scaledPos = (position / 100) * (numColors - 1);
const index = Math.floor(scaledPos);
const fraction = scaledPos - index;
if (index >= numColors - 1) {
return COLOR_RANGE_SEQUENCE[numColors - 1];
}
const color1 = hexToRgb(COLOR_RANGE_SEQUENCE[index]);
const color2 = hexToRgb(COLOR_RANGE_SEQUENCE[index + 1]);
const r = color1.red + (color2.red - color1.red) * fraction;
const g = color1.green + (color2.green - color1.green) * fraction;
const b = color1.blue + (color2.blue - color1.blue) * fraction;
return rgbToHex(r, g, b);
};
const findClosestPosition = (hex) => {
if (!hex) return 0;
const target = hexToRgb(hex);
let closestPos = 0;
let minDistance = Infinity;
for (let pos = 0; pos <= 100; pos++) {
const color = hexToRgb(interpolateColor(pos));
const distance = Math.sqrt(
Math.pow(target.red - color.red, 2) + Math.pow(target.green - color.green, 2) + Math.pow(target.blue - color.blue, 2)
);
if (distance < minDistance) {
minDistance = distance;
closestPos = pos;
}
}
return closestPos;
};
const ColorPickerIcon = ({ color }) => {
if (color) {
return <ColorBadge color={color} size={8} />;
}
return <IconBrush size={14} strokeWidth={1.5} className="opacity-70" />;
};
const ColorPicker = ({ color, onChange, icon }) => {
const [sliderPosition, setSliderPosition] = useState(() =>
color && !PRESET_COLORS.includes(color) ? findClosestPosition(color) : 0
);
const [customColor, setCustomColor] = useState(() =>
color && !PRESET_COLORS.includes(color) ? color : COLOR_RANGE_SEQUENCE[0]
);
const pendingColorRef = useRef(customColor);
const handleColorSelect = (selectedColor) => {
onChange(selectedColor);
};
const handleSliderChange = (e) => {
const newPosition = parseInt(e.target.value, 10);
setSliderPosition(newPosition);
const newColor = interpolateColor(newPosition);
setCustomColor(newColor);
pendingColorRef.current = newColor;
};
const handleSliderEnd = () => {
onChange(pendingColorRef.current);
};
const defaultIcon = (
<div className="cursor-pointer flex items-center" title="Change color">
<ColorPickerIcon color={color} />
</div>
);
const colorPickerContent = (
<StyledWrapper>
<div className="p-2">
<div className="flex flex-wrap gap-1.5 justify-between items-center">
<div
className="w-5 h-5 cursor-pointer flex items-center justify-center transition-transform duration-100 hover:scale-110"
onClick={() => handleColorSelect(null)}
title="No color"
>
<IconBan size={20} strokeWidth={1.5} />
</div>
{PRESET_COLORS.map((presetColor, index) => (
<div
key={index}
className={`w-5 h-5 rounded cursor-pointer flex items-center justify-center transition-transform duration-100 hover:scale-110 border-2 border-transparent
${color === presetColor ? 'border-solid !border-current' : ''}
`}
style={{ backgroundColor: presetColor }}
onClick={() => handleColorSelect(presetColor)}
title={presetColor}
/>
))}
</div>
<div className="flex items-center gap-2 mt-2 pt-0.5">
<div
className="w-5 h-5 rounded-full flex-shrink-0 cursor-pointer"
style={{ backgroundColor: customColor }}
onClick={() => handleColorSelect(customColor)}
title="Custom color"
/>
<ColorRangePicker
className="flex-1 flex"
value={sliderPosition}
onChange={handleSliderChange}
onMouseUp={handleSliderEnd}
selectedColor={customColor}
colorRange={COLOR_RANGE_SEQUENCE}
/>
</div>
</div>
</StyledWrapper>
);
return (
<Dropdown icon={icon || defaultIcon} placement="bottom-start">
{colorPickerContent}
</Dropdown>
);
};
export default ColorPicker;

View File

@@ -1,46 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.hue-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border-radius: 2px;
outline: none;
}
.hue-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: ${(props) => props.color ?? props.theme.bg};
border: none;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: transform 0.1s ease;
}
.hue-slider::-webkit-slider-thumb:hover {
transform: scale(1.1);
}
.hue-slider::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: ${(props) => props.color ?? props.theme.bg};
border: none;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: transform 0.1s ease;
}
.hue-slider::-moz-range-thumb:hover {
transform: scale(1.1);
}
`;
export default StyledWrapper;

View File

@@ -1,23 +0,0 @@
import StyledWrapper from './StyledWrapper';
const ColorRangePicker = ({ selectedColor, className, value, onChange, colorRange, ...props }) => {
return (
<StyledWrapper color={selectedColor} className={className}>
<input
type="range"
min="0"
max="100"
value={value}
onChange={onChange}
className="hue-slider"
style={{
background: `linear-gradient(to right, ${colorRange.join(',')})`
}}
title="Adjust color"
{...props}
/>
</StyledWrapper>
);
};
export default ColorRangePicker;

View File

@@ -1,246 +0,0 @@
import React, { useState, useRef, useCallback, useMemo } from 'react';
import { IconPlus, IconApi, IconBrandGraphql, IconPlugConnected, IconCode } from '@tabler/icons';
import ActionIcon from 'ui/ActionIcon/index';
import Dropdown from 'components/Dropdown';
import { newHttpRequest, newGrpcRequest, newWsRequest } from 'providers/ReduxStore/slices/collections/actions';
import { sanitizeName } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { useDispatch, useSelector } from 'react-redux';
import { flattenItems, isItemARequest, isItemTransientRequest } from 'utils/collections';
import filter from 'lodash/filter';
import { get } from 'lodash';
import { formatIpcError } from 'utils/common/error';
const REQUEST_TYPE = {
HTTP: 'http',
GRAPHQL: 'graphql',
GRPC: 'grpc',
WEBSOCKET: 'websocket'
};
/**
* Generate a request name for transient requests in the pattern "Untitled {Count}"
* @param {Object} collection - The collection object
* @returns {string} A request name like "Untitled 1", "Untitled 2", etc.
*/
const generateTransientRequestName = (collection) => {
if (!collection || !collection.items) {
return 'Untitled 1';
}
const allItems = flattenItems(collection.items);
const transientRequests = filter(allItems, (item) => {
return isItemTransientRequest(item);
});
// Find the highest "Untitled X" number among transient requests
let maxNumber = 0;
transientRequests.forEach((item) => {
const match = item.name?.match(/^Untitled (\d+)$/);
if (match) {
const number = parseInt(match[1], 10);
if (number > maxNumber) {
maxNumber = number;
}
}
});
// Increment from the highest number found, or start at 1 if none found
const count = maxNumber + 1;
return `Untitled ${count}`;
};
const CreateTransientRequest = ({ collectionUid }) => {
const [dropdownVisible, setDropdownVisible] = useState(false);
const dropdownTippyRef = useRef();
const dispatch = useDispatch();
const collections = useSelector((state) => state.collections.collections);
const collection = useMemo(() => {
return collections?.find((c) => c.uid === collectionUid);
}, [collections, collectionUid]);
const collectionPresets = useMemo(() => {
return get(collection, collection?.draft?.brunoConfig ? 'draft.brunoConfig.presets' : 'brunoConfig.presets', {
requestType: 'http',
requestUrl: ''
});
}, [collection]);
const onDropdownCreate = (ref) => {
dropdownTippyRef.current = ref;
if (ref) {
ref.setProps({
onHide: () => {
setDropdownVisible(false);
}
});
}
};
const handleLeftClick = () => {
handleItemClick(collectionPresets.requestType);
};
const handleRightClick = (e) => {
e.preventDefault();
setDropdownVisible(true);
};
const handleCreateHttpRequest = useCallback(() => {
if (!collection) return;
const uniqueName = generateTransientRequestName(collection);
const filename = sanitizeName(uniqueName);
dispatch(
newHttpRequest({
requestName: uniqueName,
filename: filename,
requestType: 'http-request',
requestUrl: collectionPresets.requestUrl,
requestMethod: 'GET',
collectionUid: collection.uid,
itemUid: null,
isTransient: true
})
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
}, [dispatch, collection, collectionPresets.requestUrl]);
const handleCreateGraphQLRequest = useCallback(() => {
if (!collection) return;
const uniqueName = generateTransientRequestName(collection);
const filename = sanitizeName(uniqueName);
dispatch(
newHttpRequest({
requestName: uniqueName,
filename: filename,
requestType: 'graphql-request',
requestUrl: collectionPresets.requestUrl,
requestMethod: 'POST',
collectionUid: collection.uid,
itemUid: null,
isTransient: true,
body: {
mode: 'graphql',
graphql: {
query: '',
variables: ''
}
}
})
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
}, [dispatch, collection, collectionPresets.requestUrl]);
const handleCreateWebSocketRequest = useCallback(() => {
if (!collection) return;
const uniqueName = generateTransientRequestName(collection);
const filename = sanitizeName(uniqueName);
dispatch(
newWsRequest({
requestName: uniqueName,
filename: filename,
requestUrl: collectionPresets.requestUrl,
requestMethod: 'ws',
collectionUid: collection.uid,
itemUid: null,
isTransient: true
})
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
}, [dispatch, collection, collectionPresets.requestUrl]);
const handleCreateGrpcRequest = useCallback(() => {
if (!collection) return;
const uniqueName = generateTransientRequestName(collection);
const filename = sanitizeName(uniqueName);
dispatch(
newGrpcRequest({
requestName: uniqueName,
filename: filename,
requestUrl: collectionPresets.requestUrl,
collectionUid: collection.uid,
itemUid: null,
isTransient: true
})
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
}, [dispatch, collection, collectionPresets.requestUrl]);
const handleItemClick = (type) => {
if (dropdownTippyRef.current) {
dropdownTippyRef.current.hide();
}
switch (type) {
case REQUEST_TYPE.HTTP:
handleCreateHttpRequest();
break;
case REQUEST_TYPE.GRAPHQL:
handleCreateGraphQLRequest();
break;
case REQUEST_TYPE.GRPC:
handleCreateGrpcRequest();
break;
case REQUEST_TYPE.WEBSOCKET:
handleCreateWebSocketRequest();
break;
}
};
if (!collection) {
return null;
}
const IconButton = (
<ActionIcon
onClick={handleLeftClick}
onContextMenu={handleRightClick}
aria-label="New Transient Request"
size="lg"
style={{ marginBottom: '3px' }}
>
<IconPlus size={18} strokeWidth={1.5} />
</ActionIcon>
);
return (
<Dropdown
icon={IconButton}
visible={dropdownVisible}
onCreate={onDropdownCreate}
onClickOutside={() => setDropdownVisible(false)}
placement="bottom-end"
>
<div className="dropdown-item" onClick={() => handleItemClick(REQUEST_TYPE.HTTP)}>
<div className="dropdown-icon">
<IconApi size={16} strokeWidth={2} />
</div>
<div className="dropdown-label">HTTP</div>
</div>
<div className="dropdown-item" onClick={() => handleItemClick(REQUEST_TYPE.GRAPHQL)}>
<div className="dropdown-icon">
<IconBrandGraphql size={16} strokeWidth={2} />
</div>
<div className="dropdown-label">GraphQL</div>
</div>
<div className="dropdown-item" onClick={() => handleItemClick(REQUEST_TYPE.GRPC)}>
<div className="dropdown-icon">
<IconCode size={16} strokeWidth={2} />
</div>
<div className="dropdown-label">gRPC</div>
</div>
<div className="dropdown-item" onClick={() => handleItemClick(REQUEST_TYPE.WEBSOCKET)}>
<div className="dropdown-icon">
<IconPlugConnected size={16} strokeWidth={2} />
</div>
<div className="dropdown-label">WebSocket</div>
</div>
</Dropdown>
);
};
export default CreateTransientRequest;

View File

@@ -113,7 +113,7 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
return (
<StyledSessionList>
{sessions.map((session, idx) => {
{sessions.map((session) => {
const { name } = getSessionDisplayInfo(session);
return (
<ToolHint
@@ -125,7 +125,6 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
>
<div
className={`session-list-item ${activeSessionId === session.sessionId ? 'active' : ''}`}
data-testid={`session-list-${idx}`}
onClick={() => onSelectSession(session.sessionId)}
>
<div className="session-name">
@@ -134,7 +133,6 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
</div>
<div
className="session-close-btn"
data-testid={`session-close-${idx}`}
onClick={(e) => {
e.stopPropagation();
onCloseSession(session.sessionId);

View File

@@ -2,37 +2,10 @@ import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { IconTerminal2, IconPlus } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
import SessionList from './SessionList';
import '@xterm/xterm/css/xterm.css';
// Build xterm.js theme from app theme
const getTerminalTheme = (theme) => {
return {
background: theme.console.bg,
foreground: theme.console.messageColor,
cursor: theme.console.messageColor,
selectionBackground: theme.status.info.background,
black: theme.background.base,
red: theme.status.danger.text,
green: theme.status.success.text,
yellow: theme.status.warning.text,
blue: theme.status.info.text,
magenta: theme.colors.text.purple,
cyan: theme.codemirror.variable.prompt,
white: theme.text,
brightBlack: theme.colors.text.muted,
brightRed: theme.status.danger.text,
brightGreen: theme.status.success.text,
brightYellow: theme.status.warning.text,
brightBlue: theme.status.info.text,
brightMagenta: theme.colors.text.purple,
brightCyan: theme.codemirror.variable.prompt,
brightWhite: theme.text
};
};
// Terminal instances per session - Map<sessionId, { terminal, fitAddon, inputDisposable, resizeDisposable }>
const terminalInstances = new Map();
@@ -60,7 +33,7 @@ const ensureParkingHost = () => {
return parkingHost;
};
const createTerminalForSession = (sessionId, terminalTheme) => {
const createTerminalForSession = (sessionId) => {
if (terminalInstances.has(sessionId)) {
return terminalInstances.get(sessionId);
}
@@ -69,7 +42,28 @@ const createTerminalForSession = (sessionId, terminalTheme) => {
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: terminalTheme,
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#d4d4d4',
selection: '#264f78',
black: '#1e1e1e',
red: '#f14c4c',
green: '#23d18b',
yellow: '#f5f543',
blue: '#3b8eea',
magenta: '#d670d6',
cyan: '#29b8db',
white: '#e5e5e5',
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#e5e5e5'
},
allowProposedApi: true
});
@@ -162,10 +156,10 @@ const cleanupTerminalInstance = (sessionId) => {
}
};
const openTerminalIntoContainer = async (container, sessionId, terminalTheme) => {
const openTerminalIntoContainer = async (container, sessionId) => {
if (!container || !sessionId) return;
const instance = createTerminalForSession(sessionId, terminalTheme);
const instance = createTerminalForSession(sessionId);
const { terminal, fitAddon } = instance;
if (!terminal.element) {
@@ -180,7 +174,6 @@ const openTerminalIntoContainer = async (container, sessionId, terminalTheme) =>
await new Promise((resolve) => setTimeout(resolve, 50));
try {
fitAddon.fit();
terminal.focus();
const { cols, rows } = terminal;
if (cols && rows && window.ipcRenderer) {
window.ipcRenderer.send('terminal:resize', sessionId, { cols, rows });
@@ -218,8 +211,6 @@ const TerminalTab = () => {
const [sessions, setSessions] = useState([]);
const [activeSessionId, setActiveSessionId] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const { theme } = useTheme();
const terminalTheme = getTerminalTheme(theme);
// Load sessions list
const loadSessions = useCallback(async (currentActiveSessionId = null) => {
@@ -363,15 +354,6 @@ const TerminalTab = () => {
};
}, []);
// Update all terminal themes when app theme changes
useEffect(() => {
terminalInstances.forEach((instance) => {
if (instance.terminal) {
instance.terminal.options.theme = terminalTheme;
}
});
}, [theme.mode]);
// Handle terminal display for active session
useEffect(() => {
if (!activeSessionId || !terminalRef.current) return;
@@ -379,7 +361,7 @@ const TerminalTab = () => {
let mounted = true;
const setupTerminal = async () => {
await openTerminalIntoContainer(terminalRef.current, activeSessionId, terminalTheme);
await openTerminalIntoContainer(terminalRef.current, activeSessionId);
if (mounted) {
const instance = terminalInstances.get(activeSessionId);

View File

@@ -64,89 +64,6 @@ const LogTimestamp = ({ timestamp }) => {
return <span className="log-timestamp">{time}</span>;
};
// Helper function to check if an object is a plain object (not a class instance)
const isPlainObject = (obj) => {
if (typeof obj !== 'object' || obj === null) return false;
const proto = Object.getPrototypeOf(obj);
return proto === null || proto === Object.prototype;
};
// Helper function to transform Bruno special types back to readable format
// Extracted outside component to avoid recreation on every render
const transformBrunoTypes = (obj, seen = new WeakSet()) => {
if (typeof obj !== 'object' || obj === null) {
return obj;
}
// Guard against circular references
if (seen.has(obj)) {
return '[Circular]';
}
seen.add(obj);
// Handle Bruno special types
if (obj.__brunoType) {
switch (obj.__brunoType) {
case 'Set':
// Transform Set to display values at top level with numeric indices
if (Array.isArray(obj.__brunoValue)) {
return Object.fromEntries(
obj.__brunoValue.map((value, index) => [index, transformBrunoTypes(value, seen)])
);
}
return {};
case 'Map':
// Transform Map to display entries at top level with => notation
if (Array.isArray(obj.__brunoValue)) {
const mapEntries = {};
for (const entry of obj.__brunoValue) {
// Defensive check: ensure entry is a valid [key, value] pair
if (Array.isArray(entry) && entry.length >= 2) {
const [key, value] = entry;
mapEntries[`${String(key)} =>`] = transformBrunoTypes(value, seen);
}
}
return mapEntries;
}
return {};
case 'Function':
return `[Function: ${obj.__brunoValue?.split?.('\n')?.[0]?.substring(0, 50) ?? 'anonymous'}...]`;
case 'undefined':
return 'undefined';
default:
return obj;
}
}
// Handle arrays - recurse into elements
if (Array.isArray(obj)) {
return obj.map((item) => transformBrunoTypes(item, seen));
}
// Preserve non-plain objects (Date, Error, RegExp, class instances, etc.)
if (!isPlainObject(obj)) {
return obj;
}
// Only deep-clone plain objects
const transformed = {};
for (const [key, value] of Object.entries(obj)) {
transformed[key] = transformBrunoTypes(value, seen);
}
return transformed;
};
// Helper to get metadata about Bruno types for display purposes
const getBrunoTypeMetadata = (obj) => {
if (typeof obj !== 'object' || obj === null) {
return {};
}
if (obj.__brunoType === 'Set' || obj.__brunoType === 'Map') {
return { type: obj.__brunoType };
}
return {};
};
const LogMessage = ({ message, args }) => {
const { displayedTheme } = useTheme();
@@ -154,30 +71,18 @@ const LogMessage = ({ message, args }) => {
if (originalArgs && originalArgs.length > 0) {
return originalArgs.map((arg, index) => {
if (typeof arg === 'object' && arg !== null) {
const metadata = getBrunoTypeMetadata(arg);
const transformedArg = transformBrunoTypes(arg);
// Determine the name to display based on the type
let displayName = false;
let shouldCollapse = 1; // Default: collapse at depth 1 for regular objects
if (metadata.type === 'Map' || metadata.type === 'Set') {
displayName = metadata.type;
shouldCollapse = true; // Fully collapse Maps/Sets by default
}
return (
<div key={index} className="log-object">
<ReactJson
src={transformedArg}
src={arg}
theme={displayedTheme === 'light' ? 'rjv-default' : 'monokai'}
iconStyle="triangle"
indentWidth={2}
collapsed={shouldCollapse}
collapsed={1}
displayDataTypes={false}
displayObjectSize={false}
enableClipboard={false}
name={displayName}
name={false}
style={{
backgroundColor: 'transparent',
fontSize: '${(props) => props.theme.font.size.sm}',

View File

@@ -1,20 +1,8 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 100%;
overflow-y: auto;
position: relative;
.editing-mode {
cursor: pointer;
position: sticky;
z-index: 10;
top: 0;
background: ${(props) => props.theme.bg};
padding-bottom: 0.5em;
}
.markdown-body {
height: auto !important;
overflow-y: visible !important;
}
`;

View File

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

View File

@@ -85,17 +85,6 @@ const Wrapper = styled.div`
justify-content: center;
}
.dropdown-tab-count {
margin-left: auto;
font-size: 11px;
font-weight: 500;
padding: 1px 6px;
border-radius: 10px;
background: ${(props) => props.theme.dropdown.hoverBg};
min-width: 18px;
text-align: center;
}
&:hover:not(:disabled):not(.disabled) {
background-color: ${(props) => props.theme.dropdown.hoverBg};
}
@@ -179,34 +168,11 @@ 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};
margin: 0.25rem 0;
}
.submenu-trigger {
position: relative;
}
.submenu-arrow {
color: ${(props) => props.theme.dropdown.mutedText};
flex-shrink: 0;
display: flex;
align-items: center;
margin-left: auto;
}
`;
export default Wrapper;

View File

@@ -2,7 +2,7 @@ import React from 'react';
import Tippy from '@tippyjs/react';
import StyledWrapper from './StyledWrapper';
const Dropdown = ({ icon, children, onCreate, placement, transparent, visible, appendTo, onMouseEnter, onMouseLeave, ...props }) => {
const Dropdown = ({ icon, children, onCreate, placement, transparent, visible, appendTo, ...props }) => {
// When in controlled mode (visible prop is provided), don't use trigger prop
const tippyProps = visible !== undefined
? { ...props, visible, interactive: true, appendTo: appendTo || 'parent' }
@@ -11,14 +11,7 @@ const Dropdown = ({ icon, children, onCreate, placement, transparent, visible, a
return (
<Tippy
render={(attrs) => (
<StyledWrapper
className="tippy-box dropdown"
transparent={transparent}
tabIndex={-1}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
{...attrs}
>
<StyledWrapper className="tippy-box dropdown" transparent={transparent} tabIndex={-1} {...attrs}>
{children}
</StyledWrapper>
)}

View File

@@ -1,19 +1,15 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: block;
width: 100%;
isolation: isolate;
&.is-resizing {
cursor: col-resize !important;
user-select: none;
}
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
.table-container {
overflow-y: auto;
border-radius: ${(props) => props.theme.border.radius.base};
border: solid 1px ${(props) => props.theme.border.border0};
overflow: clip;
}
table {
@@ -28,7 +24,6 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.table.thead.color} !important;
background: ${(props) => props.theme.sidebar.bg};
user-select: none;
overflow: visible;
border: none !important;
@@ -39,36 +34,10 @@ const StyledWrapper = styled.div`
border-bottom: solid 1px ${(props) => props.theme.border.border0};
border-right: solid 1px ${(props) => props.theme.border.border0};
vertical-align: middle;
position: relative;
overflow: visible;
&:last-child {
border-right: none;
}
.column-name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-right: 4px;
}
.resize-handle {
position: absolute;
right: 0;
top: 0;
width: 4px;
height: 100%;
cursor: col-resize;
background: transparent;
z-index: 10;
&:hover,
&.resizing {
background: ${(props) => props.theme.colors.accent};
}
}
}
}
@@ -79,8 +48,6 @@ const StyledWrapper = styled.div`
tbody {
tr {
height: 35px;
max-height: 35px;
transition: background 0.1s ease;
&:last-child td {
@@ -88,45 +55,15 @@ const StyledWrapper = styled.div`
}
td {
height: 35px;
max-height: 35px;
padding: 1px 10px !important;
border-top: none !important;
border-left: none !important;
border-bottom: solid 1px ${(props) => props.theme.border.border0};
border-right: solid 1px ${(props) => props.theme.border.border0};
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
box-sizing: border-box;
> div:not(.drag-handle) {
height: 33px;
max-height: 33px;
overflow: hidden;
}
/* 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 {
max-width: 100%;
}
.cm-line {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:last-child {
border-right: none;
}
}
}
@@ -138,7 +75,6 @@ const StyledWrapper = styled.div`
text-align: center;
vertical-align: middle;
line-height: 1;
text-overflow: clip;
input[type='checkbox'] {
vertical-align: baseline;
@@ -148,9 +84,6 @@ const StyledWrapper = styled.div`
.tooltip-mod {
max-width: 200px !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
white-space: normal !important;
}
input[type='text'] {
@@ -194,23 +127,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,52 +1,10 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { TableVirtuoso } from 'react-virtuoso';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { IconTrash, IconAlertCircle, IconGripVertical, IconMinusVertical } from '@tabler/icons';
import { Tooltip } from 'react-tooltip';
import { uuid } from 'utils/common';
import StyledWrapper from './StyledWrapper';
const MIN_COLUMN_WIDTH = 80;
const ROW_HEIGHT = 35;
const findScrollParent = (element) => {
let parent = element?.parentElement;
while (parent) {
const { overflowY } = getComputedStyle(parent);
if (overflowY === 'auto' || overflowY === 'scroll') return parent;
parent = parent.parentElement;
}
return null;
};
const TableRow = React.memo(
({ children, item, context, ...rest }) => {
const rowIndex = Number(rest['data-item-index']);
const { reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, onDragStart, onDragOver, onDrop, onDragEnd, onDragLeave } = context;
const isEmpty = isLastEmptyRow(item, rowIndex);
const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount;
const isDragOver = canDrag && dragOverRow === rowIndex;
const existingClass = rest.className || '';
const className = isDragOver ? `${existingClass} drag-over`.trim() : existingClass;
return (
<tr
{...rest}
className={className}
draggable={canDrag}
onDragStart={canDrag ? (e) => onDragStart(e, rowIndex) : undefined}
onDragOver={canDrag ? (e) => onDragOver(e, rowIndex) : undefined}
onDragLeave={canDrag ? (e) => onDragLeave(e, rowIndex) : undefined}
onDrop={canDrag ? (e) => onDrop(e, rowIndex) : undefined}
onDragEnd={canDrag ? onDragEnd : undefined}
>
{children}
</tr>
);
}
);
const EditableTable = ({
tableId, // Not being used kept to maintain uniqueness & pass similar in onColumnWidthsChange
columns,
rows,
onChange,
@@ -60,103 +18,12 @@ const EditableTable = ({
reorderable = false,
onReorder,
showAddRow = true,
testId = 'editable-table',
columnWidths,
initialScroll = 0,
onColumnWidthsChange
testId = 'editable-table'
}) => {
const wrapperRef = useRef(null);
const virtuosoRef = useRef(null);
const tableRef = useRef(null);
const emptyRowUidRef = useRef(null);
const prevRowCountRef = useRef(0);
const [resizing, setResizing] = useState(null);
const [tableHeight, setTableHeight] = useState(0);
const [scrollParent, setScrollParent] = useState(null);
const [dragOverRow, setDragOverRow] = useState(null);
const widths = columnWidths || {};
useLayoutEffect(() => {
setScrollParent(findScrollParent(wrapperRef.current));
}, []);
const handleTotalHeightChanged = useCallback((h) => {
setTableHeight(h);
}, []);
const handleColumnWidthsChange = useCallback((newWidths) => {
onColumnWidthsChange?.(newWidths);
}, [onColumnWidthsChange]);
const handleResizeStart = useCallback((e, columnKey) => {
e.preventDefault();
e.stopPropagation();
const currentCell = e.target.closest('td');
const nextCell = currentCell?.nextElementSibling;
if (!currentCell || !nextCell) return;
const columnIndex = columns.findIndex((col) => col.key === columnKey);
if (columnIndex >= columns.length - 1) return;
const startX = e.clientX;
const startWidth = currentCell.offsetWidth;
const nextColumnKey = columns[columnIndex + 1].key;
const nextColumnStartWidth = nextCell.offsetWidth;
setResizing(columnKey);
const handleMouseMove = (moveEvent) => {
const diff = moveEvent.clientX - startX;
const maxGrow = nextColumnStartWidth - MIN_COLUMN_WIDTH;
const maxShrink = startWidth - MIN_COLUMN_WIDTH;
const clampedDiff = Math.max(-maxShrink, Math.min(maxGrow, diff));
const newWidths = {
...widths,
[columnKey]: `${startWidth + clampedDiff}px`,
[nextColumnKey]: `${nextColumnStartWidth - clampedDiff}px`
};
handleColumnWidthsChange(newWidths);
};
const handleMouseUp = () => {
// Convert pixel widths to percentages for responsive scaling
const table = wrapperRef.current?.querySelector('table');
if (table) {
const tableWidth = table.offsetWidth;
const headerCells = table.querySelectorAll('thead td');
const newWidths = {};
headerCells.forEach((cell, cellIndex) => {
const checkboxOffset = showCheckbox ? 1 : 0;
const colIndex = cellIndex - checkboxOffset;
if (colIndex >= 0 && colIndex < columns.length) {
const colKey = columns[colIndex]?.key;
if (colKey) {
const percentage = (cell.offsetWidth / tableWidth) * 100;
newWidths[colKey] = `${percentage}%`;
}
}
});
if (Object.keys(newWidths).length > 0) {
handleColumnWidthsChange({ ...widths, ...newWidths });
}
}
setResizing(null);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}, [columns, showCheckbox, widths, handleColumnWidthsChange]);
const getColumnWidth = useCallback((column) => {
return widths[column.key] || column.width || 'auto';
}, [widths]);
const [hoveredRow, setHoveredRow] = useState(null);
const [dragStart, setDragStart] = useState(null);
const createEmptyRow = useCallback(() => {
const newUid = uuid();
@@ -213,16 +80,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;
@@ -281,39 +138,40 @@ const EditableTable = ({
onChange(filteredRows);
}, [rows, onChange]);
const getColumnWidth = useCallback((column) => {
if (column.width) return column.width;
return 'auto';
}, []);
const handleDragStart = useCallback((e, index) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', index);
setDragStart(index);
}, []);
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);
updatedOrder.splice(toIndex, 0, movedRow);
onReorder({ updateReorderedItem: updatedOrder.map((row) => row.uid) });
}
setDragStart(null);
setHoveredRow(null);
}, [onReorder, rowsWithEmpty, showAddRow]);
const handleDragEnd = useCallback(() => {
setDragOverRow(null);
setDragStart(null);
setHoveredRow(null);
}, []);
const renderCell = useCallback((column, row, rowIndex) => {
@@ -321,34 +179,15 @@ const EditableTable = ({
const value = column.getValue ? column.getValue(row) : row[column.key];
const error = getRowError?.(row, rowIndex, column.key);
const errorIcon = error && !isEmpty ? (
<span>
<IconAlertCircle
data-tooltip-id={`error-${row.uid}-${column.key}`}
className="text-red-600 cursor-pointer ml-1"
size={20}
/>
<Tooltip
className="tooltip-mod"
id={`error-${row.uid}-${column.key}`}
html={error}
/>
</span>
) : null;
if (column.render) {
return (
<div className="flex items-center">
{column.render({
row,
value,
rowIndex,
isLastEmptyRow: isEmpty,
onChange: (newValue) => handleValueChange(row.uid, column.key, newValue)
})}
{errorIcon}
</div>
);
return column.render({
row,
value,
rowIndex,
isLastEmptyRow: isEmpty,
onChange: (newValue) => handleValueChange(row.uid, column.key, newValue),
error
});
}
return (
@@ -362,132 +201,123 @@ const EditableTable = ({
className="mousetrap"
value={value || ''}
readOnly={column.readOnly}
placeholder={!value ? column.placeholder || column.name : ''}
placeholder={isEmpty ? column.placeholder || column.name : ''}
onChange={(e) => handleValueChange(row.uid, column.key, e.target.value)}
/>
{errorIcon}
{error && !isEmpty && (
<span>
<IconAlertCircle
data-tooltip-id={`error-${row.uid}-${column.key}`}
className="text-red-600 cursor-pointer"
size={20}
/>
<Tooltip
className="tooltip-mod"
id={`error-${row.uid}-${column.key}`}
html={error}
/>
</span>
)}
</div>
);
}, [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'}>
<div className="table-container" ref={tableRef} data-testid={testId}>
<table>
<thead>
<tr>
{showCheckbox && (
<td className="text-center">{checkboxLabel}</td>
)}
{columns.map((column) => (
<td
key={column.key}
style={{ width: getColumnWidth(column) }}
>
{column.name}
</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

@@ -1,659 +0,0 @@
import React, { useCallback, useRef, useState, useEffect, useMemo } from 'react';
import { TableVirtuoso } from 'react-virtuoso';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useSelector, useDispatch } from 'react-redux';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import MultiLineEditor from 'components/MultiLineEditor/index';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { variableNameRegex } from 'utils/common/regex';
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}
</tr>
),
(prevProps, nextProps) => {
const prevUid = prevProps?.item?.uid;
const nextUid = nextProps?.item?.uid;
return prevUid === nextUid && prevProps.children === nextProps.children;
}
);
const EnvironmentVariablesTable = ({
environment,
collection,
onSave,
draft,
onDraftChange,
onDraftClear,
setIsModified,
renderExtraValueContent,
searchQuery = ''
}) => {
const { storedTheme } = useTheme();
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const activeWorkspace = useSelector((state) => {
const uid = state.workspaces?.activeWorkspaceUid;
return state.workspaces?.workspaces?.find((w) => w.uid === uid);
});
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const hasDraftForThisEnv = draft?.environmentUid === environment.uid;
const rowCount = (environment.variables?.length || 0) + 1;
const [tableHeight, setTableHeight] = useState(rowCount * MIN_ROW_HEIGHT);
// We need to add <EditableTable/> component for env table
const [scroll, setScroll] = usePersistedState({
key: `persisted::${activeTabUid}::collection-envs-scroll-${environment.uid}`,
default: 0
});
const scrollerRef = useRef(null);
const [scrollerEl, setScrollerEl] = useState(null);
scrollerRef.current = scrollerEl;
const initialTopMostItemIndex = useRef(Math.max(0, Math.floor(scroll / MIN_ROW_HEIGHT))).current;
useTrackScroll({ ref: scrollerRef, onChange: setScroll, initialValue: scroll, enabled: !!scrollerEl });
// Use environment UID as part of tableId so each environment has its own column widths
const tableId = `env-vars-table-${environment.uid}`;
// Get column widths from Redux - derived value (not state)
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const storedColumnWidths = focusedTab?.tableColumnWidths?.[tableId];
// Local state initialized from Redux (computed once on mount/environment change via key)
const [columnWidths, setColumnWidths] = useState(() => {
return storedColumnWidths || { name: '30%', value: 'auto' };
});
const [resizing, setResizing] = useState(null);
const [pinnedData, setPinnedData] = useState({ query: '', uids: new Set() });
const handleColumnWidthsChange = (id, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId: id, widths }));
};
// Store column widths in ref for access in event handlers
const columnWidthsRef = useRef(columnWidths);
columnWidthsRef.current = columnWidths;
const handleResizeStart = useCallback((e, columnKey) => {
e.preventDefault();
e.stopPropagation();
const currentCell = e.target.closest('td');
const nextCell = currentCell?.nextElementSibling;
if (!currentCell || !nextCell) return;
const startX = e.clientX;
const startWidth = currentCell.offsetWidth;
const nextColumnKey = 'value';
const nextColumnStartWidth = nextCell.offsetWidth;
setResizing(columnKey);
const handleMouseMove = (moveEvent) => {
const diff = moveEvent.clientX - startX;
const maxGrow = nextColumnStartWidth - MIN_COLUMN_WIDTH;
const maxShrink = startWidth - MIN_COLUMN_WIDTH;
const clampedDiff = Math.max(-maxShrink, Math.min(maxGrow, diff));
const newWidths = {
[columnKey]: `${startWidth + clampedDiff}px`,
[nextColumnKey]: `${nextColumnStartWidth - clampedDiff}px`
};
setColumnWidths(newWidths);
};
const handleMouseUp = () => {
setResizing(null);
// Save to Redux after resize ends using ref for latest values
handleColumnWidthsChange(tableId, columnWidthsRef.current);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}, [handleColumnWidthsChange]);
const handleTotalHeightChanged = useCallback((h) => {
setTableHeight(h);
}, []);
const handleRowFocus = useCallback((uid) => {
setPinnedData((prev) => ({
query: searchQuery,
uids: prev.query === searchQuery ? new Set([...prev.uids, uid]) : new Set([uid])
}));
}, [searchQuery]);
const prevEnvUidRef = useRef(null);
const prevEnvVariablesRef = useRef(environment.variables);
const mountedRef = useRef(false);
let _collection = collection ? cloneDeep(collection) : {};
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
if (_collection) {
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
}
// When collection is null (global/workspace environments), populate process env
// variables from the active workspace so that {{process.env.X}} can resolve
if (!collection && activeWorkspace?.processEnvVariables) {
_collection.workspaceProcessEnvVariables = activeWorkspace.processEnvVariables;
}
const initialValues = useMemo(() => {
const vars = environment.variables || [];
return [
...vars,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
}, [environment.uid, environment.variables]);
const formik = useFormik({
enableReinitialize: true,
initialValues: initialValues,
validationSchema: Yup.array().of(
Yup.object({
enabled: Yup.boolean(),
name: Yup.string().when('$isLastRow', {
is: true,
then: (schema) => schema.optional(),
otherwise: (schema) =>
schema
.required('Name cannot be empty')
.matches(
variableNameRegex,
'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.'
)
.trim()
}),
secret: Yup.boolean(),
type: Yup.string(),
uid: Yup.string(),
value: Yup.mixed().nullable()
})
),
validate: (values) => {
const errors = {};
values.forEach((variable, index) => {
const isLastRow = index === values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
if (isLastRow && isEmptyRow) {
return;
}
if (!variable.name || variable.name.trim() === '') {
if (!errors[index]) errors[index] = {};
errors[index].name = 'Name cannot be empty';
} else if (!variableNameRegex.test(variable.name)) {
if (!errors[index]) errors[index] = {};
errors[index].name
= 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.';
}
});
return Object.keys(errors).length > 0 ? errors : {};
},
onSubmit: () => {}
});
// Restore draft values on mount or environment switch
useEffect(() => {
const isMount = !mountedRef.current;
const envChanged = prevEnvUidRef.current !== null && prevEnvUidRef.current !== environment.uid;
const variablesReloaded = !isMount && !envChanged && prevEnvVariablesRef.current !== environment.variables;
prevEnvUidRef.current = environment.uid;
prevEnvVariablesRef.current = environment.variables;
mountedRef.current = true;
if ((isMount || envChanged || variablesReloaded) && hasDraftForThisEnv && draft?.variables) {
formik.setValues([
...draft.variables,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
]);
}
}, [environment.uid, environment.variables, hasDraftForThisEnv, draft?.variables]);
const savedValuesJson = useMemo(() => {
return JSON.stringify((environment.variables || []).map(stripEnvVarUid));
}, [environment.variables]);
useEffect(() => {
setPinnedData({ query: '', uids: new Set() });
}, [savedValuesJson]);
// Sync modified state
useEffect(() => {
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const currentValuesJson = JSON.stringify(currentValues.map(stripEnvVarUid));
const hasActualChanges = currentValuesJson !== savedValuesJson;
setIsModified(hasActualChanges);
}, [formik.values, savedValuesJson, setIsModified]);
// Sync draft state
useEffect(() => {
const timeoutId = setTimeout(() => {
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const currentValuesJson = JSON.stringify(currentValues.map(stripEnvVarUid));
const hasActualChanges = currentValuesJson !== savedValuesJson;
const existingDraftVariables = hasDraftForThisEnv ? draft?.variables : null;
const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables.map(stripEnvVarUid)) : null;
if (hasActualChanges) {
if (currentValuesJson !== existingDraftJson) {
onDraftChange(currentValues);
}
} else if (hasDraftForThisEnv) {
onDraftClear();
}
}, 300);
return () => clearTimeout(timeoutId);
}, [formik.values, savedValuesJson, environment.uid, hasDraftForThisEnv, draft?.variables, onDraftChange, onDraftClear]);
const ErrorMessage = ({ name, index }) => {
const meta = formik.getFieldMeta(name);
const id = `error-${name}-${index}`;
const isLastRow = index === formik.values.length - 1;
const variable = formik.values[index];
const isEmptyRow = !variable?.name || variable.name.trim() === '';
if (isLastRow && isEmptyRow) {
return null;
}
if (!meta.error || !meta.touched) {
return null;
}
return (
<span>
<IconAlertCircle id={id} className="text-red-600 cursor-pointer" size={20} />
<Tooltip className="tooltip-mod" anchorId={id} html={meta.error || ''} />
</span>
);
};
const handleRemoveVar = useCallback(
(id) => {
const currentValues = formik.values;
if (!currentValues || currentValues.length === 0) {
return;
}
const lastRow = currentValues[currentValues.length - 1];
const isLastEmptyRow = lastRow?.uid === id && (!lastRow.name || lastRow.name.trim() === '');
if (isLastEmptyRow) {
return;
}
const filteredValues = currentValues.filter((variable) => variable.uid !== id);
const hasEmptyLastRow
= filteredValues.length > 0
&& (!filteredValues[filteredValues.length - 1].name
|| filteredValues[filteredValues.length - 1].name.trim() === '');
const newValues = hasEmptyLastRow
? filteredValues
: [
...filteredValues,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
formik.setValues(newValues);
},
[formik.values]
);
const handleNameChange = (index, e) => {
formik.handleChange(e);
const isLastRow = index === formik.values.length - 1;
if (isLastRow) {
const newVariable = {
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
};
setTimeout(() => {
formik.setFieldValue(formik.values.length, newVariable, false);
}, 0);
}
};
const handleNameBlur = (index) => {
formik.setFieldTouched(`${index}.name`, true, true);
};
const handleNameKeyDown = (index, e) => {
if (e.key === 'Enter') {
e.preventDefault();
formik.setFieldTouched(`${index}.name`, true, true);
}
};
const handleSave = useCallback(() => {
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const savedValues = environment.variables || [];
// Compare without UIDs since they can be different but the actual data is the same
const hasChanges = JSON.stringify(variablesToSave.map(stripEnvVarUid)) !== JSON.stringify(savedValues.map(stripEnvVarUid));
if (!hasChanges) {
toast.error('No changes to save');
return;
}
const hasValidationErrors = variablesToSave.some((variable) => {
if (!variable.name || variable.name.trim() === '') {
return true;
}
if (!variableNameRegex.test(variable.name)) {
return true;
}
return false;
});
if (hasValidationErrors) {
toast.error('Please fix validation errors before saving');
return;
}
onSave(cloneDeep(variablesToSave))
.then(() => {
toast.success('Changes saved successfully');
onDraftClear();
const newValues = [
...variablesToSave,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
formik.resetForm({ values: newValues });
setIsModified(false);
})
.catch((error) => {
console.error(error);
toast.error('An error occurred while saving the changes');
});
}, [formik.values, environment.variables, onSave, onDraftClear, setIsModified]);
const handleReset = useCallback(() => {
const originalVars = environment.variables || [];
const resetValues = [
...originalVars,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
formik.resetForm({ values: resetValues });
setIsModified(false);
}, [environment.variables, setIsModified]);
const handleSaveRef = useRef(handleSave);
handleSaveRef.current = handleSave;
useEffect(() => {
const handleSaveEvent = () => {
handleSaveRef.current();
};
window.addEventListener('environment-save', handleSaveEvent);
return () => {
window.removeEventListener('environment-save', handleSaveEvent);
};
}, []);
const filteredVariables = useMemo(() => {
const allVariables = formik.values.map((variable, index) => ({ variable, index }));
if (!searchQuery?.trim()) {
return allVariables;
}
const query = searchQuery.toLowerCase().trim();
const effectivePins = pinnedData.query === searchQuery ? pinnedData.uids : new Set();
return allVariables.filter(({ variable }) => {
if (effectivePins.has(variable.uid)) return true;
const nameMatch = variable.name ? variable.name.toLowerCase().includes(query) : false;
const valueText
= typeof variable.value === 'string'
? variable.value
: typeof variable.value === 'number' || typeof variable.value === 'boolean'
? String(variable.value)
: '';
const valueMatch = valueText.toLowerCase().includes(query);
return !!(nameMatch || valueMatch);
});
}, [formik.values, searchQuery, pinnedData]);
const isSearchActive = !!searchQuery?.trim();
return (
<StyledWrapper className={resizing ? 'is-resizing' : ''}>
{isSearchActive && filteredVariables.length === 0 ? (
<div className="no-results">No results found for &ldquo;{searchQuery.trim()}&rdquo;</div>
) : (
<TableVirtuoso
className="table-container"
style={{ height: tableHeight }}
scrollerRef={setScrollerEl}
initialTopMostItemIndex={initialTopMostItemIndex}
overscan={Math.min(30, filteredVariables.length)}
components={{ TableRow }}
data={filteredVariables}
totalListHeightChanged={handleTotalHeightChanged}
fixedHeaderContent={() => (
<tr>
<td className="text-center"></td>
<td style={{ width: columnWidths.name }}>
Name
<div
className={`resize-handle ${resizing === 'name' ? 'resizing' : ''}`}
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
onMouseDown={(e) => handleResizeStart(e, 'name')}
/>
</td>
<td style={{ width: columnWidths.value }}>Value</td>
<td className="text-center">Secret</td>
<td></td>
</tr>
)}
computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`}
itemContent={(virtualIndex, { variable, index: actualIndex }) => {
const isLastRow = actualIndex === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
return (
<>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${actualIndex}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
)}
</td>
<td style={{ width: columnWidths.name }}>
<div className="flex items-center">
<div className="name-cell-wrapper">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${actualIndex}.name`}
name={`${actualIndex}.name`}
value={variable.name}
placeholder={!variable.name || (typeof variable.name === 'string' && variable.name.trim() === '') ? 'Name' : ''}
onChange={(e) => handleNameChange(actualIndex, e)}
onFocus={() => handleRowFocus(variable.uid)}
onBlur={() => {
handleNameBlur(actualIndex);
}}
onKeyDown={(e) => handleNameKeyDown(actualIndex, e)}
/>
</div>
<ErrorMessage name={`${actualIndex}.name`} index={actualIndex} />
</div>
</td>
<td
className="flex flex-row flex-nowrap items-center"
style={{ width: columnWidths.value }}
>
<div
className="overflow-hidden grow w-full relative"
onFocus={() => handleRowFocus(variable.uid)}
>
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${actualIndex}.value`}
value={variable.value}
placeholder={variable.value == null || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => {
formik.setFieldValue(`${actualIndex}.value`, newValue, true);
// Clear ephemeral metadata when user manually edits the value
if (variable.ephemeral) {
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}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
<Tooltip
anchorId={`${variable.uid}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
/>
</span>
)}
{renderExtraValueContent && renderExtraValueContent(variable)}
</td>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${actualIndex}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</>
);
}}
/>
)}
{/* 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">
Save
</button>
<button type="button" className="submit reset ml-2" onClick={handleReset} data-testid="reset-env">
Reset
</button>
</div>
</div>
</StyledWrapper>
);
};
export default EnvironmentVariablesTable;

View File

@@ -1,106 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
&.collapsed {
flex-shrink: 0;
.section-content {
display: none;
}
}
&.expanded {
flex: 1;
min-height: 0;
overflow: hidden;
.section-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
cursor: pointer;
user-select: none;
border-radius: 4px;
transition: background 0.15s ease;
flex-shrink: 0;
&:hover {
background: ${(props) => props.theme.workspace.button.bg};
}
.section-title-wrapper {
display: flex;
align-items: center;
gap: 6px;
}
.section-icon {
color: ${(props) => props.theme.colors.text.muted};
transition: transform 0.2s ease;
&.expanded {
transform: rotate(90deg);
}
}
.section-title {
padding-right: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: ${(props) => props.theme.sidebar.color};
}
.section-badge {
font-size: 10px;
padding: 1px 6px;
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
border-radius: 10px;
color: ${(props) => props.theme.colors.text.muted};
}
.section-actions {
display: flex;
align-items: center;
gap: 2px;
.btn-action {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
color: ${(props) => props.theme.text};
}
}
}
}
.section-content {
padding: 4px 0;
}
`;
export default StyledWrapper;

View File

@@ -1,41 +0,0 @@
import React from 'react';
import { IconChevronRight } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const CollapsibleSection = ({
title,
expanded,
onToggle,
badge,
actions,
children,
testId
}) => {
return (
<StyledWrapper className={expanded ? 'expanded' : 'collapsed'}>
<div className="section-header" onClick={onToggle} data-testid={testId}>
<div className="section-title-wrapper">
<IconChevronRight
size={14}
strokeWidth={2}
className={`section-icon ${expanded ? 'expanded' : ''}`}
/>
<span className="section-title">{title}</span>
{badge !== undefined && badge !== null && (
<span className="section-badge">{badge}</span>
)}
</div>
{actions && (
<div className="section-actions" onClick={(e) => e.stopPropagation()}>
{actions}
</div>
)}
</div>
<div className="section-content">
{children}
</div>
</StyledWrapper>
);
};
export default CollapsibleSection;

View File

@@ -46,8 +46,8 @@ const ImportEnvironmentModal = ({ type = 'collection', collection, onClose, onEn
let importedCount = 0;
for (const environment of validEnvironments) {
const action = isGlobal
? addGlobalEnvironment({ name: environment.name, variables: environment.variables, color: environment.color })
: importEnvironment({ name: environment.name, variables: environment.variables, color: environment.color, collectionUid: collection?.uid });
? addGlobalEnvironment({ name: environment.name, variables: environment.variables })
: importEnvironment({ name: environment.name, variables: environment.variables, collectionUid: collection?.uid });
await dispatch(action);
importedCount++;

View File

@@ -4,14 +4,7 @@ import Modal from 'components/Modal';
import Portal from 'components/Portal';
import Button from 'ui/Button';
const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose, isGlobal, isDotEnv }) => {
let settingsLabel = 'collection environment settings';
if (isDotEnv) {
settingsLabel = '.env file';
} else if (isGlobal) {
settingsLabel = 'global environment settings';
}
const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose, isGlobal }) => {
return (
<Portal>
<Modal
@@ -28,7 +21,7 @@ const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose,
<h1 className="ml-2 text-lg font-medium">Hold on...</h1>
</div>
<div className="font-normal mt-4">
You have unsaved changes in {settingsLabel}.
You have unsaved changes in {isGlobal ? 'global' : 'collection'} environment settings.
</div>
<div className="flex justify-between mt-6">

View File

@@ -1,93 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: ${(props) => props.theme.bg};
.header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 8px 20px;
flex-shrink: 0;
.title {
font-size: ${(props) => props.theme.font.size.base};
font-weight: 500;
color: ${(props) => props.theme.text};
margin: 0;
}
.actions {
display: flex;
align-items: center;
gap: 12px;
.view-toggle {
display: flex;
border: 1px solid ${(props) => props.theme.border.border0};
border-radius: 4px;
overflow: hidden;
.toggle-btn {
padding: 4px 12px;
font-size: 12px;
border: none;
background: transparent;
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
transition: all 0.15s ease;
&:first-child {
border-right: 1px solid ${(props) => props.theme.border.border0};
}
&:hover {
background: ${(props) => props.theme.sidebar.bg};
}
&.active {
background: ${(props) => props.theme.brand};
color: ${(props) => props.theme.bg};
}
}
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border: none;
background: transparent;
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.bg};
color: ${(props) => props.theme.text};
}
&.delete-btn:hover {
color: ${(props) => props.theme.colors.text.danger};
}
}
}
}
.content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 0 20px 20px 20px;
}
`;
export default StyledWrapper;

View File

@@ -1,75 +0,0 @@
import React, { useState } from 'react';
import { IconTrash } from '@tabler/icons';
import DeleteDotEnvFile from 'components/Environments/EnvironmentSettings/DeleteDotEnvFile';
import StyledWrapper from './StyledWrapper';
const DotEnvFileDetails = ({
title,
children,
onDelete,
dotEnvExists,
viewMode,
onViewModeChange
}) => {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const handleDeleteClick = () => {
setShowDeleteModal(true);
};
const handleConfirmDelete = () => {
if (onDelete) {
onDelete();
}
};
return (
<StyledWrapper>
<div className="header">
<h3 className="title">{title}</h3>
<div className="actions">
{dotEnvExists && (
<>
<div className="view-toggle" role="group" aria-label="View mode">
<button
type="button"
className={`toggle-btn ${viewMode === 'table' ? 'active' : ''}`}
onClick={() => onViewModeChange?.('table')}
aria-pressed={viewMode === 'table'}
>
Table
</button>
<button
type="button"
className={`toggle-btn ${viewMode === 'raw' ? 'active' : ''}`}
onClick={() => onViewModeChange?.('raw')}
aria-pressed={viewMode === 'raw'}
data-testid="dotenv-view-raw"
>
Raw
</button>
</div>
<button type="button" onClick={handleDeleteClick} title="Delete .env file" className="action-btn delete-btn">
<IconTrash size={15} strokeWidth={1.5} />
</button>
</>
)}
</div>
</div>
{showDeleteModal && (
<DeleteDotEnvFile
onClose={() => setShowDeleteModal(false)}
onConfirm={handleConfirmDelete}
filename={title}
/>
)}
<div className="content">
{children}
</div>
</StyledWrapper>
);
};
export default DotEnvFileDetails;

View File

@@ -1,16 +0,0 @@
import React from 'react';
import { IconFileOff } from '@tabler/icons';
const DotEnvEmptyState = () => {
return (
<div className="empty-state">
<IconFileOff size={48} strokeWidth={1.5} />
<div className="title">No .env File</div>
<div className="description">
Add a variable below to create a .env file in this location.
</div>
</div>
);
};
export default DotEnvEmptyState;

View File

@@ -1,25 +0,0 @@
import React from 'react';
import { IconAlertCircle } from '@tabler/icons';
import { Tooltip } from 'react-tooltip';
const DotEnvErrorMessage = React.memo(({ formik, name, index }) => {
const meta = formik.getFieldMeta(name);
const id = `error-${name}-${index}`;
const isLastRow = index === formik.values.length - 1;
const variable = formik.values[index];
const isEmptyRow = !variable?.name || variable.name.trim() === '';
if ((isLastRow && isEmptyRow) || !meta.error || !meta.touched) {
return null;
}
return (
<span>
<IconAlertCircle id={id} className="text-red-600 cursor-pointer" size={20} />
<Tooltip className="tooltip-mod" anchorId={id} html={meta.error || ''} />
</span>
);
});
export default DotEnvErrorMessage;

View File

@@ -1,43 +0,0 @@
import React from 'react';
import CodeEditor from 'components/CodeEditor';
const DotEnvRawView = ({
collection,
item,
theme,
value,
onChange,
onSave,
onReset,
isSaving
}) => {
return (
<>
<div className="raw-editor-container" data-testid="dotenv-raw-editor">
<CodeEditor
collection={collection}
item={item}
theme={theme}
value={value}
onEdit={onChange}
onSave={onSave}
mode="text/plain"
enableVariableHighlighting={false}
enableBrunoVarInfo={false}
/>
</div>
<div className="button-container">
<div className="flex items-center">
<button type="button" className="submit" onClick={onSave} disabled={isSaving} data-testid="save-dotenv-raw">
{isSaving ? 'Saving...' : 'Save'}
</button>
<button type="button" className="submit reset ml-2" onClick={onReset} disabled={isSaving} data-testid="reset-dotenv-raw">
Reset
</button>
</div>
</div>
</>
);
};
export default DotEnvRawView;

View File

@@ -1,130 +0,0 @@
import React, { useCallback, useRef } from 'react';
import { TableVirtuoso } from 'react-virtuoso';
import { IconTrash } from '@tabler/icons';
import MultiLineEditor from 'components/MultiLineEditor/index';
import DotEnvErrorMessage from './DotEnvErrorMessage';
import { MIN_TABLE_HEIGHT } from './utils';
const TableRow = React.memo(({ children, item }) => (
<tr key={item.uid} data-testid={`dotenv-var-row-${item.name}`}>{children}</tr>
), (prevProps, nextProps) => {
const prevUid = prevProps?.item?.uid;
const nextUid = nextProps?.item?.uid;
return prevUid === nextUid && prevProps.children === nextProps.children;
});
const DotEnvTableView = ({
formik,
theme,
showValueColumn,
tableHeight,
onHeightChange,
onNameChange,
onNameBlur,
onNameKeyDown,
onRemoveVar,
onSave,
onReset,
isSaving
}) => {
const handleTotalHeightChanged = useCallback((h) => {
onHeightChange(h);
}, [onHeightChange]);
// Use refs for stable access to formik values in callbacks
const formikRef = useRef(formik);
formikRef.current = formik;
// Don't memoize itemContent - TableVirtuoso handles this internally
// and we need fresh access to formik values
const itemContent = (index, variable) => {
const currentFormik = formikRef.current;
const isLastRow = index === currentFormik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
return (
<>
<td>
<div className="flex items-center">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${index}.name`}
name={`${index}.name`}
value={variable.name}
placeholder={isLastEmptyRow ? 'Name' : ''}
onChange={(e) => onNameChange(index, e)}
onBlur={() => onNameBlur(index)}
onKeyDown={(e) => onNameKeyDown(index, e)}
/>
<DotEnvErrorMessage formik={currentFormik} name={`${index}.name`} index={index} />
</div>
</td>
{showValueColumn && (
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
theme={theme}
name={`${index}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
onChange={(newValue) => currentFormik.setFieldValue(`${index}.value`, newValue, true)}
onSave={onSave}
/>
</div>
</td>
)}
<td className="delete-col">
{!isLastEmptyRow && (
<button
type="button"
aria-label="Delete variable"
onClick={() => onRemoveVar(variable.uid)}
>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</>
);
};
return (
<>
<TableVirtuoso
className="table-container"
style={{ height: tableHeight || MIN_TABLE_HEIGHT }}
components={{ TableRow }}
data={formik.values}
totalListHeightChanged={handleTotalHeightChanged}
fixedHeaderContent={() => (
<tr>
<td>Name</td>
{showValueColumn && <td>Value</td>}
<td className="delete-col"></td>
</tr>
)}
fixedItemHeight={35}
computeItemKey={(index, variable) => variable.uid}
itemContent={itemContent}
/>
<div className="button-container">
<div className="flex items-center">
<button type="button" className="submit" onClick={onSave} disabled={isSaving} data-testid="save-dotenv">
{isSaving ? 'Saving...' : 'Save'}
</button>
<button type="button" className="submit reset ml-2" onClick={onReset} disabled={isSaving} data-testid="reset-dotenv">
Reset
</button>
</div>
</div>
</>
);
};
export default DotEnvTableView;

View File

@@ -1,346 +0,0 @@
import React, { useCallback, useRef, useMemo, useEffect, useState } from 'react';
import { useTheme } from 'providers/Theme';
import { uuid } from 'utils/common';
import { useFormik } from 'formik';
import { variableNameRegex } from 'utils/common/regex';
import toast from 'react-hot-toast';
import useDeferredLoading from 'hooks/useDeferredLoading';
import StyledWrapper from './StyledWrapper';
import DotEnvTableView from './DotEnvTableView';
import DotEnvRawView from './DotEnvRawView';
import DotEnvEmptyState from './DotEnvEmptyState';
import { variablesToRaw, rawToVariables, MIN_TABLE_HEIGHT } from './utils';
const DotEnvFileEditor = ({
variables,
onSave,
onSaveRaw,
isModified,
setIsModified,
dotEnvExists,
rawContent,
viewMode = 'table',
collection,
item
}) => {
const { displayedTheme } = useTheme();
const [tableHeight, setTableHeight] = useState(MIN_TABLE_HEIGHT);
// Derive a single baseline raw value for consistent dirty-tracking
const baselineRaw = rawContent ?? variablesToRaw(variables || []);
const initialRawValue = baselineRaw;
const [rawValue, setRawValue] = useState(initialRawValue);
const [prevViewMode, setPrevViewMode] = useState(viewMode);
const [isSaving, setIsSaving] = useState(false);
const showSaving = useDeferredLoading(isSaving, 200);
const formikRef = useRef(null);
const initialValues = useMemo(() => {
const vars = (variables || []).map((v) => ({
...v,
uid: v.uid || uuid()
}));
return [
...vars,
{
uid: uuid(),
name: '',
value: ''
}
];
}, [variables]);
const formik = useFormik({
enableReinitialize: true,
initialValues: initialValues,
validate: (values) => {
const errors = {};
values.forEach((variable, index) => {
const isLastRow = index === values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
if (isLastRow && isEmptyRow) {
return;
}
if (!variable.name || variable.name.trim() === '') {
if (!errors[index]) errors[index] = {};
errors[index].name = 'Name cannot be empty';
} else if (!variableNameRegex.test(variable.name)) {
if (!errors[index]) errors[index] = {};
errors[index].name
= 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.';
}
});
return Object.keys(errors).length > 0 ? errors : {};
},
onSubmit: () => {}
});
formikRef.current = formik;
// Sync raw value with external changes
useEffect(() => {
setRawValue(baselineRaw);
}, [baselineRaw]);
// Handle view mode switching
useEffect(() => {
if (viewMode !== prevViewMode) {
if (viewMode === 'raw' && prevViewMode === 'table') {
const currentVars = formikRef.current.values.filter((v) => v.name && v.name.trim() !== '');
const newRawValue = variablesToRaw(currentVars);
setRawValue(newRawValue);
} else if (viewMode === 'table' && prevViewMode === 'raw') {
const parsedVars = rawToVariables(rawValue);
const newValues = [
...parsedVars,
{ uid: uuid(), name: '', value: '' }
];
formikRef.current.setValues(newValues);
}
setPrevViewMode(viewMode);
}
}, [viewMode, prevViewMode, rawValue]);
const normalizeForComparison = (vars) => {
return vars
.filter((v) => v.name && v.name.trim() !== '')
.map(({ name, value }) => ({ name, value: value || '' }));
};
const savedValuesJson = useMemo(() => {
return JSON.stringify(normalizeForComparison(variables || []));
}, [variables]);
useEffect(() => {
if (viewMode === 'raw') {
const hasRawChanges = rawValue !== baselineRaw;
setIsModified(hasRawChanges);
} else {
const currentValuesJson = JSON.stringify(normalizeForComparison(formik.values));
const hasActualChanges = currentValuesJson !== savedValuesJson;
setIsModified(hasActualChanges);
}
}, [formik.values, savedValuesJson, setIsModified, viewMode, rawValue, baselineRaw]);
// Ref for stable formik.values access
const valuesRef = useRef(formik.values);
valuesRef.current = formik.values;
const handleRemoveVar = useCallback((id) => {
const currentValues = valuesRef.current;
if (!currentValues || currentValues.length === 0) {
return;
}
const lastRow = currentValues[currentValues.length - 1];
const isLastEmptyRow = lastRow?.uid === id && (!lastRow.name || lastRow.name.trim() === '');
if (isLastEmptyRow) {
return;
}
const filteredValues = currentValues.filter((variable) => variable.uid !== id);
const hasEmptyLastRow
= filteredValues.length > 0
&& (!filteredValues[filteredValues.length - 1].name
|| filteredValues[filteredValues.length - 1].name.trim() === '');
const newValues = hasEmptyLastRow
? filteredValues
: [
...filteredValues,
{ uid: uuid(), name: '', value: '' }
];
formikRef.current.setValues(newValues);
}, []);
const handleNameChange = useCallback((index, e) => {
formik.handleChange(e);
const isLastRow = index === valuesRef.current.length - 1;
if (isLastRow) {
const newVariable = { uid: uuid(), name: '', value: '' };
setTimeout(() => {
formik.setValues((prev) => {
const lastRow = prev[prev.length - 1];
if (lastRow?.name?.trim()) {
return [...prev, newVariable];
}
return prev;
});
}, 0);
}
}, []);
const handleNameBlur = useCallback((index) => {
formik.setFieldTouched(`${index}.name`, true, true);
}, []);
const handleNameKeyDown = useCallback((index, e) => {
if (e.key === 'Enter') {
e.preventDefault();
formik.setFieldTouched(`${index}.name`, true, true);
}
}, []);
const handleSave = useCallback(() => {
if (isSaving) return;
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const hasValidationErrors = variablesToSave.some((variable) => {
if (!variable.name || variable.name.trim() === '') {
return true;
}
if (!variableNameRegex.test(variable.name)) {
return true;
}
return false;
});
if (hasValidationErrors) {
toast.error('Please fix validation errors before saving');
return;
}
setIsSaving(true);
onSave(variablesToSave)
.then(() => {
toast.success('Changes saved successfully');
const newValues = [
...variablesToSave,
{ uid: uuid(), name: '', value: '' }
];
formik.resetForm({ values: newValues });
setIsModified(false);
window.dispatchEvent(new Event('dotenv-save-complete'));
})
.catch((error) => {
console.error(error);
toast.error('An error occurred while saving the changes');
window.dispatchEvent(new Event('dotenv-save-failed'));
})
.finally(() => {
setIsSaving(false);
});
}, [isSaving, formik.values, onSave, setIsModified]);
const handleSaveRaw = useCallback(() => {
if (isSaving) return;
if (!onSaveRaw) {
toast.error('Raw save is not supported');
return;
}
setIsSaving(true);
onSaveRaw(rawValue)
.then(() => {
toast.success('Changes saved successfully');
setIsModified(false);
window.dispatchEvent(new Event('dotenv-save-complete'));
})
.catch((error) => {
console.error(error);
toast.error('An error occurred while saving the changes');
window.dispatchEvent(new Event('dotenv-save-failed'));
})
.finally(() => {
setIsSaving(false);
});
}, [isSaving, rawValue, onSaveRaw, setIsModified]);
const handleReset = useCallback(() => {
if (viewMode === 'raw') {
setRawValue(baselineRaw);
setIsModified(false);
} else {
const originalVars = (variables || []).map((v) => ({
...v,
uid: v.uid || uuid()
}));
const resetValues = [
...originalVars,
{ uid: uuid(), name: '', value: '' }
];
formik.resetForm({ values: resetValues });
setIsModified(false);
}
}, [viewMode, baselineRaw, variables, setIsModified]);
const handleRawChange = useCallback((newValue) => {
setRawValue(newValue);
}, []);
// Global save event listener
const handleSaveRef = useRef(handleSave);
handleSaveRef.current = handleSave;
const handleSaveRawRef = useRef(handleSaveRaw);
handleSaveRawRef.current = handleSaveRaw;
useEffect(() => {
const handleSaveEvent = () => {
if (viewMode === 'raw') {
handleSaveRawRef.current();
} else {
handleSaveRef.current();
}
};
window.addEventListener('dotenv-save', handleSaveEvent);
return () => {
window.removeEventListener('dotenv-save', handleSaveEvent);
};
}, [viewMode]);
// Raw view mode
if (viewMode === 'raw') {
return (
<StyledWrapper>
<DotEnvRawView
collection={collection}
item={item}
theme={displayedTheme}
value={rawValue}
onChange={handleRawChange}
onSave={handleSaveRaw}
onReset={handleReset}
isSaving={showSaving}
/>
</StyledWrapper>
);
}
// Empty state (no .env file exists yet)
const showEmptyState = !dotEnvExists && (!variables || variables.length === 0);
return (
<StyledWrapper>
{showEmptyState && <DotEnvEmptyState />}
<DotEnvTableView
formik={formik}
theme={displayedTheme}
showValueColumn={!showEmptyState}
tableHeight={showEmptyState ? MIN_TABLE_HEIGHT : tableHeight}
onHeightChange={setTableHeight}
onNameChange={handleNameChange}
onNameBlur={handleNameBlur}
onNameKeyDown={handleNameKeyDown}
onRemoveVar={handleRemoveVar}
onSave={handleSave}
onReset={handleReset}
isSaving={showSaving}
/>
</StyledWrapper>
);
};
export default DotEnvFileEditor;

View File

@@ -1,57 +0,0 @@
import { uuid } from 'utils/common';
import { utils } from '@usebruno/common';
export const variablesToRaw = (variables) => {
return utils.jsonToDotenv(variables);
};
export const rawToVariables = (rawContent) => {
if (!rawContent || rawContent.trim() === '') {
return [];
}
const variables = [];
const lines = rawContent.split('\n');
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine.startsWith('#')) {
continue;
}
const equalIndex = trimmedLine.indexOf('=');
if (equalIndex === -1) {
continue;
}
const name = trimmedLine.substring(0, equalIndex).trim();
let value = trimmedLine.substring(equalIndex + 1);
if (value.startsWith('\'') && value.endsWith('\'')) {
// Single-quoted values are fully literal in dotenv — no unescaping
value = value.slice(1, -1);
} else if (value.startsWith('`') && value.endsWith('`')) {
// Backtick-quoted values are fully literal in dotenv — no unescaping
value = value.slice(1, -1);
} else if (value.startsWith('"') && value.endsWith('"')) {
// Double-quoted values: unescape \", \n, and \r (the escapes we produce)
value = value.slice(1, -1);
value = value.replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\r/g, '\r');
}
if (name) {
variables.push({
uid: uuid(),
name,
value,
enabled: true,
secret: false
});
}
}
return variables;
};
export const MIN_TABLE_HEIGHT = 35 * 2;

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { IconPlus, IconDownload, IconSettings } from '@tabler/icons';
import ToolHint from 'components/ToolHint';
import ColorBadge from 'components/ColorBadge';
const EnvironmentListContent = ({
environments,
@@ -17,11 +16,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
@@ -43,14 +38,13 @@ const EnvironmentListContent = ({
data-tooltip-content={env.name}
data-tooltip-hidden={env.name?.length < 90}
>
<ColorBadge color={env.color} size={8} />
<span className="max-w-100% truncate no-wrap">{env.name}</span>
</div>
))}
</div>
</ToolHint>
<div className="dropdown-item configure-button">
<button onClick={onSettingsClick} id="configure-env" data-testid="configure-env">
<button onClick={onSettingsClick} id="configure-env">
<IconSettings size={16} strokeWidth={1.5} />
<span>Configure</span>
</button>

View File

@@ -9,7 +9,6 @@ const Wrapper = styled.div`
border: 1px solid ${(props) => props.theme.app.collection.toolbar.environmentSelector.border};
line-height: 1rem;
transition: all 0.15s ease;
height: 24px;
&:hover {
border-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.hoverBorder};
@@ -34,7 +33,8 @@ const Wrapper = styled.div`
}
.env-separator {
background-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.separator};
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.separator};
margin: 0 0.35rem;
}
.env-text-inactive {
@@ -74,7 +74,7 @@ const Wrapper = styled.div`
border-top: 0.0625rem solid ${(props) => props.theme.dropdown.separator};
z-index: 10;
margin: 0;
&:hover {
background-color: ${(props) => props.theme.dropdown.bg + ' !important'};
}
@@ -117,14 +117,10 @@ const Wrapper = styled.div`
overflow: hidden;
}
.no-environment {
color: ${(props) => props.theme.colors.text.subtext0};
}
.environment-list {
flex: 1;
overflow-y: auto;
max-height: calc(75vh - 8rem);
max-height: calc(75vh - 8rem);
padding-bottom: 2.625rem;
}

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