Compare commits

..

1 Commits

Author SHA1 Message Date
Bijin A B
ed51f304b8 fix: variables set via setVar should be interpolated only during runtime 2026-01-15 13:35:42 +05:30
749 changed files with 8252 additions and 50725 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View File

@@ -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
View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@opencollection/types": "~0.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"
}
}
}

View File

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

View File

@@ -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)/'

View File

@@ -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)
}
};

View File

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

View File

@@ -8,6 +8,8 @@
"build": "rsbuild build -m production",
"preview": "rsbuild preview",
"test": "jest",
"test:prettier": "prettier --check \"./src/**/*.{js,jsx,json,ts,tsx}\"",
"prettier": "prettier --write \"./src/**/*.{js,jsx,json,ts,tsx}\"",
"storybook": "storybook dev -p 6006 --config-dir storybook",
"build-storybook": "storybook build --config-dir storybook"
},
@@ -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",

View File

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

View File

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

View File

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

View File

@@ -4,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>

View File

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

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { IconRegex, IconArrowUp, IconArrowDown, IconX, IconLetterCase, IconLetterW } from '@tabler/icons';
import ToolHint from 'components/ToolHint';
import StyledWrapper from './StyledWrapper';
@@ -8,45 +8,7 @@ function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');
}
const MAX_MATCHES = 99_999;
function findSearchMatches(editor, searchText, regex, caseSensitive, wholeWord) {
try {
let query, options = {};
if (regex) {
try {
query = new RegExp(searchText, caseSensitive ? 'g' : 'gi');
} catch (error) {
console.warn('Invalid regex provided in search!', error);
return [];
}
} else if (wholeWord) {
const escaped = escapeRegExp(searchText);
query = new RegExp(`\\b${escaped}\\b`, caseSensitive ? 'g' : 'gi');
} else {
query = searchText;
options = { caseFold: !caseSensitive };
}
const cursor = editor.getSearchCursor(query, { line: 0, ch: 0 }, options);
const out = [];
while (cursor.findNext()) {
out.push({ from: cursor.from(), to: cursor.to() });
if (out.length >= MAX_MATCHES) {
break;
}
}
return out;
} catch (e) {
console.error('Search error:', e);
return [];
}
}
function createCacheKey(editor, searchText, regex, caseSensitive, wholeWord) {
return `${editor.getValue().length}${searchText}${regex}${caseSensitive}${wholeWord}`;
}
const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
const CodeMirrorSearch = ({ visible, editor, onClose }) => {
const [searchText, setSearchText] = useState('');
const [regex, setRegex] = useState(false);
const [caseSensitive, setCaseSensitive] = useState(false);
@@ -57,15 +19,48 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
const searchMarks = useRef([]);
const searchLineHighlight = useRef(null);
const searchMatches = useRef([]);
const searchCacheKey = useRef('');
const inputRef = useRef(null);
const debouncedSearchText = useDebounce(searchText, 250);
const doSearch = useCallback((newIndex = 0) => {
if (!editor || !visible) {
return;
const debouncedSearchText = useDebounce(searchText, 150);
const memoizedMatches = useMemo(() => {
if (!editor || !visible) return [];
if (!debouncedSearchText) return [];
try {
let query, options = {};
if (regex) {
try {
query = new RegExp(debouncedSearchText, caseSensitive ? 'g' : 'gi');
} catch {
return [];
}
} else if (wholeWord) {
const escaped = escapeRegExp(debouncedSearchText);
query = new RegExp(`\\b${escaped}\\b`, caseSensitive ? 'g' : 'gi');
} else {
query = debouncedSearchText;
options = { caseFold: !caseSensitive };
}
const cursor = editor.getSearchCursor(query, { line: 0, ch: 0 }, options);
const out = [];
while (cursor.findNext()) {
out.push({ from: cursor.from(), to: cursor.to() });
}
return out;
} catch (e) {
console.error('Search error:', e);
return [];
}
}, [editor, visible, debouncedSearchText, regex, caseSensitive, wholeWord]);
const doSearch = useCallback((newIndex = 0) => {
if (!editor) return;
// Clear previous marks
searchMarks.current.forEach((mark) => mark.clear());
searchMarks.current = [];
// Clear previous line highlight
if (searchLineHighlight.current !== null) {
editor.removeLineClass(searchLineHighlight.current, 'wrap', 'cm-search-line-highlight');
searchLineHighlight.current = null;
@@ -75,100 +70,44 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
setMatchCount(0);
setMatchIndex(0);
searchMatches.current = [];
searchMarks.current.forEach((mark) => mark.clear());
searchMarks.current = [];
return;
}
try {
const newCacheKey = createCacheKey(editor, debouncedSearchText, regex, caseSensitive, wholeWord);
const isCacheHit = newCacheKey === searchCacheKey.current;
let matches = searchMatches.current;
if (!isCacheHit) {
matches = findSearchMatches(editor, debouncedSearchText, regex, caseSensitive, wholeWord);
searchMatches.current = matches;
searchCacheKey.current = newCacheKey;
setMatchCount(matches.length);
}
if (!matches.length) {
setMatchIndex(0);
// Clear previous marks
searchMarks.current.forEach((mark) => mark.clear());
searchMarks.current = [];
return;
}
const matchIndex = Math.max(0, Math.min(newIndex, matches.length - 1));
setMatchIndex(matchIndex);
if (isCacheHit) {
// Clear only old current mark
const oldIndex = searchMarks.current.findIndex((mark) => mark.className?.includes('cm-search-current'));
if (oldIndex !== -1) {
searchMarks.current[oldIndex].clear();
searchMarks.current.splice(oldIndex, 1);
}
// Add mark to the new current and remark the previous and next
const toMark = [
// Previous
matchIndex > 0 ? matchIndex - 1 : null,
// Current
matchIndex,
// Next
matchIndex < matches.length - 1 ? matchIndex + 1 : null
].filter((i) => i !== null);
toMark.forEach((i) => {
const mark = editor.markText(matches[i].from, matches[i].to, {
className: i === matchIndex ? 'cm-search-current' : 'cm-search-match',
clearOnEnter: true
});
searchMarks.current.push(mark);
const matches = memoizedMatches;
let matchIndex = matches.length ? Math.max(0, Math.min(newIndex, matches.length - 1)) : 0;
matches.forEach((m, i) => {
const mark = editor.markText(m.from, m.to, {
className: i === matchIndex ? 'cm-search-current' : 'cm-search-match',
clearOnEnter: true
});
searchMarks.current.push(mark);
});
if (matches.length) {
const currentLine = matches[matchIndex].from.line;
editor.addLineClass(currentLine, 'wrap', 'cm-search-line-highlight');
searchLineHighlight.current = currentLine;
editor.scrollIntoView(matches[matchIndex].from, 100);
editor.setSelection(matches[matchIndex].from, matches[matchIndex].to);
} else {
// Clear previous marks
searchMarks.current.forEach((mark) => mark.clear());
searchMarks.current = [];
// Mark all on new search
matches.forEach((m, i) => {
const mark = editor.markText(m.from, m.to, {
className: i === matchIndex ? 'cm-search-current' : 'cm-search-match',
clearOnEnter: true
});
searchMarks.current.push(mark);
});
searchLineHighlight.current = null;
}
const currentLine = matches[matchIndex].from.line;
editor.addLineClass(currentLine, 'wrap', 'cm-search-line-highlight');
searchLineHighlight.current = currentLine;
editor.scrollIntoView(matches[matchIndex].from, 100);
editor.setSelection(matches[matchIndex].from, matches[matchIndex].to);
setMatchCount(matches.length);
setMatchIndex(matchIndex);
searchMatches.current = matches;
} catch (e) {
console.error('Search error:', e);
setMatchCount(0);
setMatchIndex(0);
searchMatches.current = [];
searchCacheKey.current = '';
}
}, [debouncedSearchText, regex, caseSensitive, wholeWord, editor, visible]);
useImperativeHandle(ref, () => ({
focus: () => {
if (inputRef.current) {
inputRef.current.focus();
}
}
}));
}, [debouncedSearchText, regex, caseSensitive, wholeWord, editor, memoizedMatches]);
useEffect(() => {
doSearch(0);
doSearch(0, debouncedSearchText);
}, [debouncedSearchText, doSearch]);
const handleSearchBarClose = useCallback(() => {
@@ -179,7 +118,6 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
searchLineHighlight.current = null;
}
searchMatches.current = [];
searchCacheKey.current = '';
if (onClose) onClose();
// Focus the editor after closing the search bar
if (editor) {
@@ -195,27 +133,32 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
const handleToggleRegex = () => {
setRegex((prev) => !prev);
setMatchIndex(0);
doSearch(0);
};
const handleToggleCase = () => {
setCaseSensitive((prev) => !prev);
setMatchIndex(0);
doSearch(0);
};
const handleToggleWholeWord = () => {
setWholeWord((prev) => !prev);
setMatchIndex(0);
doSearch(0);
};
const handleNext = () => {
if (!searchMatches.current || !searchMatches.current.length) return;
const next = (matchIndex + 1) % searchMatches.current.length;
let next = (matchIndex + 1) % searchMatches.current.length;
setMatchIndex(next);
doSearch(next);
};
const handlePrev = () => {
if (!searchMatches.current || !searchMatches.current.length) return;
const prev = (matchIndex - 1 + searchMatches.current.length) % searchMatches.current.length;
let prev = (matchIndex - 1 + searchMatches.current.length) % searchMatches.current.length;
setMatchIndex(prev);
doSearch(prev);
};
@@ -225,7 +168,6 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
<StyledWrapper>
<div className="bruno-search-bar">
<input
ref={inputRef}
autoFocus
type="text"
value={searchText}
@@ -254,6 +196,6 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
</div>
</StyledWrapper>
);
});
};
export default CodeMirrorSearch;

View File

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

View File

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

View File

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

View File

@@ -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') : ''}
/>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'] {

View File

@@ -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 && (

View File

@@ -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 &ldquo;{searchQuery.trim()}&rdquo;</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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { IconPlus, IconDownload, IconSettings } from '@tabler/icons';
import ToolHint from 'components/ToolHint';
import ColorBadge from 'components/ColorBadge';
const EnvironmentListContent = ({
environments,
@@ -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>
))}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
);
};

View File

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

View File

@@ -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>
);

View File

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

View File

@@ -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>
);

View File

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

View File

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

View File

@@ -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);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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') : ''}
/>
)
}

View File

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

View File

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

View File

@@ -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);
}

View File

@@ -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();
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);

View File

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

View File

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

View File

@@ -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>
);

View File

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

View File

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

View File

@@ -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;
}
`;

View File

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

View File

@@ -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;
}
}
`;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
);
};

View File

@@ -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' : ''}
/>
);
}

View File

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

View File

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