mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 09:28:33 +00:00
Compare commits
1 Commits
chore/upda
...
fix/remove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed51f304b8 |
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
20
.github/actions/tests/run-cli-tests/action.yml
vendored
20
.github/actions/tests/run-cli-tests/action.yml
vendored
@@ -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
|
||||
22
.github/actions/tests/run-e2e-tests/action.yml
vendored
22
.github/actions/tests/run-e2e-tests/action.yml
vendored
@@ -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
|
||||
48
.github/actions/tests/run-unit-tests/action.yml
vendored
48
.github/actions/tests/run-unit-tests/action.yml
vendored
@@ -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
|
||||
70
.github/scripts/comment-on-flaky-tests.js
vendored
70
.github/scripts/comment-on-flaky-tests.js
vendored
@@ -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`);
|
||||
78
.github/scripts/detect-flaky-tests.js
vendored
78
.github/scripts/detect-flaky-tests.js
vendored
@@ -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);
|
||||
120
.github/workflows/flaky-test-detector.yml
vendored
120
.github/workflows/flaky-test-detector.yml
vendored
@@ -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
|
||||
26
.github/workflows/lint-checks.yml
vendored
26
.github/workflows/lint-checks.yml
vendored
@@ -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' }}
|
||||
146
.github/workflows/tests.yml
vendored
146
.github/workflows/tests.yml
vendored
@@ -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
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -49,14 +49,12 @@ bruno.iml
|
||||
.idea
|
||||
.vscode
|
||||
.cursor
|
||||
.claude
|
||||
|
||||
# Playwright
|
||||
/blob-report/
|
||||
|
||||
# Development plan files
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
*.plan.md
|
||||
|
||||
# packages dist
|
||||
|
||||
7
.prettierrc.json
Normal file
7
.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"printWidth": 120
|
||||
}
|
||||
@@ -66,16 +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
|
||||
|
||||
- Styled Component CSS might also change layout but tailwind classes shouldn't define colors.
|
||||
|
||||
## Readability and Abstractions
|
||||
|
||||
|
||||
1397
package-lock.json
generated
1397
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -23,7 +23,7 @@
|
||||
"@eslint/compat": "^1.3.2",
|
||||
"@faker-js/faker": "^7.6.0",
|
||||
"@jest/globals": "^29.2.0",
|
||||
"@opencollection/types": "~0.8.0",
|
||||
"@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,7 +36,6 @@
|
||||
"@types/node": "^22.14.1",
|
||||
"@typescript-eslint/parser": "^8.39.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"cross-env": "10.1.0",
|
||||
"eslint": "^9.26.0",
|
||||
"eslint-plugin-diff": "^2.0.3",
|
||||
"fs-extra": "^11.1.1",
|
||||
@@ -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",
|
||||
@@ -78,12 +78,12 @@
|
||||
"build:electron:rpm": "./scripts/build-electron.sh rpm",
|
||||
"build:electron:snap": "./scripts/build-electron.sh snap",
|
||||
"watch:common": "npm run watch --workspace=packages/bruno-common",
|
||||
"watch:requests": "npm run watch --workspace=packages/bruno-requests",
|
||||
"test:codegen": "node playwright/codegen.ts",
|
||||
"test:e2e": "playwright test --project=default",
|
||||
"test:e2e:ssl": "playwright test --project=ssl",
|
||||
"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": {
|
||||
@@ -100,7 +100,6 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"git-url-parse": "^14.1.0"
|
||||
"ajv": "^8.17.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
7
packages/bruno-app/.prettierrc.json
Normal file
7
packages/bruno-app/.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"printWidth": 120
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
module.exports = {
|
||||
rootDir: '.',
|
||||
transform: {
|
||||
'^.+\\.[jt]sx?$': '<rootDir>/jest/transformers/babel-with-esm-replacements.cjs'
|
||||
// '^.+\\.[jt]sx?$': [require("./jest/transformers/with-replacements.cjs"),'babel-jest']
|
||||
'^.+\\.[jt]sx?$': 'babel-jest'
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'/node_modules/(?!strip-json-comments|nanoid|xml-formatter)/'
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
const babelJest = require('babel-jest')
|
||||
|
||||
module.exports = {
|
||||
process(sourceText, sourcePath, options) {
|
||||
const transformer = babelJest.createTransformer();
|
||||
return transformer.process(sourceText.replace(`import.meta.env.MODE`, 'test'), sourcePath, options)
|
||||
}
|
||||
};
|
||||
@@ -13,8 +13,7 @@
|
||||
"api/*": ["src/api/*"],
|
||||
"pageComponents/*": ["src/pageComponents/*"],
|
||||
"providers/*": ["src/providers/*"],
|
||||
"utils/*": ["src/utils/*"],
|
||||
"store/*": ["src/store/*"]
|
||||
"utils/*": ["src/utils/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
@@ -67,7 +69,7 @@
|
||||
"polished": "^4.3.1",
|
||||
"posthog-node": "4.2.1",
|
||||
"prettier": "^2.7.1",
|
||||
"qs": "^6.14.1",
|
||||
"qs": "^6.11.0",
|
||||
"query-string": "^7.0.1",
|
||||
"react": "19.0.0",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
@@ -88,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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -4,11 +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 { openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { sortWorkspaces, toggleWorkspacePin } from 'utils/workspaces';
|
||||
import { focusTab } from 'providers/ReduxStore/slices/tabs';
|
||||
|
||||
import Bruno from 'components/Bruno';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
@@ -18,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';
|
||||
@@ -130,10 +128,7 @@ const AppTitleBar = () => {
|
||||
});
|
||||
|
||||
const handleHomeClick = () => {
|
||||
const scratchCollectionUid = activeWorkspace?.scratchCollectionUid;
|
||||
if (scratchCollectionUid) {
|
||||
dispatch(focusTab({ uid: `${scratchCollectionUid}-overview` }));
|
||||
}
|
||||
dispatch(showHomePage());
|
||||
};
|
||||
|
||||
const handleWorkspaceSwitch = (workspaceUid) => {
|
||||
@@ -252,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>
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React, { createRef } from 'react';
|
||||
import React from 'react';
|
||||
import { isEqual, escapeRegExp } from 'lodash';
|
||||
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
|
||||
import { setupAutoComplete, showRootHints } from 'utils/codemirror/autocomplete';
|
||||
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import * as jsonlint from '@prantlf/jsonlint';
|
||||
import { JSHINT } from 'jshint';
|
||||
@@ -16,8 +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 { setupShortcuts } from 'utils/codemirror/shortcuts';
|
||||
import CodeMirrorSearch from 'components/CodeMirrorSearch/index';
|
||||
import CodeMirrorSearch from 'components/CodeMirrorSearch';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
window.jsonlint = jsonlint;
|
||||
@@ -35,7 +34,6 @@ export default class CodeEditor extends React.Component {
|
||||
this.cachedValue = props.value || '';
|
||||
this.variables = {};
|
||||
this.searchResultsCountElementId = 'search-results-count';
|
||||
this.searchBarRef = createRef();
|
||||
|
||||
this.lintOptions = {
|
||||
esversion: 11,
|
||||
@@ -47,9 +45,6 @@ export default class CodeEditor extends React.Component {
|
||||
this.state = {
|
||||
searchBarVisible: false
|
||||
};
|
||||
|
||||
// Shortcuts cleanup function
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -99,14 +94,14 @@ export default class CodeEditor extends React.Component {
|
||||
}
|
||||
},
|
||||
'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': 'replace',
|
||||
'Ctrl-H': 'replace',
|
||||
@@ -116,12 +111,8 @@ export default class CodeEditor extends React.Component {
|
||||
: cm.replaceSelection(' ', 'end');
|
||||
},
|
||||
'Shift-Tab': 'indentLess',
|
||||
'Ctrl-Space': (cm) => {
|
||||
showRootHints(cm, this.props.showHintsFor);
|
||||
},
|
||||
'Cmd-Space': (cm) => {
|
||||
showRootHints(cm, this.props.showHintsFor);
|
||||
},
|
||||
'Ctrl-Space': 'autocomplete',
|
||||
'Cmd-Space': 'autocomplete',
|
||||
'Ctrl-Y': 'foldAll',
|
||||
'Cmd-Y': 'foldAll',
|
||||
'Ctrl-I': 'unfoldAll',
|
||||
@@ -221,9 +212,6 @@ export default class CodeEditor extends React.Component {
|
||||
|
||||
// Setup lint error tooltip on line number hover
|
||||
this.cleanupLintErrorTooltip = setupLintErrorTooltip(editor);
|
||||
|
||||
// Setup keyboard shortcuts
|
||||
this._shortcutsCleanup = setupShortcuts(editor, this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,18 +228,10 @@ export default class CodeEditor extends React.Component {
|
||||
CodeMirror.signal(this.editor, 'change', this.editor);
|
||||
}
|
||||
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
|
||||
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
|
||||
const nextValue = this.props.value ?? '';
|
||||
const currentValue = this.editor.getValue();
|
||||
// Skip updating only when focused and editable; read-only editors (e.g. response viewer) must always show new value
|
||||
if (this.editor.hasFocus?.() && currentValue !== nextValue && !this.props.readOnly) {
|
||||
this.cachedValue = currentValue;
|
||||
} else {
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = nextValue;
|
||||
this.editor.setValue(nextValue);
|
||||
this.editor.setCursor(cursor);
|
||||
}
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = this.props.value;
|
||||
this.editor.setValue(this.props.value);
|
||||
this.editor.setCursor(cursor);
|
||||
}
|
||||
|
||||
if (this.editor) {
|
||||
@@ -295,12 +275,6 @@ export default class CodeEditor extends React.Component {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Cleanup shortcuts (keymap and store subscription)
|
||||
if (this._shortcutsCleanup) {
|
||||
this._shortcutsCleanup();
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
if (this.editor) {
|
||||
if (this.props.onScroll) {
|
||||
this.props.onScroll(this.editor);
|
||||
@@ -331,10 +305,6 @@ export default 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 })}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -11,7 +11,6 @@ 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';
|
||||
|
||||
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
|
||||
|
||||
@@ -33,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',
|
||||
@@ -56,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}
|
||||
@@ -64,7 +47,7 @@ const Headers = ({ collection }) => {
|
||||
onChange={(newValue) => onChange(newValue.replace(/[\r\n]/g, ''))}
|
||||
autocomplete={headerAutoCompleteList}
|
||||
collection={collection}
|
||||
placeholder={!value ? 'Name' : ''}
|
||||
placeholder={isLastEmptyRow ? 'Name' : ''}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -72,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}
|
||||
@@ -80,7 +63,7 @@ const Headers = ({ collection }) => {
|
||||
onChange={onChange}
|
||||
collection={collection}
|
||||
autocomplete={MimeTypes}
|
||||
placeholder={!value ? 'Value' : ''}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -118,7 +101,6 @@ const Headers = ({ collection }) => {
|
||||
rows={headers}
|
||||
onChange={handleHeadersChange}
|
||||
defaultRow={defaultRow}
|
||||
getRowError={getRowError}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button className="text-link select-none" onClick={toggleBulkEditMode}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import { getTotalRequestCountInCollection } from 'utils/collections/';
|
||||
import { IconFolder, IconWorld, IconApi, IconShare, IconBook } from '@tabler/icons';
|
||||
import { areItemsLoading, getItemsLoadStats } from 'utils/collections/index';
|
||||
import { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import ShareCollection from 'components/ShareCollection/index';
|
||||
@@ -10,13 +11,10 @@ import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const Info = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const totalRequestsInCollection = getTotalRequestCountInCollection(collection);
|
||||
|
||||
const isCollectionLoading = collection.isLoading;
|
||||
|
||||
const totalRequestsInCollection = useMemo(
|
||||
() => getTotalRequestCountInCollection(collection),
|
||||
[collection.items]
|
||||
);
|
||||
const isCollectionLoading = areItemsLoading(collection);
|
||||
const { loading: itemsLoadingCount, total: totalItems } = getItemsLoadStats(collection);
|
||||
const [showShareCollectionModal, toggleShowShareCollectionModal] = useState(false);
|
||||
const [showGenerateDocumentationModal, setShowGenerateDocumentationModal] = useState(false);
|
||||
|
||||
@@ -97,9 +95,7 @@ const Info = ({ collection }) => {
|
||||
<div className="font-medium">Requests</div>
|
||||
<div className="mt-1 text-muted">
|
||||
{
|
||||
isCollectionLoading
|
||||
? 'Loading requests...'
|
||||
: `${totalRequestsInCollection} request${totalRequestsInCollection !== 1 ? 's' : ''} in collection`
|
||||
isCollectionLoading ? `${totalItems - itemsLoadingCount} out of ${totalItems} requests in the collection loaded` : `${totalRequestsInCollection} request${totalRequestsInCollection !== 1 ? 's' : ''} in collection`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,39 +6,20 @@ import { updateCollectionRequestScript, updateCollectionResponseScript } from 'p
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
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';
|
||||
|
||||
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', '');
|
||||
|
||||
// Default to post-response if pre-request script is empty
|
||||
const getInitialTab = () => {
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
return hasPreRequestScript ? 'pre-request' : 'post-response';
|
||||
};
|
||||
|
||||
const [activeTab, setActiveTab] = useState(getInitialTab);
|
||||
const prevCollectionUidRef = useRef(collection.uid);
|
||||
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
// Update active tab only when switching to a different collection
|
||||
useEffect(() => {
|
||||
if (prevCollectionUidRef.current !== collection.uid) {
|
||||
prevCollectionUidRef.current = collection.uid;
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
setActiveTab(hasPreRequestScript ? 'pre-request' : 'post-response');
|
||||
}
|
||||
}, [collection.uid, requestScript]);
|
||||
|
||||
// Refresh CodeMirror when tab becomes visible
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -74,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">
|
||||
@@ -86,18 +63,8 @@ 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">
|
||||
|
||||
@@ -46,14 +46,14 @@ const VarsTable = ({ collection, vars, varType }) => {
|
||||
</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') : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
@@ -184,18 +173,6 @@ const Wrapper = styled.div`
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -6,13 +6,8 @@ const StyledWrapper = styled.div`
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
&.is-resizing {
|
||||
cursor: col-resize !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow: auto;
|
||||
overflow-y: auto;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
border: solid 1px ${(props) => props.theme.border.border0};
|
||||
}
|
||||
@@ -29,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;
|
||||
|
||||
@@ -40,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: 100;
|
||||
|
||||
&:hover,
|
||||
&.resizing {
|
||||
background: ${(props) => props.theme.colors.accent};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,32 +61,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;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* Handle CodeMirror editors overflow */
|
||||
.cm-editor {
|
||||
max-width: 100%;
|
||||
|
||||
.cm-scroller {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,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;
|
||||
@@ -139,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'] {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react';
|
||||
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 EditableTable = ({
|
||||
columns,
|
||||
rows,
|
||||
@@ -25,101 +23,7 @@ const EditableTable = ({
|
||||
const tableRef = useRef(null);
|
||||
const emptyRowUidRef = useRef(null);
|
||||
const [hoveredRow, setHoveredRow] = useState(null);
|
||||
const [resizing, setResizing] = useState(null);
|
||||
const [tableHeight, setTableHeight] = useState(0);
|
||||
const [columnWidths, setColumnWidths] = useState(() => {
|
||||
const initialWidths = {};
|
||||
columns.forEach((col) => {
|
||||
initialWidths[col.key] = col.width || 'auto';
|
||||
});
|
||||
return initialWidths;
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
setColumnWidths((prev) => ({
|
||||
...prev,
|
||||
[columnKey]: `${startWidth + clampedDiff}px`,
|
||||
[nextColumnKey]: `${nextColumnStartWidth - clampedDiff}px`
|
||||
}));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
// Convert pixel widths to percentages for responsive scaling
|
||||
const table = tableRef.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) {
|
||||
setColumnWidths((prev) => ({ ...prev, ...newWidths }));
|
||||
}
|
||||
}
|
||||
setResizing(null);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}, [columns, showCheckbox]);
|
||||
|
||||
// Track table height for resize handles
|
||||
useEffect(() => {
|
||||
const table = tableRef.current?.querySelector('table');
|
||||
if (!table) return;
|
||||
|
||||
const updateHeight = () => {
|
||||
setTableHeight(table.offsetHeight);
|
||||
};
|
||||
|
||||
updateHeight();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateHeight);
|
||||
resizeObserver.observe(table);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [rows.length]);
|
||||
|
||||
const getColumnWidth = useCallback((column) => {
|
||||
return columnWidths[column.key] || column.width || 'auto';
|
||||
}, [columnWidths]);
|
||||
const [dragStart, setDragStart] = useState(null);
|
||||
|
||||
const createEmptyRow = useCallback(() => {
|
||||
const newUid = uuid();
|
||||
@@ -234,9 +138,15 @@ 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) => {
|
||||
@@ -252,17 +162,15 @@ const EditableTable = ({
|
||||
const reorderableRows = showAddRow ? rowsWithEmpty.slice(0, -1) : rowsWithEmpty;
|
||||
const updatedOrder = [...reorderableRows];
|
||||
const [movedRow] = updatedOrder.splice(fromIndex, 1);
|
||||
if (!movedRow) {
|
||||
setHoveredRow(null);
|
||||
return;
|
||||
}
|
||||
updatedOrder.splice(toIndex, 0, movedRow);
|
||||
onReorder({ updateReorderedItem: updatedOrder.map((row) => row.uid) });
|
||||
}
|
||||
setDragStart(null);
|
||||
setHoveredRow(null);
|
||||
}, [onReorder, rowsWithEmpty, showAddRow]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragStart(null);
|
||||
setHoveredRow(null);
|
||||
}, []);
|
||||
|
||||
@@ -271,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 (
|
||||
@@ -312,10 +201,23 @@ 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]);
|
||||
@@ -323,7 +225,7 @@ const EditableTable = ({
|
||||
const reorderableRowCount = showAddRow ? rowsWithEmpty.length - 1 : rowsWithEmpty.length;
|
||||
|
||||
return (
|
||||
<StyledWrapper className={`${showCheckbox ? 'has-checkbox' : 'no-checkbox'} ${resizing ? 'is-resizing' : ''}`}>
|
||||
<StyledWrapper className={showCheckbox ? 'has-checkbox' : 'no-checkbox'}>
|
||||
<div className="table-container" ref={tableRef} data-testid={testId}>
|
||||
<table>
|
||||
<thead>
|
||||
@@ -331,19 +233,12 @@ const EditableTable = ({
|
||||
{showCheckbox && (
|
||||
<td className="text-center">{checkboxLabel}</td>
|
||||
)}
|
||||
{columns.map((column, colIndex) => (
|
||||
{columns.map((column) => (
|
||||
<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)}
|
||||
/>
|
||||
)}
|
||||
{column.name}
|
||||
</td>
|
||||
))}
|
||||
{showDelete && (
|
||||
|
||||
@@ -1,590 +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 } from 'react-redux';
|
||||
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';
|
||||
|
||||
const MIN_H = 35 * 2;
|
||||
const MIN_COLUMN_WIDTH = 80;
|
||||
|
||||
const TableRow = React.memo(
|
||||
({ children, item }) => (
|
||||
<tr key={item.uid} 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 highlightText = (text, query) => {
|
||||
if (!query?.trim() || !text) return text;
|
||||
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
||||
const parts = text.split(regex);
|
||||
return parts.map((part, i) =>
|
||||
regex.test(part) ? <mark key={i} className="search-highlight">{part}</mark> : part
|
||||
);
|
||||
};
|
||||
|
||||
const EnvironmentVariablesTable = ({
|
||||
environment,
|
||||
collection,
|
||||
onSave,
|
||||
draft,
|
||||
onDraftChange,
|
||||
onDraftClear,
|
||||
setIsModified,
|
||||
renderExtraValueContent,
|
||||
searchQuery = ''
|
||||
}) => {
|
||||
const { storedTheme, theme } = useTheme();
|
||||
const valueMatchBg = theme?.colors?.accent ? `${theme.colors.accent}1a` : undefined;
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
|
||||
|
||||
const hasDraftForThisEnv = draft?.environmentUid === environment.uid;
|
||||
|
||||
const [tableHeight, setTableHeight] = useState(MIN_H);
|
||||
const [columnWidths, setColumnWidths] = useState({ name: '30%', value: 'auto' });
|
||||
const [resizing, setResizing] = useState(null);
|
||||
const [focusedNameIndex, setFocusedNameIndex] = useState(null);
|
||||
|
||||
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));
|
||||
|
||||
setColumnWidths({
|
||||
[columnKey]: `${startWidth + clampedDiff}px`,
|
||||
[nextColumnKey]: `${nextColumnStartWidth - clampedDiff}px`
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setResizing(null);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}, []);
|
||||
|
||||
const handleTotalHeightChanged = useCallback((h) => {
|
||||
setTableHeight(h);
|
||||
}, []);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
// 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');
|
||||
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, 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();
|
||||
|
||||
return allVariables.filter(({ variable }) => {
|
||||
const nameMatch = variable.name ? variable.name.toLowerCase().includes(query) : false;
|
||||
const valueMatch = typeof variable.value === 'string' ? variable.value.toLowerCase().includes(query) : false;
|
||||
return !!(nameMatch || valueMatch);
|
||||
});
|
||||
}, [formik.values, searchQuery]);
|
||||
|
||||
const isSearchActive = !!searchQuery?.trim();
|
||||
|
||||
return (
|
||||
<StyledWrapper className={resizing ? 'is-resizing' : ''}>
|
||||
{isSearchActive && filteredVariables.length === 0 ? (
|
||||
<div className="no-results">No results found for “{searchQuery.trim()}”</div>
|
||||
) : (
|
||||
<TableVirtuoso
|
||||
className="table-container"
|
||||
style={{ height: tableHeight }}
|
||||
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>
|
||||
)}
|
||||
fixedItemHeight={35}
|
||||
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;
|
||||
const activeQuery = searchQuery?.trim().toLowerCase();
|
||||
const valueMatchesOnly = activeQuery
|
||||
&& !(variable.name?.toLowerCase().includes(activeQuery))
|
||||
&& typeof variable.value === 'string'
|
||||
&& variable.value.toLowerCase().includes(activeQuery);
|
||||
|
||||
return (
|
||||
<>
|
||||
<td className="text-center">
|
||||
{!isLastEmptyRow && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mousetrap"
|
||||
name={`${actualIndex}.enabled`}
|
||||
checked={variable.enabled}
|
||||
onChange={isSearchActive ? undefined : formik.handleChange}
|
||||
disabled={isSearchActive}
|
||||
/>
|
||||
)}
|
||||
</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.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
|
||||
readOnly={isSearchActive}
|
||||
onChange={isSearchActive ? undefined : (e) => handleNameChange(actualIndex, e)}
|
||||
onFocus={() => !isSearchActive && setFocusedNameIndex(actualIndex)}
|
||||
onBlur={() => {
|
||||
setFocusedNameIndex(null); if (!isSearchActive) handleNameBlur(actualIndex);
|
||||
}}
|
||||
onKeyDown={isSearchActive ? undefined : (e) => handleNameKeyDown(actualIndex, e)}
|
||||
style={searchQuery?.trim() && focusedNameIndex !== actualIndex ? { color: 'transparent' } : undefined}
|
||||
/>
|
||||
{searchQuery?.trim() && focusedNameIndex !== actualIndex && (
|
||||
<div className="name-highlight-overlay">
|
||||
{highlightText(variable.name || '', searchQuery)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ErrorMessage name={`${actualIndex}.name`} index={actualIndex} />
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className="flex flex-row flex-nowrap items-center"
|
||||
style={{ width: columnWidths.value, ...(valueMatchesOnly && valueMatchBg ? { background: valueMatchBg } : {}) }}
|
||||
>
|
||||
<div className="overflow-hidden grow w-full relative">
|
||||
<MultiLineEditor
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
name={`${actualIndex}.value`}
|
||||
value={variable.value}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
isSecret={variable.secret}
|
||||
readOnly={isSearchActive || 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);
|
||||
}
|
||||
}}
|
||||
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={isSearchActive ? undefined : formik.handleChange}
|
||||
disabled={isSearchActive}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{!isLastEmptyRow && (
|
||||
<button onClick={isSearchActive ? undefined : () => handleRemoveVar(variable.uid)} disabled={isSearchActive}>
|
||||
<IconTrash strokeWidth={1.5} size={18} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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;
|
||||
@@ -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;
|
||||
@@ -1,40 +0,0 @@
|
||||
import React from 'react';
|
||||
import { IconChevronRight } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const CollapsibleSection = ({
|
||||
title,
|
||||
expanded,
|
||||
onToggle,
|
||||
badge,
|
||||
actions,
|
||||
children
|
||||
}) => {
|
||||
return (
|
||||
<StyledWrapper className={expanded ? 'expanded' : 'collapsed'}>
|
||||
<div className="section-header" onClick={onToggle}>
|
||||
<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;
|
||||
@@ -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++;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
@@ -1,74 +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'}
|
||||
>
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
<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;
|
||||
@@ -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;
|
||||
@@ -1,344 +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 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 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={isSaving}
|
||||
/>
|
||||
</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={isSaving}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default DotEnvFileEditor;
|
||||
@@ -1,59 +0,0 @@
|
||||
import { uuid } from 'utils/common';
|
||||
|
||||
export const variablesToRaw = (variables) => {
|
||||
return variables
|
||||
.filter((v) => v.name && v.name.trim() !== '')
|
||||
.map((v) => {
|
||||
const value = v.value || '';
|
||||
if (value.includes('\n') || value.includes('"') || value.includes('\'')) {
|
||||
const escapedValue = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
||||
return `${v.name}="${escapedValue}"`;
|
||||
}
|
||||
return `${v.name}=${value}`;
|
||||
})
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
export const rawToVariables = (rawContent) => {
|
||||
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('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
|
||||
value = value.slice(1, -1);
|
||||
value = value.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
||||
}
|
||||
|
||||
if (name) {
|
||||
variables.push({
|
||||
uid: uuid(),
|
||||
name,
|
||||
value,
|
||||
enabled: true,
|
||||
secret: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return variables;
|
||||
};
|
||||
|
||||
export const MIN_TABLE_HEIGHT = 35 * 2;
|
||||
@@ -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,
|
||||
@@ -39,7 +38,6 @@ 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>
|
||||
))}
|
||||
|
||||
@@ -33,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 {
|
||||
|
||||
@@ -13,166 +13,6 @@ import ImportEnvironmentModal from 'components/Environments/Common/ImportEnviron
|
||||
import CreateGlobalEnvironment from 'components/WorkspaceHome/WorkspaceEnvironments/CreateEnvironment';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { transparentize, toColorString, parseToRgb } from 'polished';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'collection', label: 'Collection', icon: <IconDatabase size={16} strokeWidth={1.5} /> },
|
||||
{ id: 'global', label: 'Global', icon: <IconWorld size={16} strokeWidth={1.5} /> }
|
||||
];
|
||||
|
||||
const EMPTY_STATE_DESCRIPTIONS = {
|
||||
collection: 'Create your first environment to begin working with your collection.',
|
||||
global: 'Create your first global environment to begin working across collections.'
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates background color with transparency for environment badges
|
||||
*/
|
||||
const getEnvBackgroundColor = (color) => (color ? transparentize(1 - 0.12, color) : 'transparent');
|
||||
|
||||
/**
|
||||
* Calculates the style for an environment badge section
|
||||
*/
|
||||
const getEnvBadgeStyle = (environment, position, hasOtherEnv) => {
|
||||
const color = environment?.color;
|
||||
const isLeft = position === 'left';
|
||||
|
||||
// Determine border radius based on position and whether other env exists
|
||||
let borderRadius = '0.3rem';
|
||||
if (hasOtherEnv) {
|
||||
borderRadius = isLeft ? '0.3rem 0 0 0.3rem' : '0 0.3rem 0.3rem 0';
|
||||
}
|
||||
|
||||
// Determine padding based on position
|
||||
const padding = isLeft
|
||||
? hasOtherEnv
|
||||
? '0.25rem 0.5rem 0.25rem 0.5rem'
|
||||
: '0.25rem 0.3rem 0.25rem 0.5rem'
|
||||
: '0.25rem 0.3rem 0.25rem 0.5rem';
|
||||
|
||||
return {
|
||||
backgroundColor: getEnvBackgroundColor(color),
|
||||
padding,
|
||||
borderRadius
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates dropdown width based on longest environment name
|
||||
*/
|
||||
const calculateDropdownWidth = (environments, globalEnvironments) => {
|
||||
const allEnvironments = [...environments, ...globalEnvironments];
|
||||
if (allEnvironments.length === 0) return 0;
|
||||
|
||||
const maxCharLength = Math.max(...allEnvironments.map((env) => env.name?.length || 0));
|
||||
// 8 pixels per character (rough estimate for average character width)
|
||||
return maxCharLength * 8;
|
||||
};
|
||||
|
||||
/**
|
||||
* Displays a single environment with icon, name, and optional color styling
|
||||
*/
|
||||
const EnvironmentBadge = ({ environment, icon: Icon }) => {
|
||||
if (!environment) return null;
|
||||
|
||||
const colorStyle = environment.color ? { color: environment.color } : {};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Icon size={14} strokeWidth={1.5} className="env-icon" style={colorStyle} />
|
||||
<ToolHint
|
||||
text={environment.name}
|
||||
toolhintId={`env-${environment.uid}`}
|
||||
place="bottom-start"
|
||||
delayShow={1000}
|
||||
hidden={environment.name?.length < 7}
|
||||
>
|
||||
<span className="env-text max-w-24 truncate overflow-hidden" style={colorStyle}>
|
||||
{environment.name}
|
||||
</span>
|
||||
</ToolHint>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Dropdown trigger component showing active environments
|
||||
*/
|
||||
const DropdownTrigger = forwardRef(({ collectionEnv, globalEnv }, ref) => {
|
||||
const hasAnyEnv = collectionEnv || globalEnv;
|
||||
|
||||
// Empty state - no environments selected
|
||||
if (!hasAnyEnv) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="current-environment flex align-center justify-center cursor-pointer bg-transparent no-environments"
|
||||
data-testid="environment-selector-trigger"
|
||||
>
|
||||
<span className="env-text-inactive max-w-36 truncate no-wrap">No Environment</span>
|
||||
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Only collection env selected - caret goes with collection env
|
||||
if (collectionEnv && !globalEnv) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="current-environment flex align-center justify-center cursor-pointer bg-transparent"
|
||||
style={{ padding: 0 }}
|
||||
data-testid="environment-selector-trigger"
|
||||
>
|
||||
<div className="flex items-center" style={getEnvBadgeStyle(collectionEnv, 'left', false)}>
|
||||
<EnvironmentBadge environment={collectionEnv} icon={IconDatabase} />
|
||||
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Only global env selected - caret goes with global env
|
||||
if (!collectionEnv && globalEnv) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="current-environment flex align-center justify-center cursor-pointer bg-transparent"
|
||||
style={{ padding: 0 }}
|
||||
data-testid="environment-selector-trigger"
|
||||
>
|
||||
<div className="flex items-center" style={getEnvBadgeStyle(globalEnv, 'right', false)}>
|
||||
<EnvironmentBadge environment={globalEnv} icon={IconWorld} />
|
||||
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Both environments selected
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="current-environment flex align-center justify-center cursor-pointer bg-transparent"
|
||||
style={{ padding: 0 }}
|
||||
data-testid="environment-selector-trigger"
|
||||
>
|
||||
{/* Collection Environment Section */}
|
||||
<div className="flex items-center" style={getEnvBadgeStyle(collectionEnv, 'left', true)}>
|
||||
<EnvironmentBadge environment={collectionEnv} icon={IconDatabase} />
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="env-separator" style={{ width: '1px', alignSelf: 'stretch' }} />
|
||||
|
||||
{/* Global Environment Section + Caret */}
|
||||
<div className="flex items-center" style={getEnvBadgeStyle(globalEnv, 'right', true)}>
|
||||
<EnvironmentBadge environment={globalEnv} icon={IconWorld} />
|
||||
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const EnvironmentSelector = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -195,82 +35,159 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
? find(environments, (e) => e.uid === activeEnvironmentUid)
|
||||
: null;
|
||||
|
||||
const dropdownWidth = useMemo(
|
||||
() => calculateDropdownWidth(environments, globalEnvironments),
|
||||
[environments, globalEnvironments]
|
||||
);
|
||||
const tabs = [
|
||||
{ id: 'collection', label: 'Collection', icon: <IconDatabase size={16} strokeWidth={1.5} /> },
|
||||
{ id: 'global', label: 'Global', icon: <IconWorld size={16} strokeWidth={1.5} /> }
|
||||
];
|
||||
|
||||
const description = EMPTY_STATE_DESCRIPTIONS[activeTab];
|
||||
const onDropdownCreate = (ref) => {
|
||||
dropdownTippyRef.current = ref;
|
||||
};
|
||||
|
||||
const hideDropdown = () => dropdownTippyRef.current?.hide();
|
||||
// Get description based on active tab
|
||||
const description
|
||||
= activeTab === 'collection'
|
||||
? 'Create your first environment to begin working with your collection.'
|
||||
: 'Create your first global environment to begin working across collections.';
|
||||
|
||||
// Environment selection handler
|
||||
const handleEnvironmentSelect = (environment) => {
|
||||
const action
|
||||
= activeTab === 'collection'
|
||||
? selectEnvironment(environment?.uid || null, collection.uid)
|
||||
: selectGlobalEnvironment({ environmentUid: environment?.uid || null });
|
||||
? selectEnvironment(environment ? environment.uid : null, collection.uid)
|
||||
: selectGlobalEnvironment({ environmentUid: environment ? environment.uid : null });
|
||||
|
||||
dispatch(action)
|
||||
.then(() => {
|
||||
toast.success(environment ? `Environment changed to ${environment.name}` : 'No Environments are active now');
|
||||
hideDropdown();
|
||||
if (environment) {
|
||||
toast.success(`Environment changed to ${environment.name}`);
|
||||
} else {
|
||||
toast.success('No Environments are active now');
|
||||
}
|
||||
dropdownTippyRef.current.hide();
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((err) => {
|
||||
toast.error('An error occurred while selecting the environment');
|
||||
});
|
||||
};
|
||||
|
||||
// Settings handler - opens environment settings tab
|
||||
const handleSettingsClick = () => {
|
||||
const isCollection = activeTab === 'collection';
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-${isCollection ? 'environment' : 'global-environment'}-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: isCollection ? 'environment-settings' : 'global-environment-settings'
|
||||
})
|
||||
);
|
||||
hideDropdown();
|
||||
if (activeTab === 'collection') {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-environment-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: 'environment-settings'
|
||||
})
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-global-environment-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: 'global-environment-settings'
|
||||
})
|
||||
);
|
||||
}
|
||||
dropdownTippyRef.current.hide();
|
||||
};
|
||||
|
||||
// Create handler
|
||||
const handleCreateClick = () => {
|
||||
if (activeTab === 'collection') {
|
||||
setShowCreateCollectionModal(true);
|
||||
} else {
|
||||
setShowCreateGlobalModal(true);
|
||||
}
|
||||
hideDropdown();
|
||||
dropdownTippyRef.current.hide();
|
||||
};
|
||||
|
||||
// Import handler
|
||||
const handleImportClick = () => {
|
||||
if (activeTab === 'collection') {
|
||||
setShowImportCollectionModal(true);
|
||||
} else {
|
||||
setShowImportGlobalModal(true);
|
||||
}
|
||||
hideDropdown();
|
||||
dropdownTippyRef.current.hide();
|
||||
};
|
||||
|
||||
const openEnvironmentSettingsTab = (type) => {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-${type}-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: `${type}-settings`
|
||||
})
|
||||
// Calculate dropdown width based on the longest environment name.
|
||||
// To prevent resizing while switching between collection and global environments.
|
||||
const dropdownWidth = useMemo(() => {
|
||||
const allEnvironments = [...environments, ...globalEnvironments];
|
||||
if (allEnvironments.length === 0) return 0;
|
||||
|
||||
const maxCharLength = Math.max(...allEnvironments.map((env) => env.name?.length || 0));
|
||||
// 8 pixels per character: This is a rough estimate for the average character width in most fonts
|
||||
// (monospace fonts are typically 8-10px, proportional fonts vary but 8px is a safe average)
|
||||
return maxCharLength * 8;
|
||||
}, [environments, globalEnvironments]);
|
||||
|
||||
// Create icon component for dropdown trigger
|
||||
const Icon = forwardRef((props, ref) => {
|
||||
const hasAnyEnv = activeGlobalEnvironment || activeCollectionEnvironment;
|
||||
|
||||
const displayContent = hasAnyEnv ? (
|
||||
<>
|
||||
{activeCollectionEnvironment && (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<IconDatabase size={14} strokeWidth={1.5} className="env-icon" />
|
||||
<ToolHint
|
||||
text={activeCollectionEnvironment.name}
|
||||
toolhintId={`collection-env-${activeCollectionEnvironment.uid}`}
|
||||
place="bottom-start"
|
||||
delayShow={1000}
|
||||
hidden={activeCollectionEnvironment.name?.length < 7}
|
||||
>
|
||||
<span className="env-text max-w-24 truncate overflow-hidden">{activeCollectionEnvironment.name}</span>
|
||||
</ToolHint>
|
||||
</div>
|
||||
{activeGlobalEnvironment && <span className="env-separator">|</span>}
|
||||
</>
|
||||
)}
|
||||
{activeGlobalEnvironment && (
|
||||
<div className="flex items-center">
|
||||
<IconWorld size={14} strokeWidth={1.5} className="env-icon" />
|
||||
<ToolHint
|
||||
text={activeGlobalEnvironment.name}
|
||||
toolhintId={`global-env-${activeGlobalEnvironment.uid}`}
|
||||
place="bottom-start"
|
||||
delayShow={1000}
|
||||
hidden={activeGlobalEnvironment.name?.length < 7}
|
||||
>
|
||||
<span className="env-text max-w-24 truncate overflow-hidden">{activeGlobalEnvironment.name}</span>
|
||||
</ToolHint>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="env-text-inactive max-w-36 truncate no-wrap">No Environment</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`current-environment flex align-center justify-center cursor-pointer bg-transparent ${
|
||||
!hasAnyEnv ? 'no-environments' : ''
|
||||
}`}
|
||||
data-testid="environment-selector-trigger"
|
||||
>
|
||||
{displayContent}
|
||||
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledWrapper width={dropdownWidth}>
|
||||
<div className="environment-selector flex align-center cursor-pointer">
|
||||
<Dropdown
|
||||
onCreate={(ref) => (dropdownTippyRef.current = ref)}
|
||||
icon={<DropdownTrigger collectionEnv={activeCollectionEnvironment} globalEnv={activeGlobalEnvironment} />}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
|
||||
{/* Tab Headers */}
|
||||
<div className="tab-header flex pt-3 pb-2 px-3">
|
||||
{TABS.map((tab) => (
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`tab-button whitespace-nowrap pb-[0.375rem] border-b-[0.125rem] bg-transparent flex align-center cursor-pointer transition-all duration-200 mr-[1.25rem] ${
|
||||
@@ -305,7 +222,15 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
{showCreateGlobalModal && (
|
||||
<CreateGlobalEnvironment
|
||||
onClose={() => setShowCreateGlobalModal(false)}
|
||||
onEnvironmentCreated={() => openEnvironmentSettingsTab('global-environment')}
|
||||
onEnvironmentCreated={() => {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-global-environment-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: 'global-environment-settings'
|
||||
})
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -313,7 +238,15 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
<ImportEnvironmentModal
|
||||
type="global"
|
||||
onClose={() => setShowImportGlobalModal(false)}
|
||||
onEnvironmentCreated={() => openEnvironmentSettingsTab('global-environment')}
|
||||
onEnvironmentCreated={() => {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-global-environment-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: 'global-environment-settings'
|
||||
})
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -321,7 +254,15 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
<CreateEnvironment
|
||||
collection={collection}
|
||||
onClose={() => setShowCreateCollectionModal(false)}
|
||||
onEnvironmentCreated={() => openEnvironmentSettingsTab('environment')}
|
||||
onEnvironmentCreated={() => {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-environment-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: 'environment-settings'
|
||||
})
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -330,7 +271,15 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
type="collection"
|
||||
collection={collection}
|
||||
onClose={() => setShowImportCollectionModal(false)}
|
||||
onEnvironmentCreated={() => openEnvironmentSettingsTab('environment')}
|
||||
onEnvironmentCreated={() => {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-environment-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: 'environment-settings'
|
||||
})
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
button.submit {
|
||||
color: white;
|
||||
background-color: var(--color-background-danger) !important;
|
||||
border: inherit !important;
|
||||
|
||||
&:hover {
|
||||
border: inherit !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
@@ -1,30 +0,0 @@
|
||||
import React from 'react';
|
||||
import Portal from 'components/Portal/index';
|
||||
import Modal from 'components/Modal/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const DeleteDotEnvFile = ({ onClose, onConfirm, filename = '.env' }) => {
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<StyledWrapper>
|
||||
<Modal
|
||||
size="sm"
|
||||
title={`Delete ${filename} File`}
|
||||
confirmText="Delete"
|
||||
handleConfirm={handleConfirm}
|
||||
handleCancel={onClose}
|
||||
confirmButtonColor="danger"
|
||||
>
|
||||
Are you sure you want to delete <span className="font-medium">{filename}</span> file?
|
||||
</Modal>
|
||||
</StyledWrapper>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteDotEnvFile;
|
||||
@@ -1,22 +1,11 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
const Wrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
.raw-editor-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
border: solid 1px ${(props) => props.theme.border.border0};
|
||||
|
||||
.CodeMirror {
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
}
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-y: auto;
|
||||
border-radius: 8px;
|
||||
@@ -33,14 +22,19 @@ const StyledWrapper = styled.div`
|
||||
vertical-align: middle;
|
||||
padding: 2px 10px;
|
||||
|
||||
&:first-child {
|
||||
width: 35%;
|
||||
&:nth-child(1) {
|
||||
width: 25px;
|
||||
border-right: none;
|
||||
}
|
||||
&:nth-child(4) {
|
||||
width: 80px;
|
||||
}
|
||||
&:nth-child(5) {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
&.delete-col {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
padding: 2px 4px;
|
||||
&:nth-child(2) {
|
||||
width: 30%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,32 +148,20 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
.discard {
|
||||
padding: 6px 16px;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
svg {
|
||||
opacity: 0.4;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
max-width: 300px;
|
||||
line-height: 1.5;
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
export default Wrapper;
|
||||
@@ -1,20 +1,57 @@
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import React, { useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import { TableVirtuoso } from 'react-virtuoso';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { get } from 'lodash';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
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 { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { setEnvironmentsDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
|
||||
import { flattenItems, isItemARequest } from 'utils/collections';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { getGlobalEnvironmentVariables, flattenItems, isItemARequest } from 'utils/collections';
|
||||
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
|
||||
import EnvironmentVariablesTable from 'components/EnvironmentVariablesTable';
|
||||
import { sensitiveFields } from './constants';
|
||||
|
||||
const EnvironmentVariables = ({ environment, setIsModified, collection, searchQuery = '' }) => {
|
||||
const TableRow = React.memo(({ children, item }) => <tr key={item.uid} 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 MIN_H = 35 * 2; // 2 rows worth of height
|
||||
|
||||
const EnvironmentVariables = ({ environment, setIsModified, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
|
||||
|
||||
const [tableHeight, setTableHeight] = React.useState(MIN_H);
|
||||
|
||||
const environmentsDraft = collection?.environmentsDraft;
|
||||
const hasDraftForThisEnv = environmentsDraft?.environmentUid === environment.uid;
|
||||
|
||||
const handleTotalHeightChanged = React.useCallback((h) => {
|
||||
setTableHeight(h);
|
||||
}, []);
|
||||
|
||||
// Track environment changes for draft restoration
|
||||
const prevEnvUidRef = React.useRef(null);
|
||||
const mountedRef = React.useRef(false);
|
||||
|
||||
let _collection = collection ? cloneDeep(collection) : {};
|
||||
|
||||
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
|
||||
if (_collection) {
|
||||
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
|
||||
}
|
||||
|
||||
// Check for non-secret variables used in sensitive fields
|
||||
const nonSecretSensitiveVarUsageMap = useMemo(() => {
|
||||
const result = {};
|
||||
@@ -60,59 +97,428 @@ const EnvironmentVariables = ({ environment, setIsModified, collection, searchQu
|
||||
return result;
|
||||
}, [collection, environment]);
|
||||
|
||||
const hasSensitiveUsage = useCallback((name) => !!nonSecretSensitiveVarUsageMap[name], [nonSecretSensitiveVarUsageMap]);
|
||||
const hasSensitiveUsage = (name) => !!nonSecretSensitiveVarUsageMap[name];
|
||||
|
||||
const handleSave = useCallback(
|
||||
(variables) => {
|
||||
return dispatch(saveEnvironment(cloneDeep(variables), environment.uid, collection.uid));
|
||||
},
|
||||
[dispatch, environment.uid, collection.uid]
|
||||
);
|
||||
|
||||
const handleDraftChange = useCallback(
|
||||
(variables) => {
|
||||
dispatch(
|
||||
setEnvironmentsDraft({
|
||||
collectionUid: collection.uid,
|
||||
environmentUid: environment.uid,
|
||||
variables
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch, collection.uid, environment.uid]
|
||||
);
|
||||
|
||||
const handleDraftClear = useCallback(() => {
|
||||
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
|
||||
}, [dispatch, collection.uid]);
|
||||
|
||||
const renderExtraValueContent = useCallback(
|
||||
(variable) => {
|
||||
if (!variable.secret && hasSensitiveUsage(variable.name)) {
|
||||
return (
|
||||
<SensitiveFieldWarning
|
||||
fieldName={variable.name}
|
||||
warningMessage="This variable is used in sensitive fields. Mark it as a secret for security"
|
||||
/>
|
||||
);
|
||||
// Initial values based only on saved environment variables (not draft)
|
||||
// Draft restoration happens in a separate effect to avoid infinite loops
|
||||
const initialValues = React.useMemo(() => {
|
||||
const vars = environment.variables || [];
|
||||
return [
|
||||
...vars,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}
|
||||
return null;
|
||||
];
|
||||
}, [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 : {};
|
||||
},
|
||||
[hasSensitiveUsage]
|
||||
);
|
||||
onSubmit: () => {}
|
||||
});
|
||||
|
||||
// Restore draft values on mount or environment switch
|
||||
useEffect(() => {
|
||||
const isMount = !mountedRef.current;
|
||||
const envChanged = prevEnvUidRef.current !== null && prevEnvUidRef.current !== environment.uid;
|
||||
|
||||
prevEnvUidRef.current = environment.uid;
|
||||
mountedRef.current = true;
|
||||
|
||||
if ((isMount || envChanged) && hasDraftForThisEnv && environmentsDraft?.variables) {
|
||||
formik.setValues([
|
||||
...environmentsDraft.variables,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}
|
||||
]);
|
||||
}
|
||||
}, [environment.uid, hasDraftForThisEnv, environmentsDraft?.variables]);
|
||||
|
||||
const savedValuesJson = useMemo(() => {
|
||||
return JSON.stringify(environment.variables || []);
|
||||
}, [environment.variables]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
const currentValuesJson = JSON.stringify(currentValues);
|
||||
const hasActualChanges = currentValuesJson !== savedValuesJson;
|
||||
setIsModified(hasActualChanges);
|
||||
}, [formik.values, savedValuesJson, setIsModified]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
const currentValuesJson = JSON.stringify(currentValues);
|
||||
const hasActualChanges = currentValuesJson !== savedValuesJson;
|
||||
|
||||
// Get existing draft for comparison
|
||||
const existingDraftVariables = hasDraftForThisEnv ? environmentsDraft?.variables : null;
|
||||
const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables) : null;
|
||||
|
||||
if (hasActualChanges) {
|
||||
// Only dispatch if draft values are actually different
|
||||
if (currentValuesJson !== existingDraftJson) {
|
||||
dispatch(setEnvironmentsDraft({
|
||||
collectionUid: collection.uid,
|
||||
environmentUid: environment.uid,
|
||||
variables: currentValues
|
||||
}));
|
||||
}
|
||||
} else if (hasDraftForThisEnv) {
|
||||
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [formik.values, savedValuesJson, environment.uid, collection.uid, dispatch, hasDraftForThisEnv, environmentsDraft?.variables]);
|
||||
|
||||
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 = () => {
|
||||
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
const savedValues = environment.variables || [];
|
||||
|
||||
const hasChanges = JSON.stringify(variablesToSave) !== JSON.stringify(savedValues);
|
||||
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;
|
||||
}
|
||||
|
||||
dispatch(saveEnvironment(cloneDeep(variablesToSave), environment.uid, collection.uid))
|
||||
.then(() => {
|
||||
toast.success('Changes saved successfully');
|
||||
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');
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const originalVars = environment.variables || [];
|
||||
const resetValues = [
|
||||
...originalVars,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}
|
||||
];
|
||||
formik.resetForm({ values: resetValues });
|
||||
setIsModified(false);
|
||||
};
|
||||
|
||||
const handleSaveRef = useRef(handleSave);
|
||||
handleSaveRef.current = handleSave;
|
||||
|
||||
useEffect(() => {
|
||||
const handleSaveEvent = () => {
|
||||
handleSaveRef.current();
|
||||
};
|
||||
|
||||
window.addEventListener('environment-save', handleSaveEvent);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('environment-save', handleSaveEvent);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<EnvironmentVariablesTable
|
||||
environment={environment}
|
||||
collection={collection}
|
||||
onSave={handleSave}
|
||||
draft={hasDraftForThisEnv ? environmentsDraft : null}
|
||||
onDraftChange={handleDraftChange}
|
||||
onDraftClear={handleDraftClear}
|
||||
setIsModified={setIsModified}
|
||||
renderExtraValueContent={renderExtraValueContent}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
<StyledWrapper>
|
||||
<TableVirtuoso
|
||||
className="table-container"
|
||||
style={{ height: tableHeight }}
|
||||
components={{ TableRow }}
|
||||
data={formik.values}
|
||||
totalListHeightChanged={handleTotalHeightChanged}
|
||||
fixedHeaderContent={() => (
|
||||
<tr>
|
||||
<td className="text-center"></td>
|
||||
<td>Name</td>
|
||||
<td>Value</td>
|
||||
<td className="text-center">Secret</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
)}
|
||||
fixedItemHeight={35}
|
||||
computeItemKey={(index, variable) => variable.uid}
|
||||
itemContent={(index, variable) => {
|
||||
const isLastRow = index === 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={`${index}.enabled`}
|
||||
checked={variable.enabled}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<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) => handleNameChange(index, e)}
|
||||
onBlur={() => handleNameBlur(index)}
|
||||
onKeyDown={(e) => handleNameKeyDown(index, e)}
|
||||
/>
|
||||
<ErrorMessage name={`${index}.name`} index={index} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="flex flex-row flex-nowrap items-center">
|
||||
<div className="overflow-hidden grow w-full relative">
|
||||
<MultiLineEditor
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
name={`${index}.value`}
|
||||
value={variable.value}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
isSecret={variable.secret}
|
||||
readOnly={typeof variable.value !== 'string'}
|
||||
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
|
||||
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>
|
||||
)}
|
||||
{!variable.secret && hasSensitiveUsage(variable.name) && (
|
||||
<SensitiveFieldWarning
|
||||
fieldName={variable.name}
|
||||
warningMessage="This variable is used in sensitive fields. Mark it as a secret for security"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{!isLastEmptyRow && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mousetrap"
|
||||
name={`${index}.secret`}
|
||||
checked={variable.secret}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{!isLastEmptyRow && (
|
||||
<button onClick={() => handleRemoveVar(variable.uid)}>
|
||||
<IconTrash strokeWidth={1.5} size={18} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -94,63 +94,8 @@ const StyledWrapper = styled.div`
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 200px;
|
||||
padding: 5px 32px 5px 32px;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&:focus {
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.input.placeholder.color};
|
||||
opacity: ${(props) => props.theme.input.placeholder.opacity};
|
||||
}
|
||||
}
|
||||
|
||||
.clear-search {
|
||||
position: absolute;
|
||||
right: 1px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch } from '@tabler/icons';
|
||||
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX } from '@tabler/icons';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { renameEnvironment, updateEnvironmentColor } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { renameEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import CopyEnvironment from 'components/Environments/EnvironmentSettings/CopyEnvironment';
|
||||
import DeleteEnvironment from 'components/Environments/EnvironmentSettings/DeleteEnvironment';
|
||||
import EnvironmentVariables from './EnvironmentVariables';
|
||||
import ColorPicker from 'components/ColorPicker';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuery, setSearchQuery, isSearchExpanded, setIsSearchExpanded, debouncedSearchQuery, searchInputRef }) => {
|
||||
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const environments = collection?.environments || [];
|
||||
|
||||
@@ -112,27 +111,6 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchIconClick = () => {
|
||||
setIsSearchExpanded(true);
|
||||
setTimeout(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const handleSearchBlur = () => {
|
||||
if (searchQuery === '') {
|
||||
setIsSearchExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleColorChange = (color) => {
|
||||
dispatch(updateEnvironmentColor(environment.uid, color, collection.uid));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{openDeleteModal && (
|
||||
@@ -179,46 +157,11 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="title">{environment.name}</h2>
|
||||
<ColorPicker color={environment.color} onChange={handleColorChange} />
|
||||
</div>
|
||||
<h2 className="title">{environment.name}</h2>
|
||||
)}
|
||||
</div>
|
||||
{nameError && isRenaming && <div className="title-error">{nameError}</div>}
|
||||
<div className="actions">
|
||||
{isSearchExpanded ? (
|
||||
<div className="search-input-wrapper">
|
||||
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Search variables..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onBlur={handleSearchBlur}
|
||||
className="search-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="clear-search"
|
||||
onClick={handleClearSearch}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Clear search"
|
||||
>
|
||||
<IconX size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={handleSearchIconClick} title="Search variables">
|
||||
<IconSearch size={15} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleRenameClick} title="Rename">
|
||||
<IconEdit size={15} strokeWidth={1.5} />
|
||||
</button>
|
||||
@@ -232,12 +175,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
|
||||
</div>
|
||||
|
||||
<div className="content">
|
||||
<EnvironmentVariables
|
||||
environment={environment}
|
||||
setIsModified={setIsModified}
|
||||
collection={collection}
|
||||
searchQuery={debouncedSearchQuery}
|
||||
/>
|
||||
<EnvironmentVariables environment={environment} setIsModified={setIsModified} collection={collection} />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -32,6 +32,19 @@ const StyledWrapper = styled.div`
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 16px 12px 16px;
|
||||
|
||||
.title {
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.text};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -53,93 +66,43 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.env-list-search {
|
||||
.search-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 4px 6px 4px;
|
||||
|
||||
.env-list-search-icon {
|
||||
padding: 0 12px 12px 12px;
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
left: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-100%);
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.env-list-search-input {
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 5px 24px 5px 26px;
|
||||
padding: 6px 8px 6px 28px;
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: 5px;
|
||||
color: ${(props) => props.theme.text};
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: ${(props) => props.theme.colors.accent};
|
||||
}
|
||||
}
|
||||
|
||||
.env-list-search-clear {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sections-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.environments-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.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};
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: ${(props) => props.theme.colors.accent};
|
||||
}
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.environment-item {
|
||||
@@ -147,7 +110,6 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
margin-bottom: 1px;
|
||||
font-size: 13px;
|
||||
@@ -318,39 +280,6 @@ const StyledWrapper = styled.div`
|
||||
background: ${(props) => `${props.theme.colors.text.danger}15`};
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.no-env-file {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 10%;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
svg {
|
||||
opacity: 0.3;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -1,35 +1,16 @@
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import usePrevious from 'hooks/usePrevious';
|
||||
import useOnClickOutside from 'hooks/useOnClickOutside';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import EnvironmentDetails from './EnvironmentDetails';
|
||||
import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX, IconFileAlert } from '@tabler/icons';
|
||||
import Button from 'ui/Button';
|
||||
import CreateEnvironment from 'components/Environments/EnvironmentSettings/CreateEnvironment';
|
||||
import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import ConfirmSwitchEnv from 'components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/ConfirmSwitchEnv';
|
||||
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
|
||||
import CollapsibleSection from 'components/Environments/CollapsibleSection';
|
||||
import DotEnvFileEditor from 'components/Environments/DotEnvFileEditor';
|
||||
import DotEnvFileDetails from 'components/Environments/DotEnvFileDetails';
|
||||
import ColorBadge from 'components/ColorBadge';
|
||||
import { isEqual } from 'lodash';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
addEnvironment,
|
||||
renameEnvironment,
|
||||
selectEnvironment,
|
||||
saveDotEnvVariables,
|
||||
saveDotEnvRaw,
|
||||
createDotEnvFile,
|
||||
deleteDotEnvFile
|
||||
} from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { setEnvironmentsDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
|
||||
import { setEnvVarSearchQuery, setEnvVarSearchExpanded } from 'providers/ReduxStore/slices/app';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { addEnvironment, renameEnvironment, selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import classnames from 'classnames';
|
||||
|
||||
const EMPTY_ARRAY = [];
|
||||
|
||||
const EnvironmentList = ({
|
||||
environments,
|
||||
@@ -42,15 +23,10 @@ const EnvironmentList = ({
|
||||
setShowExportModal
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const envSearchQuery = useSelector((state) => state.app.envVarSearch?.collection?.query ?? '');
|
||||
const isEnvSearchExpanded = useSelector((state) => state.app.envVarSearch?.collection?.expanded ?? false);
|
||||
const setEnvSearchQuery = (q) => dispatch(setEnvVarSearchQuery({ context: 'collection', query: q }));
|
||||
const setIsEnvSearchExpanded = (v) => dispatch(setEnvVarSearchExpanded({ context: 'collection', expanded: v }));
|
||||
|
||||
const [openCreateModal, setOpenCreateModal] = useState(false);
|
||||
const [openImportModal, setOpenImportModal] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [isEnvListSearchExpanded, setIsEnvListSearchExpanded] = useState(false);
|
||||
const envListSearchInputRef = useRef(null);
|
||||
const [isCreatingInline, setIsCreatingInline] = useState(false);
|
||||
const [renamingEnvUid, setRenamingEnvUid] = useState(null);
|
||||
const [newEnvName, setNewEnvName] = useState('');
|
||||
@@ -61,56 +37,10 @@ const EnvironmentList = ({
|
||||
|
||||
const [switchEnvConfirmClose, setSwitchEnvConfirmClose] = useState(false);
|
||||
const [originalEnvironmentVariables, setOriginalEnvironmentVariables] = useState([]);
|
||||
const [environmentsExpanded, setEnvironmentsExpanded] = useState(true);
|
||||
const [dotEnvExpanded, setDotEnvExpanded] = useState(false);
|
||||
const [activeView, setActiveView] = useState('environment');
|
||||
const [isDotEnvModified, setIsDotEnvModified] = useState(false);
|
||||
const [dotEnvViewMode, setDotEnvViewMode] = useState('table');
|
||||
const [selectedDotEnvFile, setSelectedDotEnvFile] = useState(null);
|
||||
const [isCreatingDotEnvInline, setIsCreatingDotEnvInline] = useState(false);
|
||||
const [newDotEnvName, setNewDotEnvName] = useState('.env');
|
||||
const [dotEnvNameError, setDotEnvNameError] = useState('');
|
||||
const dotEnvInputRef = useRef(null);
|
||||
const dotEnvCreateContainerRef = useRef(null);
|
||||
|
||||
const debouncedEnvSearchQuery = useDebounce(envSearchQuery, 300);
|
||||
const envSearchInputRef = useRef(null);
|
||||
|
||||
const dotEnvFiles = useSelector((state) => {
|
||||
const coll = state.collections.collections.find((c) => c.uid === collection?.uid);
|
||||
return coll?.dotEnvFiles || EMPTY_ARRAY;
|
||||
});
|
||||
|
||||
const envUids = environments ? environments.map((env) => env.uid) : [];
|
||||
const prevEnvUids = usePrevious(envUids);
|
||||
|
||||
const handleDotEnvModifiedChange = useCallback((modified) => {
|
||||
setIsDotEnvModified(modified);
|
||||
if (modified) {
|
||||
dispatch(setEnvironmentsDraft({
|
||||
collectionUid: collection.uid,
|
||||
environmentUid: `dotenv:${selectedDotEnvFile}`,
|
||||
variables: []
|
||||
}));
|
||||
} else {
|
||||
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
|
||||
}
|
||||
}, [dispatch, collection.uid, selectedDotEnvFile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dotEnvFiles.length === 0) {
|
||||
setSelectedDotEnvFile(null);
|
||||
setActiveView('environment');
|
||||
handleDotEnvModifiedChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileExists = dotEnvFiles.some((f) => f.filename === selectedDotEnvFile);
|
||||
if (!selectedDotEnvFile || !fileExists) {
|
||||
setSelectedDotEnvFile(dotEnvFiles[0].filename);
|
||||
}
|
||||
}, [dotEnvFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!environments?.length) {
|
||||
setSelectedEnvironment(null);
|
||||
@@ -156,34 +86,44 @@ const EnvironmentList = ({
|
||||
}
|
||||
}, [envUids, environments, prevEnvUids]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!renamingEnvUid) return;
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (renameContainerRef.current && !renameContainerRef.current.contains(event.target)) {
|
||||
handleCancelRename();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [renamingEnvUid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCreatingInline) return;
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (createContainerRef.current && !createContainerRef.current.contains(event.target)) {
|
||||
handleCancelCreate();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isCreatingInline]);
|
||||
|
||||
const handleEnvironmentClick = (env) => {
|
||||
if (activeView === 'dotenv' && isDotEnvModified) {
|
||||
setSwitchEnvConfirmClose(true);
|
||||
return;
|
||||
}
|
||||
if (!isModified) {
|
||||
setSelectedEnvironment(env);
|
||||
setActiveView('environment');
|
||||
setEnvironmentsExpanded(true);
|
||||
} else {
|
||||
setSwitchEnvConfirmClose(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDotEnvClick = (filename) => {
|
||||
if (isModified) {
|
||||
setSwitchEnvConfirmClose(true);
|
||||
return;
|
||||
}
|
||||
if (activeView === 'dotenv' && isDotEnvModified && selectedDotEnvFile !== filename) {
|
||||
setSwitchEnvConfirmClose(true);
|
||||
return;
|
||||
}
|
||||
setSelectedDotEnvFile(filename);
|
||||
setActiveView('dotenv');
|
||||
setDotEnvExpanded(true);
|
||||
};
|
||||
|
||||
const handleEnvironmentDoubleClick = (env) => {
|
||||
setRenamingEnvUid(env.uid);
|
||||
setNewEnvName(env.name);
|
||||
@@ -194,7 +134,7 @@ const EnvironmentList = ({
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleActivateEnvironment = useCallback((e, env) => {
|
||||
const handleActivateEnvironment = (e, env) => {
|
||||
e.stopPropagation();
|
||||
dispatch(selectEnvironment(env.uid, collection.uid))
|
||||
.then(() => {
|
||||
@@ -203,7 +143,11 @@ const EnvironmentList = ({
|
||||
.catch(() => {
|
||||
toast.error('Failed to activate environment');
|
||||
});
|
||||
}, [dispatch, collection.uid]);
|
||||
};
|
||||
|
||||
if (!selectedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validateEnvironmentName = (name, excludeUid = null) => {
|
||||
if (!name || name.trim() === '') {
|
||||
@@ -226,7 +170,7 @@ const EnvironmentList = ({
|
||||
};
|
||||
|
||||
const handleCreateEnvClick = () => {
|
||||
if (!isModified && !isDotEnvModified) {
|
||||
if (!isModified) {
|
||||
setIsCreatingInline(true);
|
||||
setNewEnvName('');
|
||||
setEnvNameError('');
|
||||
@@ -238,13 +182,11 @@ const EnvironmentList = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelCreate = useCallback(() => {
|
||||
const handleCancelCreate = () => {
|
||||
setIsCreatingInline(false);
|
||||
setNewEnvName('');
|
||||
setEnvNameError('');
|
||||
}, []);
|
||||
|
||||
useOnClickOutside(createContainerRef, handleCancelCreate, isCreatingInline);
|
||||
};
|
||||
|
||||
const handleSaveNewEnv = () => {
|
||||
const error = validateEnvironmentName(newEnvName);
|
||||
@@ -311,16 +253,14 @@ const EnvironmentList = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelRename = useCallback(() => {
|
||||
const handleCancelRename = () => {
|
||||
setRenamingEnvUid(null);
|
||||
setNewEnvName('');
|
||||
setEnvNameError('');
|
||||
}, []);
|
||||
|
||||
useOnClickOutside(renameContainerRef, handleCancelRename, !!renamingEnvUid);
|
||||
};
|
||||
|
||||
const handleImportClick = () => {
|
||||
if (!isModified && !isDotEnvModified) {
|
||||
if (!isModified) {
|
||||
setOpenImportModal(true);
|
||||
} else {
|
||||
setSwitchEnvConfirmClose(true);
|
||||
@@ -339,203 +279,12 @@ const EnvironmentList = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveDotEnv = (variables) => {
|
||||
if (!selectedDotEnvFile) return Promise.reject(new Error('No file selected'));
|
||||
return dispatch(saveDotEnvVariables(collection.uid, variables, selectedDotEnvFile));
|
||||
};
|
||||
|
||||
const handleSaveDotEnvRaw = (content) => {
|
||||
if (!selectedDotEnvFile) return Promise.reject(new Error('No file selected'));
|
||||
return dispatch(saveDotEnvRaw(collection.uid, content, selectedDotEnvFile));
|
||||
};
|
||||
|
||||
const handleCreateDotEnvInlineClick = () => {
|
||||
if (isModified || isDotEnvModified) {
|
||||
setSwitchEnvConfirmClose(true);
|
||||
return;
|
||||
}
|
||||
setIsCreatingDotEnvInline(true);
|
||||
setNewDotEnvName('.env');
|
||||
setDotEnvNameError('');
|
||||
setTimeout(() => {
|
||||
dotEnvInputRef.current?.focus();
|
||||
const input = dotEnvInputRef.current;
|
||||
if (input) {
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleCancelDotEnvCreate = useCallback(() => {
|
||||
setIsCreatingDotEnvInline(false);
|
||||
setNewDotEnvName('.env');
|
||||
setDotEnvNameError('');
|
||||
}, []);
|
||||
|
||||
useOnClickOutside(dotEnvCreateContainerRef, handleCancelDotEnvCreate, isCreatingDotEnvInline);
|
||||
|
||||
const validateDotEnvName = (name) => {
|
||||
if (!name || name.trim() === '') {
|
||||
return 'Name is required';
|
||||
}
|
||||
|
||||
if (!name.startsWith('.env')) {
|
||||
return 'File name must start with .env';
|
||||
}
|
||||
|
||||
const validPattern = /^\.env[a-zA-Z0-9._-]*$/;
|
||||
if (!validPattern.test(name)) {
|
||||
return 'Invalid file name';
|
||||
}
|
||||
|
||||
const exists = dotEnvFiles.some((f) => f.filename === name);
|
||||
if (exists) {
|
||||
return 'File already exists';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSaveNewDotEnv = () => {
|
||||
const error = validateDotEnvName(newDotEnvName);
|
||||
if (error) {
|
||||
setDotEnvNameError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(createDotEnvFile(collection.uid, newDotEnvName))
|
||||
.then(() => {
|
||||
toast.success(`${newDotEnvName} file created!`);
|
||||
setIsCreatingDotEnvInline(false);
|
||||
setNewDotEnvName('.env');
|
||||
setDotEnvNameError('');
|
||||
setSelectedDotEnvFile(newDotEnvName);
|
||||
setActiveView('dotenv');
|
||||
setDotEnvExpanded(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message || 'Failed to create .env file');
|
||||
});
|
||||
};
|
||||
|
||||
const handleDotEnvNameChange = (e) => {
|
||||
const value = e.target.value;
|
||||
if (!value.startsWith('.env')) {
|
||||
setNewDotEnvName('.env');
|
||||
} else {
|
||||
setNewDotEnvName(value);
|
||||
}
|
||||
if (dotEnvNameError) {
|
||||
setDotEnvNameError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDotEnvNameKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSaveNewDotEnv();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancelDotEnvCreate();
|
||||
} else if (e.key === 'Backspace') {
|
||||
const input = e.target;
|
||||
if (input.selectionStart <= 4 && input.selectionEnd <= 4) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDotEnvFile = (filename) => {
|
||||
dispatch(deleteDotEnvFile(collection.uid, filename))
|
||||
.then(() => {
|
||||
toast.success(`${filename} file deleted!`);
|
||||
handleDotEnvModifiedChange(false);
|
||||
if (selectedDotEnvFile === filename) {
|
||||
const remainingFiles = dotEnvFiles.filter((f) => f.filename !== filename);
|
||||
if (remainingFiles.length > 0) {
|
||||
setSelectedDotEnvFile(remainingFiles[0].filename);
|
||||
} else {
|
||||
setActiveView('environment');
|
||||
if (environments?.length) {
|
||||
const env = environments.find((e) => e.uid === activeEnvironmentUid) || environments[0];
|
||||
setSelectedEnvironment(env);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message || 'Failed to delete .env file');
|
||||
});
|
||||
};
|
||||
|
||||
const handleDotEnvViewModeChange = (mode) => {
|
||||
setDotEnvViewMode(mode);
|
||||
};
|
||||
|
||||
const filteredEnvironments
|
||||
= environments?.filter((env) => env.name.toLowerCase().includes(searchText.toLowerCase())) || [];
|
||||
|
||||
const selectedDotEnvData = dotEnvFiles.find((f) => f.filename === selectedDotEnvFile);
|
||||
|
||||
const renderContent = () => {
|
||||
if (activeView === 'dotenv' && selectedDotEnvFile && selectedDotEnvData) {
|
||||
return (
|
||||
<DotEnvFileDetails
|
||||
title={selectedDotEnvFile}
|
||||
onDelete={() => handleDeleteDotEnvFile(selectedDotEnvFile)}
|
||||
dotEnvExists={selectedDotEnvData?.exists}
|
||||
viewMode={dotEnvViewMode}
|
||||
onViewModeChange={handleDotEnvViewModeChange}
|
||||
>
|
||||
<DotEnvFileEditor
|
||||
variables={selectedDotEnvData?.variables || []}
|
||||
onSave={handleSaveDotEnv}
|
||||
onSaveRaw={handleSaveDotEnvRaw}
|
||||
isModified={isDotEnvModified}
|
||||
setIsModified={handleDotEnvModifiedChange}
|
||||
dotEnvExists={selectedDotEnvData?.exists}
|
||||
viewMode={dotEnvViewMode}
|
||||
collection={collection}
|
||||
/>
|
||||
</DotEnvFileDetails>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedEnvironment) {
|
||||
return (
|
||||
<EnvironmentDetails
|
||||
environment={selectedEnvironment}
|
||||
setIsModified={setIsModified}
|
||||
originalEnvironmentVariables={originalEnvironmentVariables}
|
||||
collection={collection}
|
||||
searchQuery={envSearchQuery}
|
||||
setSearchQuery={setEnvSearchQuery}
|
||||
isSearchExpanded={isEnvSearchExpanded}
|
||||
setIsSearchExpanded={setIsEnvSearchExpanded}
|
||||
debouncedSearchQuery={debouncedEnvSearchQuery}
|
||||
searchInputRef={envSearchInputRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<IconFileAlert size={48} strokeWidth={1.5} />
|
||||
<div className="title">No Environments</div>
|
||||
<div className="actions">
|
||||
<Button size="sm" color="secondary" onClick={() => handleCreateEnvClick()}>
|
||||
Create Environment
|
||||
</Button>
|
||||
<Button size="sm" color="secondary" onClick={() => handleImportClick()}>
|
||||
Import Environment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{openCreateModal && <CreateEnvironment collection={collection} onClose={() => setOpenCreateModal(false)} />}
|
||||
{openImportModal && (
|
||||
<ImportEnvironmentModal type="collection" collection={collection} onClose={() => setOpenImportModal(false)} />
|
||||
)}
|
||||
@@ -548,133 +297,43 @@ const EnvironmentList = ({
|
||||
)}
|
||||
|
||||
<div className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<h2 className="title">Environments</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
|
||||
<IconPlus size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button className="btn-action" onClick={() => handleImportClick()} title="Import environment">
|
||||
<IconDownload size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button className="btn-action" onClick={() => handleExportClick()} title="Export environment">
|
||||
<IconUpload size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sections-container">
|
||||
<CollapsibleSection
|
||||
title="Environments"
|
||||
expanded={environmentsExpanded}
|
||||
onToggle={() => setEnvironmentsExpanded(!environmentsExpanded)}
|
||||
actions={(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-action ${isEnvListSearchExpanded ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
const next = !isEnvListSearchExpanded;
|
||||
setIsEnvListSearchExpanded(next);
|
||||
if (!next) setSearchText('');
|
||||
else setTimeout(() => envListSearchInputRef.current?.focus(), 50);
|
||||
}}
|
||||
title="Search environments"
|
||||
>
|
||||
<IconSearch size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button type="button" className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
|
||||
<IconPlus size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button type="button" className="btn-action" onClick={() => handleImportClick()} title="Import environment">
|
||||
<IconDownload size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button type="button" className="btn-action" onClick={() => handleExportClick()} title="Export environment">
|
||||
<IconUpload size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{isEnvListSearchExpanded && (
|
||||
<div className="env-list-search">
|
||||
<IconSearch size={13} strokeWidth={1.5} className="env-list-search-icon" />
|
||||
<input
|
||||
ref={envListSearchInputRef}
|
||||
type="text"
|
||||
placeholder="Search environments..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="env-list-search-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
{searchText && (
|
||||
<button className="env-list-search-clear" title="Clear search" onClick={() => setSearchText('')} onMouseDown={(e) => e.preventDefault()}>
|
||||
<IconX size={12} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="environments-list">
|
||||
{filteredEnvironments.map((env) => (
|
||||
<div
|
||||
key={env.uid}
|
||||
id={env.uid}
|
||||
className={classnames('environment-item', {
|
||||
active: activeView === 'environment' && selectedEnvironment?.uid === env.uid,
|
||||
renaming: renamingEnvUid === env.uid,
|
||||
activated: activeEnvironmentUid === env.uid
|
||||
})}
|
||||
onClick={() => renamingEnvUid !== env.uid && handleEnvironmentClick(env)}
|
||||
onDoubleClick={() => handleEnvironmentDoubleClick(env)}
|
||||
>
|
||||
{renamingEnvUid === env.uid ? (
|
||||
<div className="rename-container" ref={renameContainerRef}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="environment-name-input"
|
||||
value={newEnvName}
|
||||
onChange={handleEnvNameChange}
|
||||
onKeyDown={handleEnvNameKeyDown}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="inline-action-btn save"
|
||||
onClick={handleSaveRename}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Save"
|
||||
>
|
||||
<IconCheck size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
className="inline-action-btn cancel"
|
||||
onClick={handleCancelRename}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Cancel"
|
||||
>
|
||||
<IconX size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ColorBadge color={env.color} size={8} />
|
||||
<span className="environment-name">{env.name}</span>
|
||||
<div className="environment-actions">
|
||||
{activeEnvironmentUid === env.uid ? (
|
||||
<div className="activated-checkmark" title="Active environment">
|
||||
<IconCheck size={16} strokeWidth={2} />
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="activate-btn"
|
||||
onClick={(e) => handleActivateEnvironment(e, env)}
|
||||
title="Activate environment"
|
||||
>
|
||||
<IconCheck size={16} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="search-container">
|
||||
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search environments..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isCreatingInline && (
|
||||
<div className="environment-item creating" ref={createContainerRef}>
|
||||
<div className="environments-list">
|
||||
{filteredEnvironments.map((env) => (
|
||||
<div
|
||||
key={env.uid}
|
||||
id={env.uid}
|
||||
className={`environment-item ${selectedEnvironment.uid === env.uid ? 'active' : ''} ${renamingEnvUid === env.uid ? 'renaming' : ''} ${activeEnvironmentUid === env.uid ? 'activated' : ''}`}
|
||||
onClick={() => renamingEnvUid !== env.uid && handleEnvironmentClick(env)}
|
||||
onDoubleClick={() => handleEnvironmentDoubleClick(env)}
|
||||
>
|
||||
{renamingEnvUid === env.uid ? (
|
||||
<div className="rename-container" ref={renameContainerRef}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
@@ -682,7 +341,6 @@ const EnvironmentList = ({
|
||||
value={newEnvName}
|
||||
onChange={handleEnvNameChange}
|
||||
onKeyDown={handleEnvNameKeyDown}
|
||||
placeholder="Environment name..."
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
@@ -691,7 +349,7 @@ const EnvironmentList = ({
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="inline-action-btn save"
|
||||
onClick={handleSaveNewEnv}
|
||||
onClick={handleSaveRename}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Save"
|
||||
>
|
||||
@@ -699,7 +357,7 @@ const EnvironmentList = ({
|
||||
</button>
|
||||
<button
|
||||
className="inline-action-btn cancel"
|
||||
onClick={handleCancelCreate}
|
||||
onClick={handleCancelRename}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Cancel"
|
||||
>
|
||||
@@ -707,94 +365,75 @@ const EnvironmentList = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{envNameError && (isCreatingInline || renamingEnvUid) && <div className="env-error">{envNameError}</div>}
|
||||
|
||||
{filteredEnvironments.length === 0 && !isCreatingInline && (
|
||||
<div className="no-env-file">
|
||||
<span>No environments</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="environment-name">{env.name}</span>
|
||||
<div className="environment-actions">
|
||||
{activeEnvironmentUid === env.uid ? (
|
||||
<div className="activated-checkmark" title="Active environment">
|
||||
<IconCheck size={16} strokeWidth={2} />
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="activate-btn"
|
||||
onClick={(e) => handleActivateEnvironment(e, env)}
|
||||
title="Activate environment"
|
||||
>
|
||||
<IconCheck size={16} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
))}
|
||||
|
||||
<CollapsibleSection
|
||||
title=".env Files"
|
||||
expanded={dotEnvExpanded}
|
||||
onToggle={() => setDotEnvExpanded(!dotEnvExpanded)}
|
||||
badge={dotEnvFiles.length}
|
||||
actions={(
|
||||
<button
|
||||
className="btn-action"
|
||||
onClick={handleCreateDotEnvInlineClick}
|
||||
title="Create .env file"
|
||||
>
|
||||
<IconPlus size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<div className="environments-list">
|
||||
{dotEnvFiles.map((file) => (
|
||||
<div
|
||||
key={file.filename}
|
||||
className={classnames('environment-item', {
|
||||
active: activeView === 'dotenv' && selectedDotEnvFile === file.filename
|
||||
})}
|
||||
onClick={() => handleDotEnvClick(file.filename)}
|
||||
{isCreatingInline && (
|
||||
<div className="environment-item creating" ref={createContainerRef}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="environment-name-input"
|
||||
value={newEnvName}
|
||||
onChange={handleEnvNameChange}
|
||||
onKeyDown={handleEnvNameKeyDown}
|
||||
placeholder="Environment name..."
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="inline-action-btn save"
|
||||
onClick={handleSaveNewEnv}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Save"
|
||||
>
|
||||
<span className="environment-name">{file.filename}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isCreatingDotEnvInline && (
|
||||
<div className="environment-item creating" ref={dotEnvCreateContainerRef}>
|
||||
<input
|
||||
ref={dotEnvInputRef}
|
||||
type="text"
|
||||
className="environment-name-input"
|
||||
value={newDotEnvName}
|
||||
onChange={handleDotEnvNameChange}
|
||||
onKeyDown={handleDotEnvNameKeyDown}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="inline-action-btn save"
|
||||
onClick={handleSaveNewDotEnv}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Create"
|
||||
>
|
||||
<IconCheck size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
className="inline-action-btn cancel"
|
||||
onClick={handleCancelDotEnvCreate}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Cancel"
|
||||
>
|
||||
<IconX size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dotEnvNameError && isCreatingDotEnvInline && <div className="env-error">{dotEnvNameError}</div>}
|
||||
|
||||
{dotEnvFiles.length === 0 && !isCreatingDotEnvInline && (
|
||||
<div className="no-env-file">
|
||||
<span>No .env files</span>
|
||||
</div>
|
||||
)}
|
||||
<IconCheck size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
className="inline-action-btn cancel"
|
||||
onClick={handleCancelCreate}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Cancel"
|
||||
>
|
||||
<IconX size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{envNameError && (isCreatingInline || renamingEnvUid) && <div className="env-error">{envNameError}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderContent()}
|
||||
<EnvironmentDetails
|
||||
environment={selectedEnvironment}
|
||||
setIsModified={setIsModified}
|
||||
originalEnvironmentVariables={originalEnvironmentVariables}
|
||||
collection={collection}
|
||||
/>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
import React, { useState } from 'react';
|
||||
import CreateEnvironment from 'components/Environments/EnvironmentSettings/CreateEnvironment';
|
||||
import EnvironmentList from './EnvironmentList';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconFileAlert } from '@tabler/icons';
|
||||
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
|
||||
import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal';
|
||||
import Button from 'ui/Button';
|
||||
|
||||
const DefaultTab = ({ setTab }) => (
|
||||
<div className="empty-state">
|
||||
<IconFileAlert size={48} strokeWidth={1.5} />
|
||||
<div className="title">No Environments</div>
|
||||
<div className="actions">
|
||||
<Button size="sm" color="secondary" onClick={() => setTab('create')}>
|
||||
Create Environment
|
||||
</Button>
|
||||
<Button size="sm" color="secondary" onClick={() => setTab('import')}>
|
||||
Import Environment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const EnvironmentSettings = ({ collection }) => {
|
||||
const [isModified, setIsModified] = useState(false);
|
||||
@@ -11,8 +30,23 @@ const EnvironmentSettings = ({ collection }) => {
|
||||
if (!environments.length) return null;
|
||||
return environments.find((env) => env.uid === collection?.activeEnvironmentUid) || environments[0];
|
||||
});
|
||||
const [tab, setTab] = useState('default');
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
|
||||
if (!environments || !environments.length) {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{tab === 'create' ? (
|
||||
<CreateEnvironment collection={collection} onClose={() => setTab('default')} />
|
||||
) : tab === 'import' ? (
|
||||
<ImportEnvironmentModal type="collection" collection={collection} onClose={() => setTab('default')} />
|
||||
) : (
|
||||
<DefaultTab setTab={setTab} />
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<EnvironmentList
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import WorkspaceEnvironments from 'components/WorkspaceHome/WorkspaceEnvironments';
|
||||
|
||||
const GlobalEnvironmentSettings = () => {
|
||||
const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid);
|
||||
const workspace = useSelector((state) =>
|
||||
state.workspaces.workspaces.find((w) => w.uid === activeWorkspaceUid)
|
||||
);
|
||||
|
||||
return <WorkspaceEnvironments workspace={workspace} />;
|
||||
return <WorkspaceEnvironments />;
|
||||
};
|
||||
|
||||
export default GlobalEnvironmentSettings;
|
||||
|
||||
@@ -34,36 +34,23 @@ class ErrorBoundary extends Component {
|
||||
|
||||
const serializeArgs = (args) => {
|
||||
return args.map((arg) => {
|
||||
const seen = new WeakSet();
|
||||
|
||||
const replacer = (key, value) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (seen.has(value)) {
|
||||
return '[Circular Reference]';
|
||||
}
|
||||
seen.add(value);
|
||||
|
||||
if (value instanceof Error || Object.prototype.toString.call(value) === '[object Error]' || (typeof value.message === 'string' && typeof value.stack === 'string')) {
|
||||
const error = {};
|
||||
Object.getOwnPropertyNames(value).forEach((prop) => {
|
||||
error[prop] = value[prop];
|
||||
});
|
||||
return error;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
try {
|
||||
if (arg === null) return 'null';
|
||||
if (arg === undefined) return 'undefined';
|
||||
if (typeof arg === 'string' || typeof arg === 'number' || typeof arg === 'boolean') {
|
||||
return arg;
|
||||
}
|
||||
|
||||
if (arg instanceof Error) {
|
||||
return {
|
||||
__type: 'Error',
|
||||
name: arg.name,
|
||||
message: arg.message,
|
||||
stack: arg.stack
|
||||
};
|
||||
}
|
||||
if (typeof arg === 'object') {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(arg, replacer));
|
||||
return JSON.parse(JSON.stringify(arg));
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
pre {
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -1,34 +0,0 @@
|
||||
import React from 'react';
|
||||
import Portal from 'components/Portal';
|
||||
import Modal from 'components/Modal';
|
||||
import { useState } from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const IpcErrorModal = ({ error }) => {
|
||||
const [showModal, setShowModal] = useState(true);
|
||||
return (
|
||||
<>
|
||||
{showModal ? (
|
||||
<StyledWrapper>
|
||||
<Portal>
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Error"
|
||||
hideFooter={true}
|
||||
hideCancel={true}
|
||||
handleCancel={() => {
|
||||
setShowModal(false);
|
||||
}}
|
||||
disableCloseOnOutsideClick={true}
|
||||
disableEscapeKey={true}
|
||||
>
|
||||
<pre className="w-full flex flex-wrap whitespace-pre-wrap">{error}</pre>
|
||||
</Modal>
|
||||
</Portal>
|
||||
</StyledWrapper>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default IpcErrorModal;
|
||||
@@ -11,7 +11,6 @@ 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';
|
||||
|
||||
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
|
||||
|
||||
@@ -37,22 +36,6 @@ const Headers = ({ collection, folder }) => {
|
||||
|
||||
const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.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',
|
||||
@@ -60,7 +43,7 @@ const Headers = ({ collection, folder }) => {
|
||||
isKeyField: true,
|
||||
placeholder: 'Name',
|
||||
width: '30%',
|
||||
render: ({ value, onChange }) => (
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
<SingleLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
@@ -68,7 +51,7 @@ const Headers = ({ collection, folder }) => {
|
||||
onChange={(newValue) => onChange(newValue.replace(/[\r\n]/g, ''))}
|
||||
autocomplete={headerAutoCompleteList}
|
||||
collection={collection}
|
||||
placeholder={!value ? 'Name' : ''}
|
||||
placeholder={isLastEmptyRow ? 'Name' : ''}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -76,7 +59,7 @@ const Headers = ({ collection, folder }) => {
|
||||
key: 'value',
|
||||
name: 'Value',
|
||||
placeholder: 'Value',
|
||||
render: ({ value, onChange }) => (
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
<SingleLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
@@ -85,7 +68,7 @@ const Headers = ({ collection, folder }) => {
|
||||
collection={collection}
|
||||
item={folder}
|
||||
autocomplete={MimeTypes}
|
||||
placeholder={!value ? 'Value' : ''}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -123,7 +106,6 @@ const Headers = ({ collection, folder }) => {
|
||||
rows={headers}
|
||||
onChange={handleHeadersChange}
|
||||
defaultRow={defaultRow}
|
||||
getRowError={getRowError}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button className="text-link select-none" onClick={toggleBulkEditMode}>
|
||||
|
||||
@@ -6,39 +6,20 @@ import { updateFolderRequestScript, updateFolderResponseScript } from 'providers
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
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';
|
||||
|
||||
const Script = ({ collection, folder }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [activeTab, setActiveTab] = useState('pre-request');
|
||||
const preRequestEditorRef = useRef(null);
|
||||
const postResponseEditorRef = useRef(null);
|
||||
const requestScript = folder.draft ? get(folder, 'draft.request.script.req', '') : get(folder, 'root.request.script.req', '');
|
||||
const responseScript = folder.draft ? get(folder, 'draft.request.script.res', '') : get(folder, 'root.request.script.res', '');
|
||||
|
||||
// Default to post-response if pre-request script is empty
|
||||
const getInitialTab = () => {
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
return hasPreRequestScript ? 'pre-request' : 'post-response';
|
||||
};
|
||||
|
||||
const [activeTab, setActiveTab] = useState(getInitialTab);
|
||||
const prevFolderUidRef = useRef(folder.uid);
|
||||
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
// Update active tab only when switching to a different folder
|
||||
useEffect(() => {
|
||||
if (prevFolderUidRef.current !== folder.uid) {
|
||||
prevFolderUidRef.current = folder.uid;
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
setActiveTab(hasPreRequestScript ? 'pre-request' : 'post-response');
|
||||
}
|
||||
}, [folder.uid, requestScript]);
|
||||
|
||||
// Refresh CodeMirror when tab becomes visible
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -76,10 +57,6 @@ const Script = ({ collection, folder }) => {
|
||||
dispatch(saveFolderRoot(collection.uid, folder.uid));
|
||||
};
|
||||
|
||||
const items = flattenItems(folder.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">
|
||||
@@ -88,18 +65,8 @@ const Script = ({ collection, folder }) => {
|
||||
|
||||
<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">
|
||||
|
||||
@@ -51,7 +51,7 @@ const VarsTable = ({ folder, collection, vars, varType }) => {
|
||||
</div>
|
||||
),
|
||||
placeholder: varType === 'request' ? 'Value' : 'Expr',
|
||||
render: ({ value, onChange }) => (
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
<MultiLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
@@ -59,7 +59,7 @@ const VarsTable = ({ folder, collection, vars, varType }) => {
|
||||
onChange={onChange}
|
||||
collection={collection}
|
||||
item={folder}
|
||||
placeholder={!value ? (varType === 'request' ? 'Value' : 'Expr') : ''}
|
||||
placeholder={isLastEmptyRow ? (varType === 'request' ? 'Value' : 'Expr') : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import React from 'react';
|
||||
import Modal from 'components/Modal/index';
|
||||
import Portal from 'components/Portal/index';
|
||||
|
||||
const getOSName = () => {
|
||||
const platform = window.navigator.userAgentData?.platform || '';
|
||||
if (platform.startsWith('Win')) {
|
||||
return 'Windows';
|
||||
} else if (platform.startsWith('Mac')) {
|
||||
return 'macOS';
|
||||
} else if (platform.startsWith('Linux')) {
|
||||
return 'Linux';
|
||||
} else {
|
||||
return 'your OS';
|
||||
}
|
||||
};
|
||||
|
||||
const getDownloadUrl = (os) => {
|
||||
switch (os) {
|
||||
case 'Windows':
|
||||
return 'https://git-scm.com/download/win';
|
||||
case 'macOS':
|
||||
return 'https://git-scm.com/download/mac';
|
||||
case 'Linux':
|
||||
return 'https://git-scm.com/download/linux';
|
||||
default:
|
||||
return 'https://git-scm.com/download';
|
||||
}
|
||||
};
|
||||
|
||||
const GitNotFoundModal = ({ onClose }) => {
|
||||
const osName = getOSName();
|
||||
const downloadUrl = getDownloadUrl(osName);
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Git Not Found"
|
||||
handleCancel={onClose}
|
||||
hideFooter={true}
|
||||
>
|
||||
<div>
|
||||
<p>Git was not detected on your system. You need to install Git to proceed.</p>
|
||||
<p className="mt-2">
|
||||
You can download Git for <strong>{osName}</strong> here:
|
||||
</p>
|
||||
<p>
|
||||
<span
|
||||
className="text-blue-600 cursor-pointer border-b border-blue-600"
|
||||
onClick={() => window.open(downloadUrl, '_blank')}
|
||||
>
|
||||
Download Git for {osName}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default GitNotFoundModal;
|
||||
@@ -8,37 +8,7 @@ import React, { useState } from 'react';
|
||||
import HelpIcon from 'components/Icons/Help';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const getPlacementStyles = (placement) => {
|
||||
switch (placement) {
|
||||
case 'top':
|
||||
return {
|
||||
bottom: 'calc(100% + 8px)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)'
|
||||
};
|
||||
case 'bottom':
|
||||
return {
|
||||
top: 'calc(100% + 8px)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)'
|
||||
};
|
||||
case 'left':
|
||||
return {
|
||||
top: '50%',
|
||||
right: 'calc(100% + 8px)',
|
||||
transform: 'translateY(-50%)'
|
||||
};
|
||||
case 'right':
|
||||
default:
|
||||
return {
|
||||
top: '50%',
|
||||
left: 'calc(100% + 8px)',
|
||||
transform: 'translateY(-50%)'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const Help = ({ children, width = 200, placement = 'right' }) => {
|
||||
const Help = ({ children, width = 200 }) => {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -54,7 +24,9 @@ const Help = ({ children, width = 200, placement = 'right' }) => {
|
||||
<StyledWrapper
|
||||
className="absolute z-50 rounded-md p-3"
|
||||
style={{
|
||||
...getPlacementStyles(placement),
|
||||
top: '50%',
|
||||
left: 'calc(100% + 8px)',
|
||||
transform: 'translateY(-50%)',
|
||||
width: `${width}px`
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -15,20 +15,20 @@ const StyledMarkdownBodyWrapper = styled.div`
|
||||
margin: 0.67em 0;
|
||||
font-weight: var(--base-text-weight-semibold, 600);
|
||||
padding-bottom: 0.3em;
|
||||
font-size: 2.2em;
|
||||
font-size: 1.4em;
|
||||
border-bottom: 1px solid var(--color-border-muted);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-weight: var(--base-text-weight-semibold, 600);
|
||||
padding-bottom: 0.3em;
|
||||
font-size: 1.7em;
|
||||
font-size: 1.3em;
|
||||
border-bottom: 1px solid var(--color-border-muted);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-weight: var(--base-text-weight-semibold, 600);
|
||||
font-size: 1.45em;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
h4 {
|
||||
@@ -38,12 +38,12 @@ const StyledMarkdownBodyWrapper = styled.div`
|
||||
|
||||
h5 {
|
||||
font-weight: var(--base-text-weight-semibold, 600);
|
||||
font-size: 0.975em;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-weight: var(--base-text-weight-semibold, 600);
|
||||
font-size: 0.85em;
|
||||
font-size: 0.9em;
|
||||
color: var(--color-fg-muted);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { setupAutoComplete } from 'utils/codemirror/autocomplete';
|
||||
import { MaskedEditor } from 'utils/common/masked-editor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { setupLinkAware } from 'utils/codemirror/linkAware';
|
||||
import { setupShortcuts } from 'utils/codemirror/shortcuts';
|
||||
import { IconEye, IconEyeOff } from '@tabler/icons';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
@@ -25,9 +24,6 @@ class MultiLineEditor extends Component {
|
||||
this.state = {
|
||||
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
|
||||
};
|
||||
|
||||
// Shortcuts cleanup function
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -49,16 +45,16 @@ class MultiLineEditor extends Component {
|
||||
readOnly: this.props.readOnly,
|
||||
tabindex: 0,
|
||||
extraKeys: {
|
||||
// 'Ctrl-Enter': () => {
|
||||
// if (this.props.onRun) {
|
||||
// this.props.onRun();
|
||||
// }
|
||||
// },
|
||||
// 'Cmd-Enter': () => {
|
||||
// if (this.props.onRun) {
|
||||
// this.props.onRun();
|
||||
// }
|
||||
// },
|
||||
'Ctrl-Enter': () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Cmd-Enter': () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Cmd-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
@@ -94,9 +90,6 @@ class MultiLineEditor extends Component {
|
||||
|
||||
setupLinkAware(this.editor);
|
||||
|
||||
// Setup keyboard shortcuts
|
||||
this._shortcutsCleanup = setupShortcuts(this.editor, this);
|
||||
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.editor.on('change', this._onEdit);
|
||||
this.addOverlay(variables);
|
||||
@@ -161,17 +154,10 @@ class MultiLineEditor extends Component {
|
||||
this.editor.setOption('readOnly', this.props.readOnly);
|
||||
}
|
||||
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
|
||||
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
|
||||
const nextValue = String(this.props.value ?? '');
|
||||
const currentValue = this.editor.getValue();
|
||||
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
|
||||
this.cachedValue = currentValue;
|
||||
} else {
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = nextValue;
|
||||
this.editor.setValue(nextValue);
|
||||
this.editor.setCursor(cursor);
|
||||
}
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = String(this.props.value);
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.editor.setCursor(cursor);
|
||||
}
|
||||
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
|
||||
// If the secret flag has changed, update the editor to reflect the change
|
||||
@@ -186,12 +172,6 @@ class MultiLineEditor extends Component {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Cleanup shortcuts (keymap and store subscription)
|
||||
if (this._shortcutsCleanup) {
|
||||
this._shortcutsCleanup();
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
if (this.brunoAutoCompleteCleanup) {
|
||||
this.brunoAutoCompleteCleanup();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
.bruno-form {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.submit {
|
||||
margin-top: 1rem;
|
||||
@@ -26,6 +25,8 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.no-features-message {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--color-gray-500);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@@ -93,9 +93,12 @@ const Beta = ({ close }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="section-header">Beta Features</div>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<form className="bruno-form" onSubmit={formik.handleSubmit}>
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center mb-2">
|
||||
<IconFlask size={20} className="mr-2 text-orange-500" />
|
||||
<h2 className="text-lg font-medium">Beta Features</h2>
|
||||
</div>
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-4 text-wrap">
|
||||
Beta features are experimental previews that may change before full release. Try them and share feedback.
|
||||
</p>
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.cache-stats {
|
||||
padding: 1rem;
|
||||
border-radius: ${(props) => props.theme.border.radius.md};
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid ${(props) => props.theme.input.border};
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.purge-button {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
cursor: pointer;
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -1,89 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const Cache = () => {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [purging, setPurging] = useState(false);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const cacheStats = await window.ipcRenderer.invoke('renderer:get-cache-stats');
|
||||
setStats(cacheStats);
|
||||
} catch (error) {
|
||||
console.error('Error fetching cache stats:', error);
|
||||
setStats({ error: error.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
}, [fetchStats]);
|
||||
|
||||
const handlePurgeCache = async () => {
|
||||
setPurging(true);
|
||||
try {
|
||||
const result = await window.ipcRenderer.invoke('renderer:purge-cache');
|
||||
if (result.success) {
|
||||
toast.success('Cache purged successfully');
|
||||
await fetchStats();
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to purge cache');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error purging cache:', error);
|
||||
toast.error('Failed to purge cache');
|
||||
} finally {
|
||||
setPurging(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<div className="section-title">Collection Cache</div>
|
||||
<p className="description mb-4">
|
||||
Bruno caches parsed collection files to improve loading performance. Clearing the cache will cause collections to be fully re-parsed on next load.
|
||||
</p>
|
||||
|
||||
<div className="cache-stats">
|
||||
{loading ? (
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Loading...</span>
|
||||
</div>
|
||||
) : stats?.error ? (
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Error: {stats.error}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Cached Collections</span>
|
||||
<span className="stat-value">{stats?.totalCollections ?? 0}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Cached Files</span>
|
||||
<span className="stat-value">{stats?.totalFiles ?? 0}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Cache Version</span>
|
||||
<span className="stat-value">{stats?.version ?? 'N/A'}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="purge-button"
|
||||
onClick={handlePurgeCache}
|
||||
disabled={purging || loading}
|
||||
>
|
||||
{purging ? 'Purging...' : 'Purge Cache'}
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Cache;
|
||||
@@ -6,7 +6,7 @@ import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const Font = () => {
|
||||
const Font = ({ close }) => {
|
||||
const dispatch = useDispatch();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.zoom-field {
|
||||
width: 120px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.zoom-field label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.custom-select {
|
||||
width: 80px;
|
||||
height: 35.89px;
|
||||
padding: 0 0.5rem;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background-color: ${(props) => props.theme.input.background};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.custom-select:hover {
|
||||
border-color: ${(props) => props.theme.input.hoverBorder || props.theme.input.border};
|
||||
}
|
||||
|
||||
.custom-select .selected-value {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.custom-select .chevron-icon {
|
||||
color: ${(props) => props.theme.input.border};
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
width: 80px;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 0.25rem;
|
||||
background-color: ${(props) => props.theme.input.background};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
z-index: 50;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.dropdown-menu::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dropdown-option {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
|
||||
.dropdown-option:hover {
|
||||
background-color: ${(props) => props.theme.input.border};
|
||||
}
|
||||
|
||||
.dropdown-option.selected {
|
||||
background-color: ${(props) => props.theme.input.focusBorder || props.theme.input.border}22;
|
||||
}
|
||||
|
||||
.dropdown-option .option-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dropdown-option .check-icon {
|
||||
color: ${(props) => props.theme.textLink};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
padding: 0.45rem 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
color: ${(props) => props.theme.textLink};
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.input.border};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
box-shadow: 0 0 0 2px ${(props) => props.theme.input.focusBorder}33;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -1,124 +0,0 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconChevronDown, IconCheck } from '@tabler/icons';
|
||||
const { percentageToZoomLevel } = require('@usebruno/common');
|
||||
|
||||
// Zoom options for dropdown (50% to 150%)
|
||||
const ZOOM_OPTIONS = [
|
||||
{ label: '50%', value: 50 },
|
||||
{ label: '60%', value: 60 },
|
||||
{ label: '70%', value: 70 },
|
||||
{ label: '80%', value: 80 },
|
||||
{ label: '90%', value: 90 },
|
||||
{ label: '100%', value: 100 },
|
||||
{ label: '110%', value: 110 },
|
||||
{ label: '120%', value: 120 },
|
||||
{ label: '130%', value: 130 },
|
||||
{ label: '140%', value: 140 },
|
||||
{ label: '150%', value: 150 }
|
||||
];
|
||||
|
||||
const DEFAULT_ZOOM = 100;
|
||||
|
||||
const Zoom = () => {
|
||||
const dispatch = useDispatch();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const dropdownRef = useRef(null);
|
||||
const dropdownMenuRef = useRef(null);
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
// Get saved zoom percentage from Redux preferences (single source of truth)
|
||||
const savedZoom = get(preferences, 'display.zoomPercentage', DEFAULT_ZOOM);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Callback ref to scroll to selected option when dropdown renders
|
||||
const setDropdownMenuRef = (node) => {
|
||||
dropdownMenuRef.current = node;
|
||||
if (node) {
|
||||
const selectedOption = node.querySelector('.dropdown-option.selected');
|
||||
if (selectedOption) {
|
||||
selectedOption.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (zoom) => {
|
||||
// Apply zoom level to Electron window immediately
|
||||
if (ipcRenderer) {
|
||||
const zoomLevel = percentageToZoomLevel(zoom);
|
||||
ipcRenderer.invoke('renderer:set-zoom-level', zoomLevel);
|
||||
}
|
||||
|
||||
// Save to preferences via Redux (same pattern as layout)
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
display: {
|
||||
...get(preferences, 'display', {}),
|
||||
zoomPercentage: zoom
|
||||
}
|
||||
};
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleResetToDefault = () => {
|
||||
handleSelect(DEFAULT_ZOOM);
|
||||
};
|
||||
|
||||
const selectedOption = ZOOM_OPTIONS.find((opt) => opt.value === savedZoom);
|
||||
const isDefault = savedZoom === DEFAULT_ZOOM;
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="flex flex-row gap-1 items-end">
|
||||
<div className="zoom-field" ref={dropdownRef}>
|
||||
<label className="block">Interface Zoom</label>
|
||||
<div className="custom-select mt-2" onClick={() => setIsOpen(!isOpen)}>
|
||||
<span className="selected-value">{selectedOption?.label}</span>
|
||||
<IconChevronDown size={14} className="chevron-icon" />
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="dropdown-menu" ref={setDropdownMenuRef}>
|
||||
{ZOOM_OPTIONS.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`dropdown-option ${option.value === savedZoom ? 'selected' : ''}`}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
{option.value === savedZoom && <IconCheck size={12} className="check-icon" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isDefault && (
|
||||
<button
|
||||
type="button"
|
||||
className="reset-btn"
|
||||
onClick={handleResetToDefault}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Zoom;
|
||||
@@ -1,18 +1,11 @@
|
||||
import React from 'react';
|
||||
import Font from './Font/index';
|
||||
import Zoom from './Zoom/index';
|
||||
|
||||
const Display = ({ close }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="section-header">Display</div>
|
||||
<div className="flex flex-col mb-2 gap-10 w-full">
|
||||
<div className="w-fit flex flex-col gap-2">
|
||||
<Font close={close} />
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<Zoom />
|
||||
</div>
|
||||
<div className="flex flex-col my-2 gap-10 w-full">
|
||||
<div className="w-fit flex flex-col gap-2">
|
||||
<Font close={close} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,32 +1,7 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.text-link {
|
||||
color: ${(props) => props.theme.colors.text.link};
|
||||
text-decoration: none;
|
||||
font-size: 0.8125rem;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
form.bruno-form {
|
||||
label {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.default-location-input {
|
||||
max-width: 28rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -11,7 +11,7 @@ import toast from 'react-hot-toast';
|
||||
import path from 'utils/common/path';
|
||||
import { IconTrash } from '@tabler/icons';
|
||||
|
||||
const General = () => {
|
||||
const General = ({ close }) => {
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const dispatch = useDispatch();
|
||||
const inputFileCaCertificateRef = useRef();
|
||||
@@ -47,8 +47,8 @@ const General = () => {
|
||||
.test('isNumber', 'Save Delay must be a number', (value) => {
|
||||
return value === undefined || !isNaN(value);
|
||||
})
|
||||
.test('isValidInterval', 'Save Delay must be at least 500ms', (value) => {
|
||||
return value === undefined || Number(value) >= 500;
|
||||
.test('isValidInterval', 'Save Delay must be at least 100ms', (value) => {
|
||||
return value === undefined || Number(value) >= 100;
|
||||
})
|
||||
}).test('intervalRequired', 'Save Delay is required when Auto Save is enabled', (value) => {
|
||||
// If autosave is enabled, interval must be provided
|
||||
@@ -60,7 +60,7 @@ const General = () => {
|
||||
oauth2: Yup.object({
|
||||
useSystemBrowser: Yup.boolean()
|
||||
}),
|
||||
defaultLocation: Yup.string().max(1024)
|
||||
defaultCollectionLocation: Yup.string().max(1024)
|
||||
});
|
||||
|
||||
const formik = useFormik({
|
||||
@@ -83,7 +83,7 @@ const General = () => {
|
||||
oauth2: {
|
||||
useSystemBrowser: get(preferences, 'request.oauth2.useSystemBrowser', false)
|
||||
},
|
||||
defaultLocation: get(preferences, 'general.defaultLocation', '')
|
||||
defaultCollectionLocation: get(preferences, 'general.defaultCollectionLocation', '')
|
||||
},
|
||||
validationSchema: preferencesSchema,
|
||||
onSubmit: async (values) => {
|
||||
@@ -121,7 +121,7 @@ const General = () => {
|
||||
interval: newPreferences.autoSave.interval
|
||||
},
|
||||
general: {
|
||||
defaultLocation: newPreferences.defaultLocation
|
||||
defaultCollectionLocation: newPreferences.defaultCollectionLocation
|
||||
}
|
||||
}))
|
||||
.catch((err) => console.log(err) && toast.error('Failed to update preferences'));
|
||||
@@ -163,20 +163,19 @@ const General = () => {
|
||||
dispatch(browseDirectory())
|
||||
.then((dirPath) => {
|
||||
if (typeof dirPath === 'string') {
|
||||
formik.setFieldValue('defaultLocation', dirPath);
|
||||
formik.setFieldValue('defaultCollectionLocation', dirPath);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
formik.setFieldValue('defaultLocation', '');
|
||||
formik.setFieldValue('defaultCollectionLocation', '');
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<div className="section-header">General Settings</div>
|
||||
<form className="bruno-form" onSubmit={formik.handleSubmit}>
|
||||
<div className="flex items-center mb-2">
|
||||
<div className="flex items-center my-2">
|
||||
<input
|
||||
id="sslVerification"
|
||||
type="checkbox"
|
||||
@@ -356,38 +355,35 @@ const General = () => {
|
||||
<div className="text-red-500">{formik.errors.autoSave.interval}</div>
|
||||
)}
|
||||
<div className="flex flex-col mt-6">
|
||||
<label className="block select-none default-location-label" htmlFor="defaultLocation">
|
||||
Default Location
|
||||
<label className="block select-none default-collection-location-label" htmlFor="defaultCollectionLocation">
|
||||
Default Collection Location
|
||||
</label>
|
||||
<p className="text-muted mt-1 text-xs">
|
||||
Used as the default location for new workspaces and collections
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
name="defaultLocation"
|
||||
id="defaultLocation"
|
||||
className="block textbox mt-2 w-full cursor-pointer default-location-input"
|
||||
name="defaultCollectionLocation"
|
||||
id="defaultCollectionLocation"
|
||||
className="block textbox mt-2 w-full cursor-pointer default-collection-location-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
readOnly={true}
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.defaultLocation || ''}
|
||||
value={formik.values.defaultCollectionLocation || ''}
|
||||
onClick={browseDefaultLocation}
|
||||
placeholder="Click to browse for default location"
|
||||
/>
|
||||
<div className="mt-1">
|
||||
<span
|
||||
className="text-link cursor-pointer hover:underline default-location-browse"
|
||||
className="text-link cursor-pointer hover:underline default-collection-location-browse"
|
||||
onClick={browseDefaultLocation}
|
||||
>
|
||||
Browse
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{formik.touched.defaultLocation && formik.errors.defaultLocation ? (
|
||||
<div className="text-red-500">{formik.errors.defaultLocation}</div>
|
||||
{formik.touched.defaultCollectionLocation && formik.errors.defaultCollectionLocation ? (
|
||||
<div className="text-red-500">{formik.errors.defaultCollectionLocation}</div>
|
||||
) : null}
|
||||
</form>
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -1,198 +1,45 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.reset-all-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.table.border};
|
||||
border-radius: 6px;
|
||||
padding: 4px 4px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.button.secondary.hoverBg};
|
||||
border-color: ${(props) => props.theme.button.secondary.hoverBorder};
|
||||
}
|
||||
}
|
||||
|
||||
.keybinding-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.keybinding-row:hover .edit-btn {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.shortcut-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 260px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.shortcut-input {
|
||||
width: 200px;
|
||||
max-width: 200px;
|
||||
flex-shrink: 0;
|
||||
|
||||
caret-color: ${(props) => props.theme.table.input.color};
|
||||
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
|
||||
font-family: monospace;
|
||||
color: ${(props) => props.theme.table.input.color};
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
thead,
|
||||
td {
|
||||
border: 2px solid ${(props) => props.theme.table.border};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
opacity: 1;
|
||||
thead {
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
opacity: 0.5;
|
||||
td {
|
||||
padding: 4px 8px;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
}
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
thead th {
|
||||
font-weight: 500;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
border-radius: 8px;
|
||||
padding: 0px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shortcut-input--error {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.kb-tooltip {
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
max-width: 320px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.kb-tooltip--error {
|
||||
color: ${(props) => props.theme.colors?.text?.red || '#ef4444'};
|
||||
}
|
||||
|
||||
.table-container {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
max-height: 650px;
|
||||
overflow-y: auto;
|
||||
|
||||
border-radius: 8px;
|
||||
border-top: 1px solid ${(props) => props.theme.table.border};
|
||||
border-bottom: 1px solid ${(props) => props.theme.table.border};
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
thead th:first-child,
|
||||
tbody td:first-child {
|
||||
width: 35%;
|
||||
}
|
||||
|
||||
thead th:last-child,
|
||||
tbody td:last-child {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
|
||||
background: ${(props) => props.theme.background};
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
user-select: none;
|
||||
font-weight: 500;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
|
||||
border-left: 1px solid ${(props) => props.theme.table.border};
|
||||
border-right: 1px solid ${(props) => props.theme.table.border};
|
||||
border-bottom: 1px solid ${(props) => props.theme.table.border};
|
||||
box-shadow: 0 1px 0 ${(props) => props.theme.table.border};
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
border-top: 1px solid ${(props) => props.theme.table.border};
|
||||
border-left: 1px solid ${(props) => props.theme.table.border};
|
||||
border-right: 1px solid ${(props) => props.theme.table.border};
|
||||
.key-button {
|
||||
display: inline-block;
|
||||
color: ${(props) => props.theme.table.input.color};
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-family: monospace;
|
||||
margin-right: 8px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,524 +1,13 @@
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconRefresh, IconPencil } from '@tabler/icons';
|
||||
import React from 'react';
|
||||
import { getKeyBindingsForOS } from 'providers/Hotkeys/keyMappings';
|
||||
import { isMacOS } from 'utils/common/platform';
|
||||
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import { DEFAULT_KEY_BINDINGS } from 'providers/Hotkeys/keyMappings.js';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
|
||||
const SEP = '+bind+';
|
||||
const getOS = () => (isMacOS() ? 'mac' : 'windows');
|
||||
|
||||
// Stored tokens must match your preferences defaults (lowercase)
|
||||
const MODIFIERS = new Set(['ctrl', 'command', 'alt', 'shift']);
|
||||
|
||||
const REQUIRED_MODIFIERS_BY_OS = {
|
||||
mac: new Set(['command', 'alt', 'shift', 'ctrl']),
|
||||
windows: new Set(['ctrl', 'alt', 'shift']) // command (Win key) should NOT count
|
||||
};
|
||||
|
||||
const hasRequiredModifier = (os, arr) => arr.some((k) => REQUIRED_MODIFIERS_BY_OS[os]?.has(k));
|
||||
const isOnlyModifiers = (arr) => arr.length > 0 && arr.every((k) => MODIFIERS.has(k));
|
||||
|
||||
const sortCombo = (arr) => {
|
||||
const order = ['ctrl', 'command', 'alt', 'shift'];
|
||||
const modifiers = [];
|
||||
const nonModifiers = [];
|
||||
|
||||
// Separate modifiers from non-modifiers
|
||||
arr.forEach((key) => {
|
||||
if (order.includes(key)) {
|
||||
modifiers.push(key);
|
||||
} else {
|
||||
nonModifiers.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort modifiers by their order
|
||||
modifiers.sort((a, b) => order.indexOf(a) - order.indexOf(b));
|
||||
|
||||
// Keep non-modifiers in the order they were pressed (don't sort them)
|
||||
return [...modifiers, ...nonModifiers];
|
||||
};
|
||||
|
||||
const uniqSorted = (arr) => {
|
||||
// Remove duplicates while preserving order
|
||||
const unique = [];
|
||||
const seen = new Set();
|
||||
arr.forEach((key) => {
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
unique.push(key);
|
||||
}
|
||||
});
|
||||
return sortCombo(unique);
|
||||
};
|
||||
|
||||
const fromKeysString = (keysStr) => (keysStr ? keysStr.split(SEP).filter(Boolean) : []);
|
||||
const toKeysString = (keysArr) => uniqSorted(keysArr).join(SEP);
|
||||
|
||||
// Signature MUST be stable: unique + sorted
|
||||
const comboSignature = (arr) => toKeysString(arr);
|
||||
|
||||
// OS reserved shortcuts in stored-token format
|
||||
const RESERVED_BY_OS = {
|
||||
mac: new Set([
|
||||
comboSignature(['command', 'q']),
|
||||
comboSignature(['command', 'w']),
|
||||
comboSignature(['command', 'h']),
|
||||
comboSignature(['command', 'm']),
|
||||
comboSignature(['command', 'tab']),
|
||||
comboSignature(['command', 'space']),
|
||||
comboSignature(['ctrl', 'command', 'q']),
|
||||
comboSignature(['command', ',']),
|
||||
comboSignature(['command', 'shift', '3']),
|
||||
comboSignature(['command', 'shift', '4']),
|
||||
comboSignature(['command', 'shift', '5']),
|
||||
comboSignature(['command', 'alt', 'esc'])
|
||||
]),
|
||||
windows: new Set([
|
||||
comboSignature(['alt', 'tab']),
|
||||
comboSignature(['alt', 'f4']),
|
||||
comboSignature(['ctrl', 'alt', 'delete']),
|
||||
comboSignature(['command', 'l']),
|
||||
comboSignature(['command', 'd']),
|
||||
comboSignature(['command', 'e']),
|
||||
comboSignature(['command', 'r']),
|
||||
comboSignature(['command', 'tab']),
|
||||
comboSignature(['ctrl', 'shift', 'esc'])
|
||||
])
|
||||
};
|
||||
|
||||
// normalize keyboard event -> stored tokens
|
||||
const normalizeKey = (e) => {
|
||||
const k = e.key;
|
||||
|
||||
// ignore lock keys
|
||||
if (k === 'CapsLock' || k === 'NumLock' || k === 'ScrollLock') return null;
|
||||
|
||||
if (k === ' ') return 'space';
|
||||
if (k === 'Escape') return 'esc';
|
||||
if (k === 'Control') return 'ctrl';
|
||||
if (k === 'Alt') return 'alt';
|
||||
if (k === 'Shift') return 'shift';
|
||||
if (k === 'Enter') return 'enter';
|
||||
if (k === 'Backspace') return 'backspace';
|
||||
if (k === 'Tab') return 'tab';
|
||||
if (k === 'Delete') return 'delete';
|
||||
|
||||
// Meta -> command (matches your stored default format)
|
||||
if (k === 'Meta') return 'command';
|
||||
|
||||
// single char (letters/punct) to lowercase
|
||||
if (k.length === 1) return k.toLowerCase();
|
||||
|
||||
// ArrowUp -> arrowup, PageUp -> pageup, etc
|
||||
return k.toLowerCase();
|
||||
};
|
||||
|
||||
const ERROR = {
|
||||
EMPTY: 'EMPTY',
|
||||
ONLY_MODIFIERS: 'ONLY_MODIFIERS',
|
||||
MISSING_REQUIRED_MOD: 'MISSING_REQUIRED_MOD',
|
||||
RESERVED: 'RESERVED',
|
||||
DUPLICATE: 'DUPLICATE',
|
||||
CONFLICT: 'CONFLICT'
|
||||
};
|
||||
|
||||
const Keybindings = () => {
|
||||
const dispatch = useDispatch();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const os = getOS();
|
||||
|
||||
// Source of truth: merge defaults with user preferences
|
||||
const keyBindings = useMemo(() => {
|
||||
const merged = {};
|
||||
|
||||
// Start with defaults
|
||||
for (const [action, binding] of Object.entries(DEFAULT_KEY_BINDINGS)) {
|
||||
merged[action] = { ...binding };
|
||||
}
|
||||
|
||||
// Override with user preferences
|
||||
const userBindings = preferences?.keyBindings || {};
|
||||
for (const [action, binding] of Object.entries(userBindings)) {
|
||||
if (merged[action]) {
|
||||
// Merge user's OS-specific overrides into defaults
|
||||
merged[action] = {
|
||||
...merged[action],
|
||||
...binding
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}, [preferences?.keyBindings]);
|
||||
|
||||
// Build table data (action -> { name, keys })
|
||||
const keyMapping = useMemo(() => {
|
||||
const out = {};
|
||||
for (const [action, binding] of Object.entries(keyBindings)) {
|
||||
if (binding?.[os]) out[action] = { name: binding.name, keys: binding[os] };
|
||||
}
|
||||
return out;
|
||||
}, [keyBindings, os]);
|
||||
|
||||
// ✏️ which row is allowed to edit (pencil clicked)
|
||||
const [editingAction, setEditingAction] = useState(null);
|
||||
|
||||
// hover tracking (for showing pencil/refresh only on hover row)
|
||||
const [hoveredAction, setHoveredAction] = useState(null);
|
||||
|
||||
// Recording state
|
||||
const [recordingAction, setRecordingAction] = useState(null);
|
||||
const pressedKeysRef = useRef(new Set());
|
||||
const inputRefs = useRef({});
|
||||
const [draftByAction, setDraftByAction] = useState({}); // action -> string[]
|
||||
const [errorByAction, setErrorByAction] = useState({}); // action -> { code, message }
|
||||
|
||||
const getCurrentRowKeysString = (action) => keyBindings?.[action]?.[os] || '';
|
||||
const getDefaultRowKeysString = (action) => DEFAULT_KEY_BINDINGS?.[action]?.[os] || '';
|
||||
|
||||
const isRowDirty = (action) => {
|
||||
const current = getCurrentRowKeysString(action);
|
||||
const def = getDefaultRowKeysString(action);
|
||||
if (!DEFAULT_KEY_BINDINGS) return false;
|
||||
return current !== def;
|
||||
};
|
||||
|
||||
// Check if any keybinding is dirty (different from default)
|
||||
const hasDirtyRows = useMemo(() => {
|
||||
for (const action of Object.keys(DEFAULT_KEY_BINDINGS)) {
|
||||
if (isRowDirty(action)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [keyBindings, os]);
|
||||
|
||||
const buildUsedSignatures = (excludeAction) => {
|
||||
const used = new Set();
|
||||
for (const [action, binding] of Object.entries(keyBindings)) {
|
||||
if (action === excludeAction) continue;
|
||||
const keysStr = binding?.[os];
|
||||
if (!keysStr) continue;
|
||||
used.add(comboSignature(fromKeysString(keysStr)));
|
||||
}
|
||||
return used;
|
||||
};
|
||||
|
||||
const validateCombo = (action, arrRaw) => {
|
||||
const arr = uniqSorted(arrRaw);
|
||||
const sig = comboSignature(arr);
|
||||
|
||||
if (!sig) return { code: ERROR.EMPTY, message: `Shortcut can’t be empty.` };
|
||||
if (isOnlyModifiers(arr))
|
||||
return { code: ERROR.ONLY_MODIFIERS, message: 'Add a non-modifier key (e.g. Ctrl + K).' };
|
||||
|
||||
// OS-specific must-have modifier rule
|
||||
if (!hasRequiredModifier(os, arr)) {
|
||||
return {
|
||||
code: ERROR.MISSING_REQUIRED_MOD,
|
||||
message:
|
||||
os === 'mac'
|
||||
? 'macOS shortcuts must include at least one modifier (command/alt/shift/ctrl).'
|
||||
: 'Windows shortcuts must include at least one modifier (ctrl/alt/shift).'
|
||||
};
|
||||
}
|
||||
|
||||
// OS reserved
|
||||
if (RESERVED_BY_OS[os]?.has(sig))
|
||||
return { code: ERROR.RESERVED, message: 'This shortcut is reserved by the OS.' };
|
||||
|
||||
// No duplicates (across all other actions)
|
||||
if (buildUsedSignatures(action).has(sig))
|
||||
return { code: ERROR.DUPLICATE, message: 'That shortcut is already in use.' };
|
||||
|
||||
// Check for subset conflicts (e.g., Cmd+A conflicts with Cmd+Z+A)
|
||||
for (const [otherAction, binding] of Object.entries(keyBindings)) {
|
||||
if (otherAction === action) continue;
|
||||
const otherKeysStr = binding?.[os];
|
||||
if (!otherKeysStr) continue;
|
||||
|
||||
const otherKeys = fromKeysString(otherKeysStr);
|
||||
|
||||
// Check if current is a subset of other (current is shorter)
|
||||
if (arr.length < otherKeys.length) {
|
||||
const isSubset = arr.every((k) => otherKeys.includes(k));
|
||||
if (isSubset) {
|
||||
return {
|
||||
code: ERROR.CONFLICT,
|
||||
message: `Conflicts with "${binding.name}" (${otherKeys.join(' + ')}). Remove the longer shortcut first.`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if other is a subset of current (current is longer)
|
||||
if (arr.length > otherKeys.length) {
|
||||
const isSubset = otherKeys.every((k) => arr.includes(k));
|
||||
if (isSubset) {
|
||||
return {
|
||||
code: ERROR.CONFLICT,
|
||||
message: `Conflicts with "${binding.name}" (${otherKeys.join(' + ')}). Remove that shortcut first.`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const persistToPreferences = (action, nextKeys) => {
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
keyBindings: {
|
||||
...(preferences?.keyBindings || {}),
|
||||
[action]: {
|
||||
...(preferences?.keyBindings?.[action] || {}),
|
||||
name: preferences?.keyBindings?.[action]?.name || action,
|
||||
[os]: nextKeys
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
};
|
||||
|
||||
// Commit only if valid. Returns true if commit succeeded (or no-op), false if invalid.
|
||||
const commitCombo = (action) => {
|
||||
const draftArr = draftByAction[action] || [];
|
||||
if (!draftArr.length) return;
|
||||
|
||||
const arr = uniqSorted(draftArr);
|
||||
const err = validateCombo(action, arr);
|
||||
|
||||
if (err) {
|
||||
setErrorByAction((prev) => ({ ...prev, [action]: err }));
|
||||
return false;
|
||||
}
|
||||
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
const nextKeys = toKeysString(arr);
|
||||
const currentKeys = getCurrentRowKeysString(action);
|
||||
if (nextKeys === currentKeys) return true;
|
||||
|
||||
persistToPreferences(action, nextKeys);
|
||||
// toast success for 2s with Command name
|
||||
const commandName = keyBindings?.[action]?.name || action;
|
||||
toast.success(`"${commandName}" shortcut updated`, { autoClose: 2000 });
|
||||
return true;
|
||||
};
|
||||
|
||||
const resetRowToDefault = (action) => {
|
||||
const def = DEFAULT_KEY_BINDINGS?.[action]?.[os];
|
||||
if (!def) return;
|
||||
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
setDraftByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
persistToPreferences(action, def);
|
||||
};
|
||||
|
||||
const resetAllKeybindings = () => {
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
keyBindings: {}
|
||||
};
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
};
|
||||
|
||||
const startEditing = (action) => {
|
||||
// if another row is editing, commit/stop it first
|
||||
if (editingAction && editingAction !== action) {
|
||||
const ok = commitCombo(editingAction);
|
||||
if (ok) {
|
||||
setRecordingAction(null);
|
||||
setEditingAction(null);
|
||||
pressedKeysRef.current = new Set();
|
||||
} else {
|
||||
// keep previous row editing if invalid
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setEditingAction(action);
|
||||
setRecordingAction(action);
|
||||
pressedKeysRef.current = new Set();
|
||||
|
||||
// seed draft with current value
|
||||
setDraftByAction((prev) => ({
|
||||
...prev,
|
||||
[action]: fromKeysString(getCurrentRowKeysString(action))
|
||||
}));
|
||||
|
||||
// clear error on start edit
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
inputRefs.current[action]?.focus?.();
|
||||
inputRefs.current[action]?.setSelectionRange?.(
|
||||
inputRefs.current[action].value.length,
|
||||
inputRefs.current[action].value.length
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const stopEditing = (action) => {
|
||||
const ok = commitCombo(action);
|
||||
if (!ok) {
|
||||
// If commit failed (validation error), reset to original value
|
||||
cancelEditing(action);
|
||||
return;
|
||||
}
|
||||
|
||||
setRecordingAction(null);
|
||||
setEditingAction(null);
|
||||
pressedKeysRef.current = new Set();
|
||||
};
|
||||
|
||||
// Reset draft to original value and clear error (used on blur with invalid state)
|
||||
const cancelEditing = (action) => {
|
||||
// Clear error for this action
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
// Reset draft to current saved value
|
||||
setDraftByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
setRecordingAction(null);
|
||||
setEditingAction(null);
|
||||
pressedKeysRef.current = new Set();
|
||||
};
|
||||
|
||||
const handleKeyDown = (action, e) => {
|
||||
if (recordingAction !== action || editingAction !== action) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// allow user to clear and keep editing (do NOT auto-stop)
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
pressedKeysRef.current = new Set();
|
||||
setDraftByAction((prev) => ({ ...prev, [action]: [] }));
|
||||
setErrorByAction((prev) => ({
|
||||
...prev,
|
||||
[action]: { code: ERROR.EMPTY, message: `Shortcut can't be empty.` }
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.repeat) return;
|
||||
|
||||
const keyName = normalizeKey(e);
|
||||
if (!keyName) return;
|
||||
|
||||
pressedKeysRef.current.add(keyName);
|
||||
|
||||
const currentDraft = uniqSorted(Array.from(pressedKeysRef.current));
|
||||
|
||||
setDraftByAction((prev) => ({
|
||||
...prev,
|
||||
[action]: currentDraft
|
||||
}));
|
||||
|
||||
const err = validateCombo(action, currentDraft);
|
||||
if (err) {
|
||||
setErrorByAction((prev) => ({ ...prev, [action]: err }));
|
||||
} else {
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (action, e) => {
|
||||
if (recordingAction !== action || editingAction !== action) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const keyName = normalizeKey(e);
|
||||
if (!keyName) return;
|
||||
|
||||
pressedKeysRef.current.delete(keyName);
|
||||
|
||||
// commit only when released AND currently valid
|
||||
if (pressedKeysRef.current.size === 0) {
|
||||
const currentDraft = draftByAction[action] || [];
|
||||
|
||||
// if empty -> keep editing
|
||||
if (currentDraft.length === 0) return;
|
||||
|
||||
// if error -> keep editing
|
||||
if (errorByAction[action]?.message) return;
|
||||
|
||||
stopEditing(action);
|
||||
}
|
||||
};
|
||||
|
||||
const renderValue = (action) => {
|
||||
const arr
|
||||
= recordingAction === action ? draftByAction[action] : fromKeysString(getCurrentRowKeysString(action));
|
||||
|
||||
return (arr || []).join(' + ');
|
||||
};
|
||||
const Keybindings = ({ close }) => {
|
||||
const keyMapping = getKeyBindingsForOS(isMacOS() ? 'mac' : 'windows');
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<Tooltip
|
||||
id="kb-editing-error-tooltip"
|
||||
place="bottom-start"
|
||||
opacity={1}
|
||||
className="kb-tooltip kb-tooltip--error"
|
||||
/>
|
||||
|
||||
<div className="section-header">
|
||||
<span>Keybindings</span>
|
||||
{hasDirtyRows && (
|
||||
<button
|
||||
type="button"
|
||||
className="reset-all-btn"
|
||||
onClick={resetAllKeybindings}
|
||||
title="Reset all keybindings to default"
|
||||
>
|
||||
<IconRefresh size={12} stroke={1} />
|
||||
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -529,90 +18,18 @@ const Keybindings = () => {
|
||||
</thead>
|
||||
<tbody>
|
||||
{keyMapping ? (
|
||||
Object.entries(keyMapping).map(([action, row]) => {
|
||||
const isEditing = editingAction === action;
|
||||
const isHovered = hoveredAction === action;
|
||||
const isDirty = isRowDirty(action);
|
||||
|
||||
const showPencil = isHovered && !isEditing && !isDirty;
|
||||
const showRefresh = isDirty && !isEditing;
|
||||
const hasError = Boolean(errorByAction[action]?.message);
|
||||
const errorMessage = errorByAction[action]?.message;
|
||||
const inputId = `kb-input-${action}`;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={action}
|
||||
data-testid={`keybinding-row-${action}`}
|
||||
onMouseEnter={() => setHoveredAction(action)}
|
||||
onMouseLeave={() => setHoveredAction((prev) => (prev === action ? null : prev))}
|
||||
>
|
||||
<td data-testid={`keybinding-name-${action}`}>{row.name}</td>
|
||||
|
||||
<td>
|
||||
<div className="keybinding-row">
|
||||
<div className="shortcut-wrap">
|
||||
<input
|
||||
id={inputId}
|
||||
ref={(el) => {
|
||||
if (el) inputRefs.current[action] = el;
|
||||
}}
|
||||
data-testid={`keybinding-input-${action}`}
|
||||
className={`shortcut-input ${hasError ? 'shortcut-input--error' : ''}`}
|
||||
value={renderValue(action)}
|
||||
readOnly={!isEditing}
|
||||
onKeyDown={(e) => handleKeyDown(action, e)}
|
||||
onKeyUp={(e) => handleKeyUp(action, e)}
|
||||
onBlur={() => {
|
||||
// If there's an error, reset to original value instead of keeping invalid state
|
||||
if (isEditing && hasError) {
|
||||
cancelEditing(action);
|
||||
} else if (isEditing) {
|
||||
stopEditing(action);
|
||||
}
|
||||
}}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{isEditing && hasError && (
|
||||
<Tooltip
|
||||
id={`kb-editing-error-tooltip-${action}`}
|
||||
anchorSelect={`#${inputId}`}
|
||||
place="bottom-start"
|
||||
opacity={1}
|
||||
isOpen={true}
|
||||
content={errorMessage}
|
||||
className="kb-tooltip kb-tooltip--error"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRefresh && (
|
||||
<button
|
||||
type="button"
|
||||
className="reset-btn"
|
||||
data-testid={`keybinding-reset-${action}`}
|
||||
onClick={() => resetRowToDefault(action)}
|
||||
title="Reset to default"
|
||||
>
|
||||
<IconRefresh size={12} stroke={1} />
|
||||
</button>
|
||||
)}
|
||||
{showPencil && (
|
||||
<button
|
||||
type="button"
|
||||
className="edit-btn"
|
||||
data-testid={`keybinding-edit-${action}`}
|
||||
onClick={() => startEditing(action)}
|
||||
title="Edit shortcut"
|
||||
>
|
||||
<IconPencil size={12} stroke={1.5} />
|
||||
</button>
|
||||
)}
|
||||
Object.entries(keyMapping).map(([action, { name, keys }], index) => (
|
||||
<tr key={index}>
|
||||
<td>{name}</td>
|
||||
<td>
|
||||
{keys.split('+').map((key, i) => (
|
||||
<div className="key-button" key={i}>
|
||||
{key}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan="2">No key bindings available</td>
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
|
||||
.settings-label {
|
||||
width: 100px;
|
||||
}
|
||||
@@ -30,41 +25,6 @@ const StyledWrapper = styled.div`
|
||||
label {
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
}
|
||||
|
||||
.system-proxy-title {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.system-proxy-description {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.system-proxy-error-container {
|
||||
background: ${(props) => props.theme.status.danger.background};
|
||||
border: 1px solid ${(props) => props.theme.status.danger.border};
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.system-proxy-error-text {
|
||||
color: ${(props) => props.theme.status.danger.text};
|
||||
}
|
||||
|
||||
.system-proxy-source-label {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.system-proxy-source-value {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.system-proxy-info-text {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.system-proxy-value {
|
||||
color: ${(props) => props.theme.colors.text.purple};
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { IconLoader2, IconRefresh } from '@tabler/icons';
|
||||
import { getSystemProxyVariables, refreshSystemProxy } from 'providers/ReduxStore/slices/app';
|
||||
import StyledWrapper from '../StyledWrapper';
|
||||
|
||||
const SystemProxy = () => {
|
||||
const dispatch = useDispatch();
|
||||
const systemProxyVariables = useSelector((state) => state.app.systemProxyVariables);
|
||||
const { source, http_proxy, https_proxy, no_proxy } = systemProxyVariables || {};
|
||||
const [isFetching, setIsFetching] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const fetchProxy = (forceRefresh = false) => {
|
||||
setIsFetching(true);
|
||||
setError(null);
|
||||
const action = forceRefresh ? refreshSystemProxy : getSystemProxyVariables;
|
||||
dispatch(action())
|
||||
.then(() => setError(null))
|
||||
.catch((err) => setError(err.message || String(err)))
|
||||
.finally(() => setIsFetching(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchProxy(false);
|
||||
}, [dispatch]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchProxy(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="mb-3 text-muted system-proxy-settings space-y-4">
|
||||
<div className="flex items-start justify-start flex-col gap-2 mt-2">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div>
|
||||
<h2 className="text-xs system-proxy-title flex flex-row">
|
||||
System Proxy {isFetching ? <IconLoader2 className="animate-spin ml-1" size={16} strokeWidth={1.5} /> : null}
|
||||
</h2>
|
||||
<small className="system-proxy-description">
|
||||
Below values are sourced from your system proxy settings.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mb-2 p-3 system-proxy-error-container rounded">
|
||||
<small className="system-proxy-error-text">
|
||||
Error loading system proxy settings: {error}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
{source && (
|
||||
<div className="mb-2">
|
||||
<small className="font-medium flex flex-row gap-2">
|
||||
<div className="system-proxy-source-label text-xs">
|
||||
Proxy source:
|
||||
</div>
|
||||
<div className="system-proxy-source-value">
|
||||
{source}
|
||||
</div>
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
<small className="system-proxy-info-text">
|
||||
These values cannot be directly updated in Bruno. Please refer to your OS documentation to update these.
|
||||
</small>
|
||||
<div className="flex flex-col justify-start items-start pt-2">
|
||||
<div className="mb-1 flex items-center">
|
||||
<label className="settings-label">
|
||||
http_proxy
|
||||
</label>
|
||||
<div className="system-proxy-value">{http_proxy || '-'}</div>
|
||||
</div>
|
||||
<div className="mb-1 flex items-center">
|
||||
<label className="settings-label">
|
||||
https_proxy
|
||||
</label>
|
||||
<div className="system-proxy-value">{https_proxy || '-'}</div>
|
||||
</div>
|
||||
<div className="mb-1 flex items-center">
|
||||
<label className="settings-label">
|
||||
no_proxy
|
||||
</label>
|
||||
<div className="system-proxy-value">{no_proxy || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="text-link cursor-pointer hover:underline default-collection-location-browse flex flex-row items-center"
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<IconRefresh size={14} strokeWidth={1.5} className="mr-1" />
|
||||
Refresh
|
||||
</span>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemProxy;
|
||||
@@ -9,11 +9,13 @@ import StyledWrapper from './StyledWrapper';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { IconEye, IconEyeOff } from '@tabler/icons';
|
||||
import { useState } from 'react';
|
||||
import SystemProxy from './SystemProxy';
|
||||
|
||||
const ProxySettings = ({ close }) => {
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const systemProxyEnvVariables = useSelector((state) => state.app.systemProxyEnvVariables);
|
||||
const { http_proxy, https_proxy, no_proxy } = systemProxyEnvVariables || {};
|
||||
const dispatch = useDispatch();
|
||||
console.log(preferences);
|
||||
|
||||
const proxySchema = Yup.object({
|
||||
disabled: Yup.boolean().optional(),
|
||||
@@ -113,7 +115,6 @@ const ProxySettings = ({ close }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="section-header">Proxy Settings</div>
|
||||
<form className="bruno-form" onSubmit={formik.handleSubmit}>
|
||||
<div className="mb-3 flex items-center mt-2">
|
||||
<label className="settings-label" htmlFor="protocol">
|
||||
@@ -166,7 +167,30 @@ const ProxySettings = ({ close }) => {
|
||||
</div>
|
||||
{formik.values.disabled === false && formik.values.inherit === true ? (
|
||||
<div className="mb-3 pt-1 text-muted system-proxy-settings">
|
||||
<SystemProxy />
|
||||
<small>
|
||||
Below values are sourced from your system environment variables and cannot be directly updated in Bruno.<br />
|
||||
Please refer to your OS documentation to change these values.
|
||||
</small>
|
||||
<div className="flex flex-col justify-start items-start pt-2">
|
||||
<div className="mb-1 flex items-center">
|
||||
<label className="settings-label" htmlFor="http_proxy">
|
||||
http_proxy
|
||||
</label>
|
||||
<div className="opacity-80">{http_proxy || '-'}</div>
|
||||
</div>
|
||||
<div className="mb-1 flex items-center">
|
||||
<label className="settings-label" htmlFor="https_proxy">
|
||||
https_proxy
|
||||
</label>
|
||||
<div className="opacity-80">{https_proxy || '-'}</div>
|
||||
</div>
|
||||
<div className="mb-1 flex items-center">
|
||||
<label className="settings-label" htmlFor="no_proxy">
|
||||
no_proxy
|
||||
</label>
|
||||
<div className="opacity-80">{no_proxy || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{formik.values.disabled === false && formik.values.inherit === false ? (
|
||||
@@ -267,14 +291,12 @@ const ProxySettings = ({ close }) => {
|
||||
Auth
|
||||
</label>
|
||||
<input
|
||||
id="config.auth.disabled"
|
||||
type="checkbox"
|
||||
name="config.auth.disabled"
|
||||
checked={!formik.values.config.auth.disabled}
|
||||
onChange={(e) => {
|
||||
formik.setFieldValue('config.auth.disabled', !e.target.checked);
|
||||
}}
|
||||
className="mousetrap mr-0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
div.tabs {
|
||||
padding: 12px;
|
||||
padding: 8px;
|
||||
min-width: 160px;
|
||||
|
||||
div.tab {
|
||||
@@ -28,10 +28,10 @@ const StyledWrapper = styled.div`
|
||||
|
||||
&.active {
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
background: ${(props) => props.theme.tabs.secondary.active.bg};
|
||||
background: ${(props) => props.theme.modal.title.bg};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.tabs.secondary.active.bg} !important;
|
||||
background: ${(props) => props.theme.modal.title.bg} !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,9 +39,9 @@ const StyledWrapper = styled.div`
|
||||
|
||||
section.tab-panel {
|
||||
min-height: 70vh;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
padding: 12px;
|
||||
width: clamp(300px, 45vw, 550px);
|
||||
}
|
||||
|
||||
input[type="checkbox"],
|
||||
@@ -50,29 +50,11 @@ const StyledWrapper = styled.div`
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.textbox {
|
||||
line-height: 1.5;
|
||||
padding: 0.45rem;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
&:focus {
|
||||
border: solid 1px ${(props) => props.theme.input.focusBorder} !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
.section-header {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-weight: 500;
|
||||
margin: 6px 0 8px 0;
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
|
||||
color: ${(props) => props.theme.text};
|
||||
.rows {
|
||||
svg {
|
||||
|
||||
@@ -8,9 +8,8 @@ const Support = () => {
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="section-header">Support</div>
|
||||
<div className="rows">
|
||||
<div className="mb-2">
|
||||
<div className="mt-2">
|
||||
<a href="https://docs.usebruno.com" target="_blank" className="flex items-end">
|
||||
<IconBook size={18} strokeWidth={2} />
|
||||
<span className="label ml-2">{t('COMMON.DOCUMENTATION')}</span>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { rgba } from 'polished';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.appearance-container {
|
||||
padding-bottom: 16px;
|
||||
padding: 8px 0 16px 0;
|
||||
}
|
||||
|
||||
.theme-mode-option {
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import Modal from 'components/Modal/index';
|
||||
import classnames from 'classnames';
|
||||
import React from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { updateActivePreferencesTab } from 'providers/ReduxStore/slices/app';
|
||||
import {
|
||||
IconSettings,
|
||||
IconPalette,
|
||||
IconBrowser,
|
||||
IconUserCircle,
|
||||
IconKeyboard,
|
||||
IconZoomQuestion,
|
||||
IconSquareLetterB,
|
||||
IconDatabase
|
||||
} from '@tabler/icons';
|
||||
import React, { useState } from 'react';
|
||||
import { IconSettings, IconPalette, IconBrowser, IconUserCircle, IconKeyboard, IconZoomQuestion, IconSquareLetterB } from '@tabler/icons';
|
||||
|
||||
import Support from './Support';
|
||||
import General from './General';
|
||||
@@ -20,17 +10,11 @@ import Proxy from './ProxySettings';
|
||||
import Display from './Display';
|
||||
import Keybindings from './Keybindings';
|
||||
import Beta from './Beta';
|
||||
import Cache from './Cache';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const Preferences = () => {
|
||||
const dispatch = useDispatch();
|
||||
const tab = useSelector((state) => state.app.activePreferencesTab);
|
||||
|
||||
const setTab = (tab) => {
|
||||
dispatch(updateActivePreferencesTab({ tab }));
|
||||
};
|
||||
const Preferences = ({ onClose }) => {
|
||||
const [tab, setTab] = useState('general');
|
||||
|
||||
const getTabClassname = (tabName) => {
|
||||
return classnames(`tab select-none ${tabName}`, {
|
||||
@@ -41,31 +25,27 @@ const Preferences = () => {
|
||||
const getTabPanel = (tab) => {
|
||||
switch (tab) {
|
||||
case 'general': {
|
||||
return <General />;
|
||||
return <General close={onClose} />;
|
||||
}
|
||||
|
||||
case 'themes': {
|
||||
return <Themes />;
|
||||
return <Themes close={onClose} />;
|
||||
}
|
||||
|
||||
case 'proxy': {
|
||||
return <Proxy />;
|
||||
return <Proxy close={onClose} />;
|
||||
}
|
||||
|
||||
case 'display': {
|
||||
return <Display />;
|
||||
return <Display close={onClose} />;
|
||||
}
|
||||
|
||||
case 'keybindings': {
|
||||
return <Keybindings />;
|
||||
return <Keybindings close={onClose} />;
|
||||
}
|
||||
|
||||
case 'beta': {
|
||||
return <Beta />;
|
||||
}
|
||||
|
||||
case 'cache': {
|
||||
return <Cache />;
|
||||
return <Beta close={onClose} />;
|
||||
}
|
||||
|
||||
case 'support': {
|
||||
@@ -75,51 +55,42 @@ const Preferences = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="h-full">
|
||||
<div className="flex flex-row gap-2 h-full">
|
||||
<div className="flex flex-col items-center tabs tablist" role="tablist">
|
||||
<div className={getTabClassname('general')} role="tab" onClick={() => setTab('general')}>
|
||||
<IconSettings size={16} strokeWidth={1.5} />
|
||||
General
|
||||
</div>
|
||||
<div className={getTabClassname('themes')} role="tab" onClick={() => setTab('themes')}>
|
||||
<IconPalette size={16} strokeWidth={1.5} />
|
||||
Themes
|
||||
</div>
|
||||
<div className={getTabClassname('display')} role="tab" onClick={() => setTab('display')}>
|
||||
<IconBrowser size={16} strokeWidth={1.5} />
|
||||
Display
|
||||
</div>
|
||||
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
|
||||
<IconUserCircle size={16} strokeWidth={1.5} />
|
||||
Proxy
|
||||
</div>
|
||||
<div className={getTabClassname('keybindings')} role="tab" onClick={() => setTab('keybindings')}>
|
||||
<IconKeyboard size={16} strokeWidth={1.5} />
|
||||
Keybindings
|
||||
</div>
|
||||
<div className={getTabClassname('cache')} role="tab" onClick={() => setTab('cache')}>
|
||||
<IconDatabase size={16} strokeWidth={1.5} />
|
||||
Cache
|
||||
</div>
|
||||
<div className={getTabClassname('support')} role="tab" onClick={() => setTab('support')}>
|
||||
<IconZoomQuestion size={16} strokeWidth={1.5} />
|
||||
Support
|
||||
</div>
|
||||
<div className={getTabClassname('beta')} role="tab" onClick={() => setTab('beta')}>
|
||||
<IconSquareLetterB size={16} strokeWidth={1.5} />
|
||||
Beta
|
||||
<StyledWrapper>
|
||||
<Modal size="lg" title="Preferences" handleCancel={onClose} hideFooter={true}>
|
||||
<div className="flex flex-row gap-2 mx-[-1rem] !my-[-1.5rem] py-2">
|
||||
<div className="flex flex-col items-center tabs" role="tablist">
|
||||
<div className={getTabClassname('general')} role="tab" onClick={() => setTab('general')}>
|
||||
<IconSettings size={16} strokeWidth={1.5} />
|
||||
General
|
||||
</div>
|
||||
<div className={getTabClassname('themes')} role="tab" onClick={() => setTab('themes')}>
|
||||
<IconPalette size={16} strokeWidth={1.5} />
|
||||
Themes
|
||||
</div>
|
||||
<div className={getTabClassname('display')} role="tab" onClick={() => setTab('display')}>
|
||||
<IconBrowser size={16} strokeWidth={1.5} />
|
||||
Display
|
||||
</div>
|
||||
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
|
||||
<IconUserCircle size={16} strokeWidth={1.5} />
|
||||
Proxy
|
||||
</div>
|
||||
<div className={getTabClassname('keybindings')} role="tab" onClick={() => setTab('keybindings')}>
|
||||
<IconKeyboard size={16} strokeWidth={1.5} />
|
||||
Keybindings
|
||||
</div>
|
||||
<div className={getTabClassname('support')} role="tab" onClick={() => setTab('support')}>
|
||||
<IconZoomQuestion size={16} strokeWidth={1.5} />
|
||||
Support
|
||||
</div>
|
||||
<div className={getTabClassname('beta')} role="tab" onClick={() => setTab('beta')}>
|
||||
<IconSquareLetterB size={16} strokeWidth={1.5} />
|
||||
Beta
|
||||
</div>
|
||||
</div>
|
||||
<section className="flex flex-grow ps-2 pe-4 pt-2 pb-6 tab-panel">{getTabPanel(tab)}</section>
|
||||
</div>
|
||||
<section
|
||||
className="flex flex-grow ps-2 pe-4 pt-2 pb-6 p-[12px] tab-panel"
|
||||
role="tabpanel"
|
||||
id={`${tab}-panel`}
|
||||
aria-labelledby={`${tab}-tab`}
|
||||
>
|
||||
{getTabPanel(tab)}
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -125,7 +125,7 @@ const Assertions = ({ item, collection }) => {
|
||||
key: 'value',
|
||||
name: 'Value',
|
||||
width: '30%',
|
||||
render: ({ row, value, onChange }) => {
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => {
|
||||
const { operator, value: assertionValue } = parseAssertionOperator(value);
|
||||
|
||||
if (isUnaryOperator(operator)) {
|
||||
@@ -141,7 +141,7 @@ const Assertions = ({ item, collection }) => {
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
placeholder={!value ? 'Value' : ''}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
import React, { useRef, forwardRef } from 'react';
|
||||
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { IconCaretDown, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { inputsConfig } from './inputsConfig';
|
||||
@@ -20,6 +20,8 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const { storedTheme } = useTheme();
|
||||
const useSystemBrowser = get(preferences, 'request.oauth2.useSystemBrowser', false);
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
const { isSensitive } = useDetectSensitiveField(collection);
|
||||
const oAuth = get(request, 'auth.oauth2', {});
|
||||
const {
|
||||
@@ -39,13 +41,30 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
refreshTokenUrl,
|
||||
autoRefreshToken,
|
||||
autoFetchToken,
|
||||
tokenSource,
|
||||
additionalParameters
|
||||
} = oAuth;
|
||||
|
||||
const refreshTokenUrlAvailable = refreshTokenUrl?.trim() !== '';
|
||||
const isAutoRefreshDisabled = !refreshTokenUrlAvailable;
|
||||
|
||||
const TokenPlacementIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const CredentialsPlacementIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
|
||||
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const handleSave = () => { save(); };
|
||||
|
||||
const handleChange = (key, value) => {
|
||||
@@ -72,7 +91,6 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
refreshTokenUrl,
|
||||
autoRefreshToken,
|
||||
autoFetchToken,
|
||||
tokenSource,
|
||||
additionalParameters,
|
||||
[key]: value
|
||||
}
|
||||
@@ -101,7 +119,6 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
tokenHeaderPrefix,
|
||||
tokenQueryKey,
|
||||
autoFetchToken,
|
||||
tokenSource,
|
||||
additionalParameters,
|
||||
pkce: !Boolean(oAuth?.['pkce'])
|
||||
}
|
||||
@@ -209,19 +226,26 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
<div className="flex items-center gap-4 w-full" key="input-credentials-placement">
|
||||
<label className="block min-w-[140px]">Add Credentials to</label>
|
||||
<div className="inline-flex items-center cursor-pointer token-placement-selector">
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'body', label: 'Request Body', onClick: () => handleChange('credentialsPlacement', 'body') },
|
||||
{ id: 'basic_auth_header', label: 'Basic Auth Header', onClick: () => handleChange('credentialsPlacement', 'basic_auth_header') }
|
||||
]}
|
||||
selectedItemId={credentialsPlacement}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end token-placement-label select-none">
|
||||
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<CredentialsPlacementIcon />} placement="bottom-end">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('credentialsPlacement', 'body');
|
||||
}}
|
||||
>
|
||||
Request Body
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('credentialsPlacement', 'basic_auth_header');
|
||||
}}
|
||||
>
|
||||
Basic Auth Header
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row w-full gap-4" key="pkce">
|
||||
@@ -241,24 +265,6 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
Token
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-type">
|
||||
<label className="block min-w-[140px]">Token Source</label>
|
||||
<div className="inline-flex items-center cursor-pointer token-placement-selector">
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'access_token', label: 'Access Token', onClick: () => handleChange('tokenSource', 'access_token') },
|
||||
{ id: 'id_token', label: 'ID Token', onClick: () => handleChange('tokenSource', 'id_token') }
|
||||
]}
|
||||
selectedItemId={tokenSource}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenSource === 'id_token' ? 'ID Token' : 'Access Token'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-name">
|
||||
<label className="block min-w-[140px]">Token ID</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
@@ -277,19 +283,26 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-placement">
|
||||
<label className="block min-w-[140px]">Add token to</label>
|
||||
<div className="inline-flex items-center cursor-pointer token-placement-selector">
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'header', label: 'Header', onClick: () => handleChange('tokenPlacement', 'header') },
|
||||
{ id: 'url', label: 'URL', onClick: () => handleChange('tokenPlacement', 'url') }
|
||||
]}
|
||||
selectedItemId={tokenPlacement}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('tokenPlacement', 'header');
|
||||
}}
|
||||
>
|
||||
Header
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('tokenPlacement', 'url');
|
||||
}}
|
||||
>
|
||||
URL
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useRef, forwardRef } from 'react';
|
||||
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
@@ -7,7 +7,7 @@ import { IconCaretDown, IconSettings, IconKey, IconAdjustmentsHorizontal, IconHe
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { inputsConfig } from './inputsConfig';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
|
||||
import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
|
||||
import AdditionalParams from '../AdditionalParams/index';
|
||||
@@ -16,6 +16,8 @@ import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
|
||||
const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAuth, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
const { isSensitive } = useDetectSensitiveField(collection);
|
||||
const oAuth = get(request, 'auth.oauth2', {});
|
||||
|
||||
@@ -32,7 +34,6 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
refreshTokenUrl,
|
||||
autoRefreshToken,
|
||||
autoFetchToken,
|
||||
tokenSource,
|
||||
additionalParameters
|
||||
} = oAuth;
|
||||
|
||||
@@ -41,6 +42,24 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
|
||||
const handleSave = () => { save(); };
|
||||
|
||||
const TokenPlacementIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const CredentialsPlacementIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
|
||||
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const handleChange = (key, value) => {
|
||||
dispatch(
|
||||
updateAuth({
|
||||
@@ -61,7 +80,6 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
refreshTokenUrl,
|
||||
autoRefreshToken,
|
||||
autoFetchToken,
|
||||
tokenSource,
|
||||
additionalParameters,
|
||||
[key]: value
|
||||
}
|
||||
@@ -108,19 +126,26 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
<div className="flex items-center gap-4 w-full" key="input-credentials-placement">
|
||||
<label className="block min-w-[140px]">Add Credentials to</label>
|
||||
<div className="inline-flex items-center cursor-pointer token-placement-selector">
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'body', label: 'Request Body', onClick: () => handleChange('credentialsPlacement', 'body') },
|
||||
{ id: 'basic_auth_header', label: 'Basic Auth Header', onClick: () => handleChange('credentialsPlacement', 'basic_auth_header') }
|
||||
]}
|
||||
selectedItemId={credentialsPlacement}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end token-placement-label select-none">
|
||||
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<CredentialsPlacementIcon />} placement="bottom-end">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('credentialsPlacement', 'body');
|
||||
}}
|
||||
>
|
||||
Request Body
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('credentialsPlacement', 'basic_auth_header');
|
||||
}}
|
||||
>
|
||||
Basic Auth Header
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 mt-2">
|
||||
@@ -131,24 +156,6 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
Token
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-type">
|
||||
<label className="block min-w-[140px]">Token Source</label>
|
||||
<div className="inline-flex items-center cursor-pointer token-placement-selector">
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'access_token', label: 'Access Token', onClick: () => handleChange('tokenSource', 'access_token') },
|
||||
{ id: 'id_token', label: 'ID Token', onClick: () => handleChange('tokenSource', 'id_token') }
|
||||
]}
|
||||
selectedItemId={tokenSource}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenSource === 'id_token' ? 'ID Token' : 'Access Token'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-name">
|
||||
<label className="block min-w-[140px]">Token ID</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
@@ -167,19 +174,26 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-placement">
|
||||
<label className="block min-w-[140px]">Add token to</label>
|
||||
<div className="inline-flex items-center cursor-pointer token-placement-selector w-fit">
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'header', label: 'Header', onClick: () => handleChange('tokenPlacement', 'header') },
|
||||
{ id: 'url', label: 'URL', onClick: () => handleChange('tokenPlacement', 'url') }
|
||||
]}
|
||||
selectedItemId={tokenPlacement}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('tokenPlacement', 'header');
|
||||
}}
|
||||
>
|
||||
Header
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('tokenPlacement', 'url');
|
||||
}}
|
||||
>
|
||||
URL
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user