Compare commits

..

24 Commits

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-02 19:06:25 +00:00
naman-bruno
6e6804055d fix: default format on import modal (#7017) 2026-02-02 21:31:01 +05:30
naman-bruno
5904c36cdb feat: enhance ShareCollection component with export options and UI improvements (#7016) 2026-02-02 21:01:03 +05:30
naman-bruno
8c997c46af make yml default option (#6985)
* make yml default option
2026-02-02 19:45:45 +05:30
naman-bruno
700e25a1d5 Add: dotenv visual editor (#6964) 2026-02-02 19:43:54 +05:30
naman-bruno
c9059c9905 refactor: update opencollection extension for bruno (#7013)
* refactor: update YML parsing and stringification to utilize 'bruno' extensions for ignore and presets

* fix
2026-02-02 19:35:17 +05:30
naman-bruno
416b693afc fix: YML parsing and stringification to support post-response variables (#7009) 2026-02-02 18:57:35 +05:30
lohit
bafb235e72 feat: add certs and proxy config to bru.sendRequest API (#6988)
* feat: add certs and proxy config to bru.sendRequest API

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: handle URL string argument in bru.sendRequest

When bru.sendRequest is called with a plain URL string instead of a
config object, the function now normalizes it to { url: string } before
processing. This fixes the case where spreading a string created an
invalid config object.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add variable interpolation to bru.sendRequest certs and proxy config

Interpolate environment variables in clientCertificates and proxy
configuration for bru.sendRequest API, enabling use of variables like
{{CERT_PATH}} or {{PROXY_HOST}} in certificate paths and proxy settings.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: use interpolateObject for certs and proxy config interpolation

- Add interpolateObject to electron's interpolate-string.js using
  buildCombinedVars pattern (matches CLI implementation)
- Simplify cert-utils.js by using interpolateObject instead of
  manual field-by-field interpolation
- Add interpolation for clientCertificates and proxy config in CLI's
  run-single-request.js for bru.sendRequest

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: add all variable types to sendRequest interpolation options

- Add globalEnvVars, collectionVariables, folderVariables, requestVariables
  to sendRequestInterpolationOptions for complete variable support
- Use cached system proxy instead of redundant getSystemProxy() call
- Remove duplicate getOptions() call

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: skip CA cert loading when TLS verification is disabled

Only load CA certificates when shouldVerifyTls is true, since they
are not used for validation when TLS verification is disabled.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 17:59:46 +05:30
Sid
06dd5c14d5 CI: flaky test monitor (#7007) 2026-02-02 17:16:27 +05:30
Sanjai Kumar
0f0c2b5912 Revert "fix: ephemeral environment variables being saved to filesystem (#6723)" (#7012)
This reverts commit 5b1b1b5541.
2026-02-02 16:50:06 +05:30
gopu-bruno
6664295b2b fix: update sidebar item copy toast message (#7011) 2026-02-02 16:29:50 +05:30
shubh-bruno
679cb91549 fix: refocus search bar in code editor on Ctrl/Cmd + F (#6980) 2026-02-02 15:53:32 +05:30
naman-bruno
b9d9a27599 add support for additional context roots in opencollection (#6995)
* add support for additional context roots in opencollection
2026-02-02 14:04:46 +05:30
Chirag Chandrashekhar
1fc703e4e3 feat: implement dynamic terminal theming based on app theme (#6812)
- Added a function to build terminal themes from the app's current theme.
- Updated terminal creation and rendering functions to accept and apply the dynamic theme.
- Implemented a useEffect hook to update terminal themes when the app theme changes.
2026-02-02 12:38:51 +05:30
anusree-bruno
89a0494e7e Feat/preferences UI polish (#6989)
* Preferences UI polish

* chore: cleanup

* chore: cleanup

* chore: removed unused classname
2026-02-02 10:34:32 +05:30
shubh-bruno
04806144a5 fix: response pane actions for GQL requests (#6911) 2026-01-31 09:30:07 +05:30
Pooja
0c3d20b198 fix: restore cursor focus on save and show placeholder for empty cells (#6795) 2026-01-31 09:08:42 +05:30
gopu-bruno
3ddf8e2a8b fix: support multiline descriptions in example blocks (#6879)
* fix: support multiline descriptions in example blocks

* refactor: use outdentString for example multiline text block parsing

* test: add test case for examples without description field

* test: add jsonToBru conversion test for multiline descriptions

* refactor: generalize descriptionvalue to textvalue in example grammar
2026-01-30 23:04:48 +05:30
gopu-bruno
f10422cca6 fix: support multiline example names (#6895)
* fix: support mutliline example names

* fix: improve multiline example name parsing and processing

* test: add test cases for example name field parsing

* refactor: simplify example name parsing

* fix: sanitize multiline example names in Postman imports

* fix: sanitize Postman example names on import

* fix: sanitize OpenAPI example names on import
2026-01-30 23:01:28 +05:30
naman-bruno
ba166561cc feat: add custom AppMenu component for windows & linux (#6934)
* feat: add custom AppMenu component for windows & linux

* fixes

* fixes

* fixes

* fixes
2026-01-30 22:58:36 +05:30
lohit
3112380289 feat: cache system proxy to avoid redundant lookups (#6990)
- bruno-cli: fetch system proxy once before request loop and store in options
- bruno-electron: initialize system proxy cache at app startup
- Add refresh button in preferences to manually update cached system proxy
- Replace per-request getSystemProxy() calls with cached values

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:15:36 +05:30
Sid
559946bcce feat: add search functionality to environment variables. (#6659) (#6966) 2026-01-30 19:04:16 +05:30
Pooja
e1c01ebe18 feat: add resizable columns to table (#6843) 2026-01-30 18:25:13 +05:30
Sid
eb5dc12b43 Merge pull request #6970 from usebruno/feature/environment-color-extended
feat(#304) Environments color 🎨 (#1053)
2026-01-30 17:12:35 +05:30
143 changed files with 6193 additions and 1975 deletions

View File

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

78
.github/scripts/detect-flaky-tests.js vendored Normal file
View File

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

@@ -0,0 +1,119 @@
name: Flaky Test Detector
on:
pull_request:
branches: [main]
paths:
- 'tests/**/*.spec.ts'
permissions:
contents: read
pull-requests: 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@v8
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

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

@@ -0,0 +1,154 @@
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,6 +210,10 @@ 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

@@ -17,6 +17,7 @@ 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';
@@ -247,8 +248,9 @@ 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,7 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import React, { createRef } from 'react';
import { isEqual, escapeRegExp } from 'lodash';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { setupAutoComplete, showRootHints } from 'utils/codemirror/autocomplete';
@@ -16,7 +16,7 @@ import stripJsonComments from 'strip-json-comments';
import { getAllVariables } from 'utils/collections';
import { setupLinkAware } from 'utils/codemirror/linkAware';
import { setupLintErrorTooltip } from 'utils/codemirror/lint-errors';
import CodeMirrorSearch from 'components/CodeMirrorSearch';
import CodeMirrorSearch from 'components/CodeMirrorSearch/index';
const CodeMirror = require('codemirror');
window.jsonlint = jsonlint;
@@ -34,6 +34,7 @@ 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,
@@ -94,14 +95,14 @@ export default class CodeEditor extends React.Component {
}
},
'Cmd-F': (cm) => {
if (!this.state.searchBarVisible) {
this.setState({ searchBarVisible: true });
}
this.setState({ searchBarVisible: true }, () => {
this.searchBarRef.current?.focus();
});
},
'Ctrl-F': (cm) => {
if (!this.state.searchBarVisible) {
this.setState({ searchBarVisible: true });
}
this.setState({ searchBarVisible: true }, () => {
this.searchBarRef.current?.focus();
});
},
'Cmd-H': 'replace',
'Ctrl-H': 'replace',
@@ -309,6 +310,10 @@ 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 } from 'react';
import React, { useState, useEffect, useRef, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import { IconRegex, IconArrowUp, IconArrowDown, IconX, IconLetterCase, IconLetterW } from '@tabler/icons';
import ToolHint from 'components/ToolHint';
import StyledWrapper from './StyledWrapper';
@@ -8,7 +8,7 @@ function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');
}
const CodeMirrorSearch = ({ visible, editor, onClose }) => {
const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
const [searchText, setSearchText] = useState('');
const [regex, setRegex] = useState(false);
const [caseSensitive, setCaseSensitive] = useState(false);
@@ -19,6 +19,7 @@ const CodeMirrorSearch = ({ visible, editor, onClose }) => {
const searchMarks = useRef([]);
const searchLineHighlight = useRef(null);
const searchMatches = useRef([]);
const inputRef = useRef(null);
const debouncedSearchText = useDebounce(searchText, 150);
@@ -106,6 +107,14 @@ const CodeMirrorSearch = ({ visible, editor, onClose }) => {
}
}, [debouncedSearchText, regex, caseSensitive, wholeWord, editor, memoizedMatches]);
useImperativeHandle(ref, () => ({
focus: () => {
if (inputRef.current) {
inputRef.current.focus();
}
}
}));
useEffect(() => {
doSearch(0, debouncedSearchText);
}, [debouncedSearchText, doSearch]);
@@ -168,6 +177,7 @@ const CodeMirrorSearch = ({ visible, editor, onClose }) => {
<StyledWrapper>
<div className="bruno-search-bar">
<input
ref={inputRef}
autoFocus
type="text"
value={searchText}
@@ -196,6 +206,6 @@ const CodeMirrorSearch = ({ visible, editor, onClose }) => {
</div>
</StyledWrapper>
);
};
});
export default CodeMirrorSearch;

View File

@@ -56,7 +56,7 @@ const Headers = ({ collection }) => {
isKeyField: true,
placeholder: 'Name',
width: '30%',
render: ({ row, value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
@@ -64,7 +64,7 @@ const Headers = ({ collection }) => {
onChange={(newValue) => onChange(newValue.replace(/[\r\n]/g, ''))}
autocomplete={headerAutoCompleteList}
collection={collection}
placeholder={isLastEmptyRow ? 'Name' : ''}
placeholder={!value ? 'Name' : ''}
/>
)
},
@@ -72,7 +72,7 @@ const Headers = ({ collection }) => {
key: 'value',
name: 'Value',
placeholder: 'Value',
render: ({ row, value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
@@ -80,7 +80,7 @@ const Headers = ({ collection }) => {
onChange={onChange}
collection={collection}
autocomplete={MimeTypes}
placeholder={isLastEmptyRow ? 'Value' : ''}
placeholder={!value ? 'Value' : ''}
/>
)
}

View File

@@ -46,14 +46,14 @@ const VarsTable = ({ collection, vars, varType }) => {
</div>
),
placeholder: varType === 'request' ? 'Value' : 'Expr',
render: ({ row, value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<MultiLineEditor
value={value || ''}
theme={storedTheme}
onSave={onSave}
onChange={onChange}
collection={collection}
placeholder={isLastEmptyRow ? (varType === 'request' ? 'Value' : 'Expr') : ''}
placeholder={!value ? (varType === 'request' ? 'Value' : 'Expr') : ''}
/>
)
}

View File

@@ -2,10 +2,37 @@ 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();
@@ -33,7 +60,7 @@ const ensureParkingHost = () => {
return parkingHost;
};
const createTerminalForSession = (sessionId) => {
const createTerminalForSession = (sessionId, terminalTheme) => {
if (terminalInstances.has(sessionId)) {
return terminalInstances.get(sessionId);
}
@@ -42,28 +69,7 @@ const createTerminalForSession = (sessionId) => {
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
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'
},
theme: terminalTheme,
allowProposedApi: true
});
@@ -156,10 +162,10 @@ const cleanupTerminalInstance = (sessionId) => {
}
};
const openTerminalIntoContainer = async (container, sessionId) => {
const openTerminalIntoContainer = async (container, sessionId, terminalTheme) => {
if (!container || !sessionId) return;
const instance = createTerminalForSession(sessionId);
const instance = createTerminalForSession(sessionId, terminalTheme);
const { terminal, fitAddon } = instance;
if (!terminal.element) {
@@ -212,6 +218,8 @@ 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) => {
@@ -355,6 +363,15 @@ 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;
@@ -362,7 +379,7 @@ const TerminalTab = () => {
let mounted = true;
const setupTerminal = async () => {
await openTerminalIntoContainer(terminalRef.current, activeSessionId);
await openTerminalIntoContainer(terminalRef.current, activeSessionId, terminalTheme);
if (mounted) {
const instance = terminalInstances.get(activeSessionId);

View File

@@ -173,6 +173,18 @@ 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, ...props }) => {
const Dropdown = ({ icon, children, onCreate, placement, transparent, visible, appendTo, onMouseEnter, onMouseLeave, ...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,7 +11,14 @@ const Dropdown = ({ icon, children, onCreate, placement, transparent, visible, a
return (
<Tippy
render={(attrs) => (
<StyledWrapper className="tippy-box dropdown" transparent={transparent} tabIndex={-1} {...attrs}>
<StyledWrapper
className="tippy-box dropdown"
transparent={transparent}
tabIndex={-1}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
{...attrs}
>
{children}
</StyledWrapper>
)}

View File

@@ -6,8 +6,13 @@ const StyledWrapper = styled.div`
flex: 1;
overflow: hidden;
&.is-resizing {
cursor: col-resize !important;
user-select: none;
}
.table-container {
overflow-y: auto;
overflow: auto;
border-radius: ${(props) => props.theme.border.radius.base};
border: solid 1px ${(props) => props.theme.border.border0};
}
@@ -24,6 +29,7 @@ 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;
@@ -34,10 +40,36 @@ 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};
}
}
}
}
@@ -61,10 +93,32 @@ 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;
}
}
}
}
}

View File

@@ -1,9 +1,11 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react';
import { IconTrash, IconAlertCircle, IconGripVertical, IconMinusVertical } from '@tabler/icons';
import { Tooltip } from 'react-tooltip';
import { uuid } from 'utils/common';
import StyledWrapper from './StyledWrapper';
const MIN_COLUMN_WIDTH = 80;
const EditableTable = ({
columns,
rows,
@@ -23,7 +25,101 @@ const EditableTable = ({
const tableRef = useRef(null);
const emptyRowUidRef = useRef(null);
const [hoveredRow, setHoveredRow] = useState(null);
const [dragStart, setDragStart] = 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 createEmptyRow = useCallback(() => {
const newUid = uuid();
@@ -138,15 +234,9 @@ 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) => {
@@ -163,19 +253,16 @@ const EditableTable = ({
const updatedOrder = [...reorderableRows];
const [movedRow] = updatedOrder.splice(fromIndex, 1);
if (!movedRow) {
setDragStart(null);
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);
}, []);
@@ -225,7 +312,7 @@ const EditableTable = ({
className="mousetrap"
value={value || ''}
readOnly={column.readOnly}
placeholder={isEmpty ? column.placeholder || column.name : ''}
placeholder={!value ? column.placeholder || column.name : ''}
onChange={(e) => handleValueChange(row.uid, column.key, e.target.value)}
/>
{errorIcon}
@@ -236,7 +323,7 @@ const EditableTable = ({
const reorderableRowCount = showAddRow ? rowsWithEmpty.length - 1 : rowsWithEmpty.length;
return (
<StyledWrapper className={showCheckbox ? 'has-checkbox' : 'no-checkbox'}>
<StyledWrapper className={`${showCheckbox ? 'has-checkbox' : 'no-checkbox'} ${resizing ? 'is-resizing' : ''}`}>
<div className="table-container" ref={tableRef} data-testid={testId}>
<table>
<thead>
@@ -244,12 +331,19 @@ const EditableTable = ({
{showCheckbox && (
<td className="text-center">{checkboxLabel}</td>
)}
{columns.map((column) => (
{columns.map((column, colIndex) => (
<td
key={column.key}
style={{ width: getColumnWidth(column) }}
>
{column.name}
<span className="column-name">{column.name}</span>
{colIndex < columns.length - 1 && (
<div
className={`resize-handle ${resizing === column.key ? 'resizing' : ''}`}
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
onMouseDown={(e) => handleResizeStart(e, column.key)}
/>
)}
</td>
))}
{showDelete && (

View File

@@ -6,6 +6,11 @@ const Wrapper = styled.div`
flex: 1;
overflow: hidden;
&.is-resizing {
cursor: col-resize !important;
user-select: none;
}
.table-container {
overflow-y: auto;
border-radius: 8px;
@@ -32,10 +37,6 @@ const Wrapper = styled.div`
&:nth-child(5) {
width: 60px;
}
&:nth-child(2) {
width: 30%;
}
}
thead {
@@ -48,10 +49,26 @@ const Wrapper = styled.div`
padding: 5px 10px !important;
border-bottom: solid 1px ${(props) => props.theme.border.border0};
border-right: solid 1px ${(props) => props.theme.border.border0};
position: relative;
&:last-child {
border-right: none;
}
.resize-handle {
position: absolute;
right: 0;
top: 0;
width: 4px;
cursor: col-resize;
background: transparent;
z-index: 100;
&:hover,
&.resizing {
background: ${(props) => props.theme.colors.accent};
}
}
}
}
@@ -147,21 +164,6 @@ const Wrapper = styled.div`
opacity: 0.9;
}
}
.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;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
}
`;
export default Wrapper;

View File

@@ -0,0 +1,546 @@
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';
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 EnvironmentVariablesTable = ({
environment,
collection,
onSave,
draft,
onDraftChange,
onDraftClear,
setIsModified,
renderExtraValueContent,
searchQuery = ''
}) => {
const { storedTheme } = useTheme();
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 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 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;
prevEnvUidRef.current = environment.uid;
mountedRef.current = true;
if ((isMount || envChanged) && hasDraftForThisEnv && draft?.variables) {
formik.setValues([
...draft.variables,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
]);
}
}, [environment.uid, hasDraftForThisEnv, draft?.variables]);
const savedValuesJson = useMemo(() => {
return JSON.stringify(environment.variables || []);
}, [environment.variables]);
// Sync modified state
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]);
// Sync draft state
useEffect(() => {
const timeoutId = setTimeout(() => {
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const currentValuesJson = JSON.stringify(currentValues);
const hasActualChanges = currentValuesJson !== savedValuesJson;
const existingDraftVariables = hasDraftForThisEnv ? draft?.variables : null;
const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables) : 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 || [];
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;
}
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, index }) => {
const isLastRow = index === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
if (isLastRow && isEmptyRow) {
return true;
}
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]);
return (
<StyledWrapper className={resizing ? 'is-resizing' : ''}>
<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={(index, item) => item.variable.uid}
itemContent={(index, { variable, index: actualIndex }) => {
const isLastRow = actualIndex === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
return (
<>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${actualIndex}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
)}
</td>
<td style={{ width: columnWidths.name }}>
<div className="flex items-center">
<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() === '') ? 'Value' : ''}
onChange={(e) => handleNameChange(actualIndex, e)}
onBlur={() => handleNameBlur(actualIndex)}
onKeyDown={(e) => handleNameKeyDown(actualIndex, e)}
/>
<ErrorMessage name={`${actualIndex}.name`} index={actualIndex} />
</div>
</td>
<td className="flex flex-row flex-nowrap items-center" style={{ width: columnWidths.value }}>
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${actualIndex}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${actualIndex}.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>
)}
{renderExtraValueContent && renderExtraValueContent(variable)}
</td>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${actualIndex}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</>
);
}}
/>
<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

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

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

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

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

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

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

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

@@ -0,0 +1,130 @@
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,11 +1,22 @@
import styled from 'styled-components';
const Wrapper = styled.div`
const StyledWrapper = 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;
@@ -16,24 +27,20 @@ const Wrapper = styled.div`
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
td {
vertical-align: middle;
padding: 2px 10px;
&:nth-child(1) {
width: 25px;
border-right: none;
}
&:nth-child(4) {
width: 80px;
}
&:nth-child(5) {
width: 60px;
&:first-child {
width: 35%;
}
&:nth-child(2) {
width: 30%;
&.delete-col {
width: 40px;
text-align: center;
padding: 2px 4px;
}
}
@@ -42,30 +49,30 @@ const Wrapper = styled.div`
background: ${(props) => props.theme.sidebar.bg};
font-size: ${(props) => props.theme.font.size.base};
user-select: none;
td {
padding: 5px 10px !important;
border-bottom: solid 1px ${(props) => props.theme.border.border0};
border-right: solid 1px ${(props) => props.theme.border.border0};
&:last-child {
border-right: none;
}
}
}
tbody {
tr {
transition: background 0.1s ease;
&:last-child td {
border-bottom: none;
}
td {
border-bottom: solid 1px ${(props) => props.theme.border.border0};
border-right: solid 1px ${(props) => props.theme.border.border0};
&:last-child {
border-right: none;
}
@@ -101,12 +108,78 @@ const Wrapper = styled.div`
vertical-align: middle;
margin: 0;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
cursor: pointer;
border-radius: 4px;
transition: color 0.15s ease, background 0.15s ease;
}
.button-container {
padding: 12px 2px;
background: ${(props) => props.theme.bg};
flex-shrink: 0;
display: flex;
gap: 8px;
}
.submit {
padding: 6px 16px;
font-size: ${(props) => props.theme.font.size.sm};
border-radius: ${(props) => props.theme.border.radius.base};
border: none;
background: ${(props) => props.theme.brand};
color: ${(props) => props.theme.bg};
cursor: pointer;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.9;
}
}
.reset {
background: transparent;
padding: 6px 16px;
color: ${(props) => props.theme.brand};
&:hover {
opacity: 0.9;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: ${(props) => props.theme.colors.text.muted};
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;
}
}
`;
export default Wrapper;
export default StyledWrapper;

View File

@@ -0,0 +1,340 @@
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);
})
.catch((error) => {
console.error(error);
toast.error('An error occurred while saving the changes');
})
.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);
})
.catch((error) => {
console.error(error);
toast.error('An error occurred while saving the changes');
})
.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

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

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

@@ -0,0 +1,30 @@
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,57 +1,20 @@
import React, { useCallback, useRef, useMemo, useEffect } from 'react';
import { TableVirtuoso } from 'react-virtuoso';
import React, { useMemo, useCallback } from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { get } from 'lodash';
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 { useDispatch } from 'react-redux';
import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { setEnvironmentsDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables, flattenItems, isItemARequest } from 'utils/collections';
import { flattenItems, isItemARequest } from 'utils/collections';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import EnvironmentVariablesTable from 'components/EnvironmentVariablesTable';
import { sensitiveFields } from './constants';
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 EnvironmentVariables = ({ environment, setIsModified, collection, searchQuery = '' }) => {
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 = {};
@@ -97,428 +60,59 @@ const EnvironmentVariables = ({ environment, setIsModified, collection }) => {
return result;
}, [collection, environment]);
const hasSensitiveUsage = (name) => !!nonSecretSensitiveVarUsageMap[name];
const hasSensitiveUsage = useCallback((name) => !!nonSecretSensitiveVarUsageMap[name], [nonSecretSensitiveVarUsageMap]);
// 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
}
];
}, [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 : {};
const handleSave = useCallback(
(variables) => {
return dispatch(saveEnvironment(cloneDeep(variables), environment.uid, collection.uid));
},
onSubmit: () => {}
});
[dispatch, environment.uid, collection.uid]
);
// Restore draft values on mount or environment switch
useEffect(() => {
const isMount = !mountedRef.current;
const envChanged = prevEnvUidRef.current !== null && prevEnvUidRef.current !== environment.uid;
const handleDraftChange = useCallback(
(variables) => {
dispatch(
setEnvironmentsDraft({
collectionUid: collection.uid,
environmentUid: environment.uid,
variables
})
);
},
[dispatch, collection.uid, environment.uid]
);
prevEnvUidRef.current = environment.uid;
mountedRef.current = true;
const handleDraftClear = useCallback(() => {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
}, [dispatch, collection.uid]);
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 }));
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"
/>
);
}
}, 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);
};
}, []);
},
[hasSensitiveUsage]
);
return (
<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>
<EnvironmentVariablesTable
environment={environment}
collection={collection}
onSave={handleSave}
draft={hasDraftForThisEnv ? environmentsDraft : null}
onDraftChange={handleDraftChange}
onDraftClear={handleDraftClear}
setIsModified={setIsModified}
renderExtraValueContent={renderExtraValueContent}
searchQuery={searchQuery}
/>
);
};

View File

@@ -94,8 +94,63 @@ 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,6 +1,7 @@
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX } from '@tabler/icons';
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch } from '@tabler/icons';
import { useState, useRef } from 'react';
import { useDispatch } from 'react-redux';
import useDebounce from 'hooks/useDebounce';
import { renameEnvironment, updateEnvironmentColor } from 'providers/ReduxStore/slices/collections/actions';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
@@ -19,7 +20,11 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
const [isRenaming, setIsRenaming] = useState(false);
const [newName, setNewName] = useState('');
const [nameError, setNameError] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const inputRef = useRef(null);
const searchInputRef = useRef(null);
const validateEnvironmentName = (name) => {
if (!name || name.trim() === '') {
@@ -112,6 +117,23 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
}
};
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))
.then(() => {
@@ -176,6 +198,38 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
</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>
@@ -189,7 +243,12 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
</div>
<div className="content">
<EnvironmentVariables environment={environment} setIsModified={setIsModified} collection={collection} />
<EnvironmentVariables
environment={environment}
setIsModified={setIsModified}
collection={collection}
searchQuery={debouncedSearchQuery}
/>
</div>
</StyledWrapper>
);

View File

@@ -99,12 +99,39 @@ const StyledWrapper = styled.div`
}
}
.environments-list {
.sections-container {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0 8px;
}
.environments-list {
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};
}
}
.environment-item {
position: relative;
display: flex;
@@ -281,6 +308,39 @@ 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,17 +1,32 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import usePrevious from 'hooks/usePrevious';
import useOnClickOutside from 'hooks/useOnClickOutside';
import EnvironmentDetails from './EnvironmentDetails';
import CreateEnvironment from 'components/Environments/EnvironmentSettings/CreateEnvironment';
import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX } from '@tabler/icons';
import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX, IconFileAlert } from '@tabler/icons';
import Button from 'ui/Button';
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 } from 'react-redux';
import { addEnvironment, renameEnvironment, selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch, useSelector } from 'react-redux';
import {
addEnvironment,
renameEnvironment,
selectEnvironment,
saveDotEnvVariables,
saveDotEnvRaw,
createDotEnvFile,
deleteDotEnvFile
} 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,
@@ -25,7 +40,6 @@ const EnvironmentList = ({
}) => {
const dispatch = useDispatch();
const [openCreateModal, setOpenCreateModal] = useState(false);
const [openImportModal, setOpenImportModal] = useState(false);
const [searchText, setSearchText] = useState('');
const [isCreatingInline, setIsCreatingInline] = useState(false);
@@ -38,10 +52,40 @@ 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 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);
useEffect(() => {
if (dotEnvFiles.length === 0) {
setSelectedDotEnvFile(null);
setActiveView('environment');
setIsDotEnvModified(false);
return;
}
const fileExists = dotEnvFiles.some((f) => f.filename === selectedDotEnvFile);
if (!selectedDotEnvFile || !fileExists) {
setSelectedDotEnvFile(dotEnvFiles[0].filename);
}
}, [dotEnvFiles]);
useEffect(() => {
if (!environments?.length) {
setSelectedEnvironment(null);
@@ -87,44 +131,34 @@ 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);
@@ -135,7 +169,7 @@ const EnvironmentList = ({
}, 50);
};
const handleActivateEnvironment = (e, env) => {
const handleActivateEnvironment = useCallback((e, env) => {
e.stopPropagation();
dispatch(selectEnvironment(env.uid, collection.uid))
.then(() => {
@@ -144,11 +178,7 @@ const EnvironmentList = ({
.catch(() => {
toast.error('Failed to activate environment');
});
};
if (!selectedEnvironment) {
return null;
}
}, [dispatch, collection.uid]);
const validateEnvironmentName = (name, excludeUid = null) => {
if (!name || name.trim() === '') {
@@ -171,7 +201,7 @@ const EnvironmentList = ({
};
const handleCreateEnvClick = () => {
if (!isModified) {
if (!isModified && !isDotEnvModified) {
setIsCreatingInline(true);
setNewEnvName('');
setEnvNameError('');
@@ -183,11 +213,13 @@ const EnvironmentList = ({
}
};
const handleCancelCreate = () => {
const handleCancelCreate = useCallback(() => {
setIsCreatingInline(false);
setNewEnvName('');
setEnvNameError('');
};
}, []);
useOnClickOutside(createContainerRef, handleCancelCreate, isCreatingInline);
const handleSaveNewEnv = () => {
const error = validateEnvironmentName(newEnvName);
@@ -254,14 +286,16 @@ const EnvironmentList = ({
});
};
const handleCancelRename = () => {
const handleCancelRename = useCallback(() => {
setRenamingEnvUid(null);
setNewEnvName('');
setEnvNameError('');
};
}, []);
useOnClickOutside(renameContainerRef, handleCancelRename, !!renamingEnvUid);
const handleImportClick = () => {
if (!isModified) {
if (!isModified && !isDotEnvModified) {
setOpenImportModal(true);
} else {
setSwitchEnvConfirmClose(true);
@@ -280,12 +314,197 @@ 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!`);
setIsDotEnvModified(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={setIsDotEnvModified}
dotEnvExists={selectedDotEnvData?.exists}
viewMode={dotEnvViewMode}
collection={collection}
/>
</DotEnvFileDetails>
);
}
if (selectedEnvironment) {
return (
<EnvironmentDetails
environment={selectedEnvironment}
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
/>
);
}
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)} />
)}
@@ -299,42 +518,111 @@ 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>
<h2 className="title">Variables</h2>
</div>
<div className="search-container">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
type="text"
placeholder="Search environments..."
placeholder="Search..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="search-input"
/>
</div>
<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}>
<div className="sections-container">
<CollapsibleSection
title="Environments"
expanded={environmentsExpanded}
onToggle={() => setEnvironmentsExpanded(!environmentsExpanded)}
actions={(
<>
<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>
</>
)}
>
<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>
))}
{isCreatingInline && (
<div className="environment-item creating" ref={createContainerRef}>
<input
ref={inputRef}
type="text"
@@ -342,6 +630,7 @@ const EnvironmentList = ({
value={newEnvName}
onChange={handleEnvNameChange}
onKeyDown={handleEnvNameKeyDown}
placeholder="Environment name..."
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
@@ -350,7 +639,7 @@ const EnvironmentList = ({
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveRename}
onClick={handleSaveNewEnv}
onMouseDown={(e) => e.preventDefault()}
title="Save"
>
@@ -358,7 +647,7 @@ const EnvironmentList = ({
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelRename}
onClick={handleCancelCreate}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
@@ -366,76 +655,94 @@ const EnvironmentList = ({
</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>
</>
)}
{envNameError && (isCreatingInline || renamingEnvUid) && <div className="env-error">{envNameError}</div>}
{filteredEnvironments.length === 0 && !isCreatingInline && (
<div className="no-env-file">
<span>No environments</span>
</div>
)}
</div>
))}
</CollapsibleSection>
{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"
<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)}
>
<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>
<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>
)}
</div>
)}
{envNameError && (isCreatingInline || renamingEnvUid) && <div className="env-error">{envNameError}</div>}
</CollapsibleSection>
</div>
</div>
<EnvironmentDetails
environment={selectedEnvironment}
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
/>
{renderContent()}
</div>
</StyledWrapper>
);

View File

@@ -1,26 +1,7 @@
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);
@@ -30,23 +11,8 @@ 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,8 +1,14 @@
import React from 'react';
import { useSelector } from 'react-redux';
import WorkspaceEnvironments from 'components/WorkspaceHome/WorkspaceEnvironments';
const GlobalEnvironmentSettings = () => {
return <WorkspaceEnvironments />;
const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid);
const workspace = useSelector((state) =>
state.workspaces.workspaces.find((w) => w.uid === activeWorkspaceUid)
);
return <WorkspaceEnvironments workspace={workspace} />;
};
export default GlobalEnvironmentSettings;

View File

@@ -60,7 +60,7 @@ const Headers = ({ collection, folder }) => {
isKeyField: true,
placeholder: 'Name',
width: '30%',
render: ({ row, value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
@@ -68,7 +68,7 @@ const Headers = ({ collection, folder }) => {
onChange={(newValue) => onChange(newValue.replace(/[\r\n]/g, ''))}
autocomplete={headerAutoCompleteList}
collection={collection}
placeholder={isLastEmptyRow ? 'Name' : ''}
placeholder={!value ? 'Name' : ''}
/>
)
},
@@ -76,7 +76,7 @@ const Headers = ({ collection, folder }) => {
key: 'value',
name: 'Value',
placeholder: 'Value',
render: ({ row, value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
@@ -85,7 +85,7 @@ const Headers = ({ collection, folder }) => {
collection={collection}
item={folder}
autocomplete={MimeTypes}
placeholder={isLastEmptyRow ? 'Value' : ''}
placeholder={!value ? 'Value' : ''}
/>
)
}

View File

@@ -51,7 +51,7 @@ const VarsTable = ({ folder, collection, vars, varType }) => {
</div>
),
placeholder: varType === 'request' ? 'Value' : 'Expr',
render: ({ row, value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<MultiLineEditor
value={value || ''}
theme={storedTheme}
@@ -59,7 +59,7 @@ const VarsTable = ({ folder, collection, vars, varType }) => {
onChange={onChange}
collection={collection}
item={folder}
placeholder={isLastEmptyRow ? (varType === 'request' ? 'Value' : 'Expr') : ''}
placeholder={!value ? (varType === 'request' ? 'Value' : 'Expr') : ''}
/>
)
}

View File

@@ -1,9 +1,10 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.bruno-form {
padding: 1rem;
}
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
.submit {
margin-top: 1rem;
@@ -25,8 +26,6 @@ const StyledWrapper = styled.div`
}
.no-features-message {
text-align: center;
padding: 2rem;
color: var(--color-gray-500);
font-style: italic;
}

View File

@@ -93,12 +93,9 @@ const Beta = ({ close }) => {
return (
<StyledWrapper>
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="section-header">Beta Features</div>
<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

@@ -3,9 +3,12 @@ import Font from './Font/index';
const Display = ({ close }) => {
return (
<div className="flex flex-col my-2 gap-10 w-full">
<div className="w-fit flex flex-col gap-2">
<Font close={close} />
<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>
</div>
);

View File

@@ -1,6 +1,11 @@
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 {
@@ -18,6 +23,10 @@ const StyledWrapper = styled.div`
font-size: 0.8125rem;
}
}
.default-collection-location-input {
max-width: 28rem;
}
`;
export default StyledWrapper;

View File

@@ -174,8 +174,9 @@ const General = () => {
return (
<StyledWrapper className="w-full">
<div className="section-header">General Settings</div>
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="flex items-center my-2">
<div className="flex items-center mb-2">
<input
id="sslVerification"
type="checkbox"

View File

@@ -1,6 +1,11 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
table {
width: 80%;
border-collapse: collapse;

View File

@@ -8,6 +8,7 @@ const Keybindings = ({ close }) => {
return (
<StyledWrapper className="w-full">
<div className="section-header">Keybindings</div>
<div className="table-container">
<table>
<thead>

View File

@@ -1,6 +1,11 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
.settings-label {
width: 100px;
}
@@ -37,6 +42,7 @@ const StyledWrapper = styled.div`
.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 {

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { IconLoader2 } from '@tabler/icons';
import { getSystemProxyVariables } from 'providers/ReduxStore/slices/app';
import { IconLoader2, IconRefresh } from '@tabler/icons';
import { getSystemProxyVariables, refreshSystemProxy } from 'providers/ReduxStore/slices/app';
import StyledWrapper from '../StyledWrapper';
const SystemProxy = () => {
@@ -11,21 +11,32 @@ const SystemProxy = () => {
const [isFetching, setIsFetching] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
dispatch(getSystemProxyVariables())
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">
System Proxy {isFetching ? <IconLoader2 className="animate-spin ml-1" size={18} strokeWidth={1.5} /> : null}
<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.
@@ -75,6 +86,13 @@ const SystemProxy = () => {
<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>
);

View File

@@ -113,6 +113,7 @@ 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">

View File

@@ -72,7 +72,7 @@ const StyledWrapper = styled.div`
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
font-weight: 500;
margin-bottom: 8px;
margin: 6px 0 8px 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}

View File

@@ -1,6 +1,11 @@
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,8 +8,9 @@ const Support = () => {
return (
<StyledWrapper>
<div className="section-header">Support</div>
<div className="rows">
<div className="mt-2">
<div className="mb-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: 8px 0 16px 0;
padding-bottom: 16px;
}
.theme-mode-option {

View File

@@ -125,7 +125,7 @@ const Assertions = ({ item, collection }) => {
key: 'value',
name: 'Value',
width: '30%',
render: ({ row, value, onChange, isLastEmptyRow }) => {
render: ({ row, value, onChange }) => {
const { operator, value: assertionValue } = parseAssertionOperator(value);
if (isUnaryOperator(operator)) {
@@ -141,7 +141,7 @@ const Assertions = ({ item, collection }) => {
onRun={handleRun}
collection={collection}
item={item}
placeholder={isLastEmptyRow ? 'Value' : ''}
placeholder={!value ? 'Value' : ''}
/>
);
}

View File

@@ -47,7 +47,7 @@ const FormUrlEncodedParams = ({ item, collection }) => {
key: 'value',
name: 'Value',
placeholder: 'Value',
render: ({ row, value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<MultiLineEditor
value={value || ''}
theme={storedTheme}
@@ -57,7 +57,7 @@ const FormUrlEncodedParams = ({ item, collection }) => {
onRun={handleRun}
collection={collection}
item={item}
placeholder={isLastEmptyRow ? 'Value' : ''}
placeholder={!value ? 'Value' : ''}
/>
)
}

View File

@@ -157,7 +157,7 @@ const MultipartFormParams = ({ item, collection }) => {
allowNewlines={true}
collection={collection}
item={item}
placeholder={isLastEmptyRow ? 'Value' : ''}
placeholder={!value ? 'Value' : ''}
/>
</div>
{!hasTextValue && !isLastEmptyRow && (
@@ -178,11 +178,11 @@ const MultipartFormParams = ({ item, collection }) => {
name: 'Content-Type',
placeholder: 'Auto',
width: '20%',
render: ({ row, value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<SingleLineEditor
onSave={onSave}
theme={storedTheme}
placeholder={isLastEmptyRow ? 'Auto' : ''}
placeholder={!value ? 'Auto' : ''}
value={value || ''}
onChange={onChange}
onRun={handleRun}

View File

@@ -70,7 +70,7 @@ const QueryParams = ({ item, collection }) => {
key: 'value',
name: 'Value',
placeholder: 'Value',
render: ({ row, value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<MultiLineEditor
value={value || ''}
theme={storedTheme}
@@ -80,7 +80,7 @@ const QueryParams = ({ item, collection }) => {
collection={collection}
item={item}
variablesAutocomplete={true}
placeholder={isLastEmptyRow ? 'Value' : ''}
placeholder={!value ? 'Value' : ''}
/>
)
}

View File

@@ -66,7 +66,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
isKeyField: true,
placeholder: 'Name',
width: '30%',
render: ({ value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
@@ -76,7 +76,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
onRun={handleRun}
collection={collection}
item={item}
placeholder={isLastEmptyRow ? 'Name' : ''}
placeholder={!value ? 'Name' : ''}
/>
)
},
@@ -84,7 +84,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
key: 'value',
name: 'Value',
placeholder: 'Value',
render: ({ value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
@@ -94,7 +94,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
autocomplete={MimeTypes}
collection={collection}
item={item}
placeholder={isLastEmptyRow ? 'Value' : ''}
placeholder={!value ? 'Value' : ''}
/>
)
}

View File

@@ -61,7 +61,7 @@ const VarsTable = ({ item, collection, vars, varType }) => {
</div>
),
placeholder: varType === 'request' ? 'Value' : 'Expr',
render: ({ row, value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<MultiLineEditor
value={value || ''}
theme={storedTheme}
@@ -70,7 +70,7 @@ const VarsTable = ({ item, collection, vars, varType }) => {
onRun={handleRun}
collection={collection}
item={item}
placeholder={isLastEmptyRow ? (varType === 'request' ? 'Value' : 'Expr') : ''}
placeholder={!value ? (varType === 'request' ? 'Value' : 'Expr') : ''}
/>
)
}

View File

@@ -130,12 +130,12 @@ const ResponseExampleFileBody = ({ item, collection, exampleUid, editMode = fals
placeholder: 'Auto',
width: '30%',
readOnly: !editMode,
render: ({ row, value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<SingleLineEditor
className="flex items-center justify-center"
onSave={() => {}}
theme={storedTheme}
placeholder={isLastEmptyRow ? 'Auto' : ''}
placeholder={!value ? 'Auto' : ''}
value={value || ''}
onChange={onChange}
onRun={() => {}}

View File

@@ -58,7 +58,7 @@ const ResponseExampleFormUrlEncodedParams = ({ item, collection, exampleUid, edi
placeholder: 'Value',
width: '60%',
readOnly: !editMode,
render: ({ row, value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<MultiLineEditor
value={value || ''}
theme={storedTheme}
@@ -68,7 +68,7 @@ const ResponseExampleFormUrlEncodedParams = ({ item, collection, exampleUid, edi
onRun={() => {}}
collection={collection}
item={item}
placeholder={isLastEmptyRow ? 'Value' : ''}
placeholder={!value ? 'Value' : ''}
/>
)
}

View File

@@ -68,7 +68,7 @@ const ResponseExampleHeaders = ({ editMode, item, collection, exampleUid }) => {
placeholder: 'Key',
width: '40%',
readOnly: !editMode,
render: ({ row, value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<SingleLineEditor
value={value || ''}
readOnly={!editMode}
@@ -78,7 +78,7 @@ const ResponseExampleHeaders = ({ editMode, item, collection, exampleUid }) => {
autocomplete={headerAutoCompleteList}
onRun={() => {}}
collection={collection}
placeholder={isLastEmptyRow ? 'Key' : ''}
placeholder={!value ? 'Key' : ''}
/>
)
},
@@ -88,7 +88,7 @@ const ResponseExampleHeaders = ({ editMode, item, collection, exampleUid }) => {
placeholder: 'Value',
width: '60%',
readOnly: !editMode,
render: ({ row, value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<SingleLineEditor
value={value || ''}
readOnly={!editMode}
@@ -100,7 +100,7 @@ const ResponseExampleHeaders = ({ editMode, item, collection, exampleUid }) => {
allowNewlines={true}
collection={collection}
item={item}
placeholder={isLastEmptyRow ? 'Value' : ''}
placeholder={!value ? 'Value' : ''}
/>
)
}

View File

@@ -206,7 +206,7 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
collection={collection}
item={item}
readOnly={!editMode}
placeholder={isLastEmptyRow ? 'Value' : ''}
placeholder={!value ? 'Value' : ''}
/>
</div>
{!hasTextValue && !isLastEmptyRow && (
@@ -228,11 +228,11 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
placeholder: 'Auto',
width: '30%',
readOnly: !editMode,
render: ({ row, value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<SingleLineEditor
onSave={() => {}}
theme={storedTheme}
placeholder={isLastEmptyRow ? 'Auto' : ''}
placeholder={!value ? 'Auto' : ''}
value={value || ''}
onChange={onChange}
onRun={() => {}}

View File

@@ -105,7 +105,7 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
placeholder: 'Name',
width: '40%',
readOnly: !editMode,
render: ({ row, value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
@@ -115,7 +115,7 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
collection={collection}
variablesAutocomplete={true}
readOnly={!editMode}
placeholder={isLastEmptyRow ? 'Name' : ''}
placeholder={!value ? 'Name' : ''}
/>
)
},
@@ -125,7 +125,7 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
placeholder: 'Value',
width: '60%',
readOnly: !editMode,
render: ({ row, value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
@@ -135,7 +135,7 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
collection={collection}
variablesAutocomplete={true}
readOnly={!editMode}
placeholder={isLastEmptyRow ? 'Value' : ''}
placeholder={!value ? 'Value' : ''}
/>
)
}
@@ -154,7 +154,7 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
placeholder: 'Value',
width: '60%',
readOnly: !editMode,
render: ({ row, value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
@@ -164,7 +164,7 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
collection={collection}
variablesAutocomplete={true}
readOnly={!editMode}
placeholder={isLastEmptyRow ? 'Value' : ''}
placeholder={!value ? 'Value' : ''}
/>
)
}

View File

@@ -124,7 +124,7 @@ const ResponseExampleResponseHeaders = ({ editMode, item, collection, exampleUid
placeholder: 'Key',
width: '40%',
readOnly: !editMode,
render: ({ row, value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
@@ -134,7 +134,7 @@ const ResponseExampleResponseHeaders = ({ editMode, item, collection, exampleUid
onRun={() => {}}
collection={collection}
readOnly={!editMode}
placeholder={isLastEmptyRow ? 'Key' : ''}
placeholder={!value ? 'Key' : ''}
/>
)
},
@@ -144,7 +144,7 @@ const ResponseExampleResponseHeaders = ({ editMode, item, collection, exampleUid
placeholder: 'Value',
width: '60%',
readOnly: !editMode,
render: ({ row, value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
@@ -156,7 +156,7 @@ const ResponseExampleResponseHeaders = ({ editMode, item, collection, exampleUid
collection={collection}
item={item}
readOnly={!editMode}
placeholder={isLastEmptyRow ? 'Value' : ''}
placeholder={!value ? 'Value' : ''}
/>
)
}

View File

@@ -47,6 +47,49 @@ const ResponsePaneActions = ({ item, collection, responseSize, selectedFormat, s
const copyButtonRef = useRef(null);
const layoutToggleButtonRef = useRef(null);
/**
* GQL response actions missing with Save response - because their is schema validation missing for saving GQL response will undo once example
* scehem is updated
*/
const gqlMenuItems = [
{
id: 'copy-response',
label: 'Copy response',
leftSection: IconCopy,
get disabled() {
return copyButtonRef.current?.isDisabled ?? false;
},
onClick: () => copyButtonRef.current?.click()
},
{
id: 'download-response',
label: 'Download response',
leftSection: IconDownload,
get disabled() {
return downloadButtonRef.current?.isDisabled ?? false;
},
onClick: () => downloadButtonRef.current?.click()
},
{
id: 'clear-response',
label: 'Clear response',
leftSection: IconEraser,
get disabled() {
return clearButtonRef.current?.isDisabled ?? false;
},
onClick: () => clearButtonRef.current?.click()
},
{
id: 'change-layout',
label: 'Change layout',
leftSection: orientation === 'vertical' ? IconLayoutColumns : IconLayoutRows,
get disabled() {
return layoutToggleButtonRef.current?.isDisabled ?? false;
},
onClick: () => layoutToggleButtonRef.current?.click()
}
];
const menuItems = [
{
id: 'copy-response',
@@ -95,7 +138,7 @@ const ResponsePaneActions = ({ item, collection, responseSize, selectedFormat, s
}
];
if (item.type !== 'http-request') {
if (!['http-request', 'graphql-request'].includes(item.type)) {
return null;
}
@@ -103,7 +146,7 @@ const ResponsePaneActions = ({ item, collection, responseSize, selectedFormat, s
<StyledWrapper className="response-pane-actions-wrapper">
<div className="actions-dropdown">
<MenuDropdown
items={menuItems}
items={item.type !== 'graphql-request' ? menuItems : gqlMenuItems}
placement="bottom-end"
data-testid="response-actions-menu"
>
@@ -119,7 +162,7 @@ const ResponsePaneActions = ({ item, collection, responseSize, selectedFormat, s
data={data}
dataBuffer={dataBuffer}
/>
<ResponseBookmark ref={bookmarkButtonRef} item={item} collection={collection} responseSize={responseSize} />
{item.type !== 'graphql-request' && <ResponseBookmark ref={bookmarkButtonRef} item={item} collection={collection} responseSize={responseSize} />}
<ResponseDownload ref={downloadButtonRef} item={item} />
<ResponseClear ref={clearButtonRef} item={item} collection={collection} />
<ResponseLayoutToggle ref={layoutToggleButtonRef} />

View File

@@ -14,6 +14,7 @@ import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
import { resolveRequestFilename } from 'utils/common/platform';
import { transformRequestToSaveToFilesystem, findCollectionByUid, findItemInCollection } from 'utils/collections';
import { DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants';
import { itemSchema } from '@usebruno/schema';
const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOpen = false, onClose }) => {
@@ -115,7 +116,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
const transformedItem = transformRequestToSaveToFilesystem(itemToSave);
await itemSchema.validate(transformedItem);
const format = collection.format || 'bru';
const format = collection.format || DEFAULT_COLLECTION_FORMAT;
const targetFilename = resolveRequestFilename(sanitizedFilename, format);
await ipcRenderer.invoke('renderer:save-transient-request', {

View File

@@ -1,5 +1,4 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
.tabs {
@@ -28,44 +27,157 @@ const StyledWrapper = styled.div`
}
}
.beta-badge-corner {
position: absolute;
top: 0;
right: 0;
padding: 0.25rem 0.5rem;
font-size: 0.625rem;
.section-title {
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.025em;
background-color: ${(props) => rgba(props.theme.colors.text.yellow, 0.15)};
color: ${(props) => props.theme.colors.text.yellow};
border-top-right-radius: ${(props) => props.theme.border.radius.base};
border-bottom-left-radius: ${(props) => props.theme.border.radius.base};
letter-spacing: 0.05em;
color: ${(props) => props.theme.colors.text.subtext0};
margin-bottom: 0.75rem;
}
.share-button {
display: flex;
border-radius: ${(props) => props.theme.border.radius.base};
padding: 10px;
border: 1px solid ${(props) => props.theme.border.border0};
background-color: ${(props) => props.theme.background.base};
color: ${(props) => props.theme.text};
cursor: pointer;
transition: all 0.1s ease;
&.no-padding {
padding: 0px;
}
.note-warning {
color: ${(props) => props.theme.colors.text.warning};
background-color: ${(props) => rgba(props.theme.colors.text.warning, 0.06)};
}
.opencollection-link {
color: ${(props) => props.theme.textLink};
text-decoration: none;
&:hover {
background-color: ${(props) => props.theme.background.mantle};
text-decoration: underline;
}
}
.bruno-format-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.format-card {
display: flex;
flex-direction: column;
border-radius: ${(props) => props.theme.border.radius.base};
padding: 1rem;
border: 2px solid ${(props) => props.theme.border.border0};
background-color: ${(props) => props.theme.background.base};
cursor: pointer;
transition: border-color 0.15s ease;
min-height: 180px;
&:hover:not(.selected) {
border-color: ${(props) => props.theme.border.border2};
}
&.selected {
border-color: ${(props) => props.theme.primary.solid};
}
.card-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
.card-title {
font-weight: 600;
font-size: 0.9375rem;
}
.recommended-badge {
padding: 0.125rem 0.5rem;
font-size: 0.6875rem;
font-weight: 600;
border-radius: 0.25rem;
background-color: ${(props) => props.theme.colors.text.warning};
color: white;
}
}
.card-description {
font-size: 0.8125rem;
color: ${(props) => props.theme.colors.text.subtext0};
margin-bottom: 0.75rem;
}
.feature-list {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.375rem;
.feature-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-size: 0.8125rem;
color: ${(props) => props.theme.colors.text.subtext0};
.checkmark {
color: ${(props) => props.theme.colors.text.subtext0};
flex-shrink: 0;
margin-top: 0.125rem;
}
}
}
.best-for {
margin-top: 0.75rem;
font-size: 0.75rem;
font-style: italic;
color: ${(props) => props.theme.colors.text.muted};
}
}
.other-format-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.other-format-card {
display: flex;
align-items: center;
gap: 0.75rem;
border-radius: ${(props) => props.theme.border.radius.base};
padding: 0.75rem 1rem;
border: 2px solid ${(props) => props.theme.border.border0};
background-color: ${(props) => props.theme.background.base};
cursor: pointer;
transition: border-color 0.15s ease;
&:hover:not(.selected) {
border-color: ${(props) => props.theme.border.border2};
}
&.selected {
border-color: ${(props) => props.theme.primary.solid};
}
.format-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.format-info {
.format-name {
font-weight: 600;
font-size: 0.875rem;
}
.format-description {
font-size: 0.75rem;
color: ${(props) => props.theme.colors.text.subtext0};
}
}
}
.modal-footer {
display: flex;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid ${(props) => props.theme.border.border0};
}
`;

View File

@@ -1,22 +1,27 @@
import React, { useMemo } from 'react';
import React, { useState, useMemo } from 'react';
import Modal from 'components/Modal';
import { IconUpload, IconLoader2, IconAlertTriangle } from '@tabler/icons';
import Button from 'ui/Button';
import { IconCheck, IconAlertTriangle, IconFileExport } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import Bruno from 'components/Bruno';
import OpenCollectionIcon from 'components/Icons/OpenCollectionIcon';
import exportBrunoCollection from 'utils/collections/export';
import exportPostmanCollection from 'utils/exporters/postman-collection';
import exportOpenCollection from 'utils/exporters/opencollection';
import { cloneDeep } from 'lodash';
import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index';
import { useSelector } from 'react-redux';
import { findCollectionByUid, areItemsLoading } from 'utils/collections/index';
import { useApp } from 'providers/App';
import toast from 'react-hot-toast';
const EXPORT_FORMATS = {
ZIP: 'zip',
YAML: 'yaml',
POSTMAN: 'postman'
};
const ShareCollection = ({ onClose, collectionUid }) => {
const { version } = useApp();
const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid));
const isCollectionLoading = areItemsLoading(collection);
const [selectedFormat, setSelectedFormat] = useState(EXPORT_FORMATS.ZIP);
const [isExporting, setIsExporting] = useState(false);
const hasNonExportableRequestTypes = useMemo(() => {
let types = new Set();
@@ -40,110 +45,157 @@ const ShareCollection = ({ onClose, collectionUid }) => {
};
}, [collection]);
const handleExportBrunoCollection = () => {
const collectionCopy = cloneDeep(collection);
exportBrunoCollection(transformCollectionToSaveToExportAsFile(collectionCopy), version);
onClose();
const handleExportZip = async () => {
try {
const { ipcRenderer } = window;
const result = await ipcRenderer.invoke('renderer:export-collection-zip', collection.pathname, collection.name);
if (result.success) {
toast.success('Collection exported successfully');
}
} catch (error) {
toast.error('Failed to export collection: ' + error.message);
}
};
const handleExportPostmanCollection = () => {
const handleExportYaml = () => {
const collectionCopy = cloneDeep(collection);
exportOpenCollection(transformCollectionToSaveToExportAsFile(collectionCopy));
};
const handleExportPostman = () => {
const collectionCopy = cloneDeep(collection);
exportPostmanCollection(collectionCopy);
onClose();
};
const handleExportOpenCollection = () => {
const collectionCopy = cloneDeep(collection);
exportOpenCollection(transformCollectionToSaveToExportAsFile(collectionCopy), version);
onClose();
const handleProceed = async () => {
if (isCollectionLoading || isExporting) return;
setIsExporting(true);
try {
switch (selectedFormat) {
case EXPORT_FORMATS.ZIP:
await handleExportZip();
break;
case EXPORT_FORMATS.YAML:
handleExportYaml();
break;
case EXPORT_FORMATS.POSTMAN:
handleExportPostman();
break;
}
onClose();
} catch (error) {
console.error('Export error:', error);
} finally {
setIsExporting(false);
}
};
const isDisabled = isCollectionLoading || isExporting;
return (
<Modal
size="md"
title="Share Collection"
confirmText="Close"
handleConfirm={onClose}
handleCancel={onClose}
hideCancel
>
<StyledWrapper className="flex flex-col h-full w-[500px]">
<div className="space-y-2">
<div
className={`share-button ${
isCollectionLoading
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer'
}`}
onClick={isCollectionLoading ? undefined : handleExportBrunoCollection}
<Modal size="lg" title="Share Collection" handleCancel={onClose} hideFooter>
<StyledWrapper className="flex flex-col">
<p className="text-sm mb-4">
Bruno uses{' '}
<a
href="https://opencollection.com"
target="_blank"
rel="noopener noreferrer"
className="opencollection-link"
>
<div className="mr-3 p-1 rounded-full">
{isCollectionLoading ? <IconLoader2 size={28} className="animate-spin" /> : <Bruno width={28} />}
OpenCollection
</a>
{' '}- An open format for API collections
</p>
{/* Bruno Format Section */}
<div className="section-title">Bruno Format</div>
<div className="bruno-format-grid mb-6">
{/* ZIP Option */}
<div
className={`format-card ${selectedFormat === EXPORT_FORMATS.ZIP ? 'selected' : ''} ${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={() => !isDisabled && setSelectedFormat(EXPORT_FORMATS.ZIP)}
>
<div className="card-header">
<span className="card-title">Bruno Collection (ZIP)</span>
<span className="recommended-badge">Recommended</span>
</div>
<div className="flex-1">
<div className="font-medium">Bruno Collection</div>
<div className="text-xs">{isCollectionLoading ? 'Loading collection...' : 'Export in Bruno format'}</div>
<p className="card-description">OpenCollection format organized as folders and files</p>
<div className="feature-list">
<div className="feature-item">
<IconCheck size={14} className="checkmark" />
<span>Folder structure with individual .yml files</span>
</div>
<div className="feature-item">
<IconCheck size={14} className="checkmark" />
<span>Collaborate with your team via pull requests</span>
</div>
<div className="feature-item">
<IconCheck size={14} className="checkmark" />
<span>Extract and open directly in Bruno</span>
</div>
</div>
<p className="best-for">Best for: Team collaboration, version control, publishing</p>
</div>
{/* Single File YAML Option */}
<div
className={`share-button relative ${
isCollectionLoading
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer'
}`}
onClick={isCollectionLoading ? undefined : handleExportOpenCollection}
className={`format-card ${selectedFormat === EXPORT_FORMATS.YAML ? 'selected' : ''} ${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={() => !isDisabled && setSelectedFormat(EXPORT_FORMATS.YAML)}
>
<span className="beta-badge-corner">Beta</span>
<div className="mr-3 p-1 rounded-full">
{isCollectionLoading ? (
<IconLoader2 size={28} className="animate-spin" />
) : (
<OpenCollectionIcon size={28} />
)}
<div className="card-header">
<span className="card-title">Single File (YAML)</span>
</div>
<div className="flex-1">
<div className="font-medium">OpenCollection</div>
<div className="text-xs">{isCollectionLoading ? 'Loading collection...' : 'Export in OpenCollection format'}</div>
<p className="card-description">OpenCollection format bundled into one .yml file</p>
<div className="feature-list">
<div className="feature-item">
<IconCheck size={14} className="checkmark" />
<span>Everything in a single YAML file</span>
</div>
<div className="feature-item">
<IconCheck size={14} className="checkmark" />
<span>Paste in a gist or attach to an issue</span>
</div>
</div>
<p className="best-for">Best for: Quick sharing as a single file</p>
</div>
</div>
<div className="section-title">Other Format</div>
<div className="other-format-grid">
<div
className={`flex !flex-col share-button no-padding ${
isCollectionLoading
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer'
}`}
onClick={isCollectionLoading ? undefined : handleExportPostmanCollection}
className={`other-format-card ${selectedFormat === EXPORT_FORMATS.POSTMAN ? 'selected' : ''} ${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={() => !isDisabled && setSelectedFormat(EXPORT_FORMATS.POSTMAN)}
>
{hasNonExportableRequestTypes.has && (
<div className="px-3 py-2 w-full flex items-center note-warning">
<IconAlertTriangle size={16} className="mr-2 flex-shrink-0" />
<span>
Note:
{hasNonExportableRequestTypes.types.join(', ')}
{' '}
requests in this collection will not be exported
</span>
</div>
)}
<div className="flex items-center p-3 w-full">
<div className="mr-3 p-1 rounded-full">
{isCollectionLoading ? (
<IconLoader2 size={28} className="animate-spin" />
) : (
<IconUpload size={28} strokeWidth={1} className="" />
)}
</div>
<div className="flex-1">
<div className="font-medium">Postman Collection</div>
<div className="text-xs">
{isCollectionLoading ? 'Loading collection...' : 'Export in Postman format'}
</div>
</div>
<div className="format-icon">
<IconFileExport size={28} strokeWidth={1.5} />
</div>
<div className="format-info">
<div className="format-name">Postman</div>
<div className="format-description">Export for Postman</div>
</div>
</div>
</div>
{selectedFormat === EXPORT_FORMATS.POSTMAN && hasNonExportableRequestTypes.has && (
<div className="flex items-center mt-4 p-3 rounded" style={{ backgroundColor: 'rgba(251, 191, 36, 0.1)' }}>
<IconAlertTriangle size={16} className="mr-2 flex-shrink-0" style={{ color: '#f59e0b' }} />
<span className="text-sm" style={{ color: '#f59e0b' }}>
Note: {hasNonExportableRequestTypes.types.join(', ')} requests in this collection will not be exported
</span>
</div>
)}
<div className="modal-footer">
<Button
onClick={handleProceed}
disabled={isDisabled}
loading={isExporting}
>
{isExporting ? 'Exporting...' : 'Proceed'}
</Button>
</div>
</StyledWrapper>
</Modal>
);

View File

@@ -27,7 +27,7 @@ const ExampleItem = ({ example, item, collection }) => {
// Check if this example is the active tab
const activeTabUid = useSelector((state) => state.tabs?.activeTabUid);
const isExampleActive = activeTabUid === example.uid;
const [editName, setEditName] = useState(example.name);
const [editName, setEditName] = useState(example.name || '');
const [showRenameModal, setShowRenameModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false);
@@ -230,7 +230,7 @@ const ExampleItem = ({ example, item, collection }) => {
handleConfirm={() => handleRenameConfirm(editName)}
confirmText="Rename"
cancelText="Cancel"
confirmDisabled={!editName.trim()}
confirmDisabled={!editName || !editName.trim()}
>
<div>
<label htmlFor="renameExampleName" className="block font-medium">

View File

@@ -535,7 +535,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
const handleCopyItem = () => {
dispatch(copyRequest(item));
const itemType = isFolder ? 'Folder' : 'Request';
toast.success(`${itemType} copied to clipboard`);
toast.success(`${itemType} copied`);
};
const handlePasteItem = () => {

View File

@@ -1,28 +1,10 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
.beta-badge {
margin-left: 0.5rem;
padding: 0.125rem 0.5rem;
font-size: 0.625rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.025em;
background-color: ${(props) => rgba(props.theme.colors.text.yellow, 0.15)};
color: ${(props) => props.theme.colors.text.yellow};
border-radius: ${(props) => props.theme.border.radius.sm};
}
.discussion-link {
margin-left: 0.5rem;
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.textLink};
cursor: pointer;
font-weight: 400;
&:hover {
text-decoration: underline;
.advanced-options {
.caret {
color: ${(props) => props.theme.textLink};
fill: ${(props) => props.theme.textLink};
}
}

View File

@@ -1,4 +1,4 @@
import React, { useRef, useEffect } from 'react';
import React, { useRef, useEffect, useState, forwardRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useFormik } from 'formik';
import * as Yup from 'yup';
@@ -8,11 +8,12 @@ import Portal from 'components/Portal';
import Modal from 'components/Modal';
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
import PathDisplay from 'components/PathDisplay/index';
import { useState } from 'react';
import { IconArrowBackUp, IconEdit, IconExternalLink } from '@tabler/icons';
import { IconArrowBackUp, IconEdit, IconCaretDown } from '@tabler/icons';
import Help from 'components/Help';
import Dropdown from 'components/Dropdown';
import { multiLineMsg } from 'utils/common';
import { formatIpcError } from 'utils/common/error';
import { DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants';
import StyledWrapper from './StyledWrapper';
import get from 'lodash/get';
import Button from 'ui/Button';
@@ -23,7 +24,11 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) =>
const workspaces = useSelector((state) => state.workspaces?.workspaces || []);
const workspaceUid = useSelector((state) => state.workspaces?.activeWorkspaceUid);
const [isEditing, toggleEditing] = useState(false);
const [showFileFormat, setShowFileFormat] = useState(false);
const preferences = useSelector((state) => state.app.preferences);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const activeWorkspace = workspaces.find((w) => w.uid === workspaceUid);
const isDefaultWorkspace = activeWorkspace?.type === 'default';
@@ -35,7 +40,7 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) =>
collectionName: '',
collectionFolderName: '',
collectionLocation: defaultLocation || '',
format: 'bru'
format: DEFAULT_COLLECTION_FORMAT
},
validationSchema: Yup.object({
collectionName: Yup.string()
@@ -86,6 +91,20 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) =>
}
}, [inputRef]);
const AdvancedOptions = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex mr-2 text-link cursor-pointer items-center">
<button
className="btn-advanced"
type="button"
>
Options
</button>
<IconCaretDown className="caret ml-1" size={14} strokeWidth={2} />
</div>
);
});
return (
<Portal>
<StyledWrapper>
@@ -209,62 +228,61 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) =>
</div>
)}
<div className="mt-4">
<label htmlFor="format" className="flex items-center font-medium">
File Format
<Help width="300">
<p>
Choose the file format for storing requests in this collection.
</p>
<p className="mt-2">
<strong>OpenCollection (YAML):</strong> Industry-standard YAML format (.yml files)
</p>
<p className="mt-1">
<strong>BRU:</strong> Bruno's native file format (.bru files)
</p>
</Help>
{formik.values.format === 'yml' && (
<>
<span className="beta-badge">Beta</span>
<a
href="#"
className="discussion-link"
onClick={(e) => {
e.preventDefault();
window.open('https://github.com/usebruno/bruno/discussions/6634', '_blank', 'noopener,noreferrer');
}}
>
Join the discussion
</a>
</>
)}
</label>
<select
id="format"
name="format"
className="block textbox mt-2 w-full"
value={formik.values.format}
onChange={formik.handleChange}
>
<option value="yml">OpenCollection (YAML)</option>
<option value="bru">BRU Format (.bru)</option>
</select>
{formik.touched.format && formik.errors.format ? (
<div className="text-red-500">{formik.errors.format}</div>
) : null}
</div>
{showFileFormat && (
<div className="mt-4">
<label htmlFor="format" className="flex items-center font-medium">
File Format
<Help width="300">
<p>
Choose the file format for storing requests in this collection.
</p>
<p className="mt-2">
<strong>OpenCollection (YAML):</strong> Industry-standard YAML format (.yml files)
</p>
<p className="mt-1">
<strong>BRU:</strong> Bruno's native file format (.bru files)
</p>
</Help>
</label>
<select
id="format"
name="format"
className="block textbox mt-2 w-full"
value={formik.values.format}
onChange={formik.handleChange}
>
<option value="yml">OpenCollection (YAML)</option>
<option value="bru">BRU Format (.bru)</option>
</select>
{formik.touched.format && formik.errors.format ? (
<div className="text-red-500">{formik.errors.format}</div>
) : null}
</div>
)}
</div>
<div className="flex justify-end items-center mt-8 bruno-modal-footer">
<span className="mr-2">
<Button type="button" color="secondary" variant="ghost" onClick={onClose}>
<div className="flex justify-between items-center mt-8 bruno-modal-footer">
<div className="flex advanced-options">
<Dropdown onCreate={onDropdownCreate} icon={<AdvancedOptions />} placement="bottom-start">
<div
className="dropdown-item"
key="show-file-format"
onClick={(e) => {
dropdownTippyRef.current.hide();
setShowFileFormat(!showFileFormat);
}}
>
{showFileFormat ? 'Hide File Format' : 'Show File Format'}
</div>
</Dropdown>
</div>
<div className="flex justify-end">
<Button type="button" color="secondary" variant="ghost" onClick={onClose} className="mr-2">
Cancel
</Button>
</span>
<span>
<Button type="submit">
Create
</Button>
</span>
</div>
</div>
</form>
</Modal>

View File

@@ -16,6 +16,7 @@ import Modal from 'components/Modal';
import Help from 'components/Help';
import Dropdown from 'components/Dropdown';
import StyledWrapper from './StyledWrapper';
import { DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants';
// Extract collection name from raw data
const getCollectionName = (format, rawData) => {
@@ -92,7 +93,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
const inputRef = useRef();
const dispatch = useDispatch();
const [groupingType, setGroupingType] = useState('tags');
const [collectionFormat, setCollectionFormat] = useState('bru');
const [collectionFormat, setCollectionFormat] = useState(DEFAULT_COLLECTION_FORMAT);
const dropdownTippyRef = useRef();
const isOpenApi = format === 'openapi';

View File

@@ -1,456 +1,54 @@
import React, { useCallback, useRef } from 'react';
import React, { useCallback, useRef, 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 { 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 {
saveGlobalEnvironment,
setGlobalEnvironmentDraft,
clearGlobalEnvironmentDraft
} from 'providers/ReduxStore/slices/global-environments';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables } from 'utils/collections';
import Button from 'ui/Button';
import EnvironmentVariablesTable from 'components/EnvironmentVariablesTable';
const MIN_H = 35 * 2;
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 EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables, collection }) => {
const EnvironmentVariables = ({ environment, setIsModified, collection, searchQuery = '' }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const { globalEnvironments, activeGlobalEnvironmentUid, globalEnvironmentDraft } = useSelector(
(state) => state.globalEnvironments
);
const { globalEnvironmentDraft } = useSelector((state) => state.globalEnvironments);
const hasDraftForThisEnv = globalEnvironmentDraft?.environmentUid === environment.uid;
const [tableHeight, setTableHeight] = React.useState(MIN_H);
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;
}
// 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
}
];
}, [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 : {};
const handleSave = useCallback(
(variables) => {
return dispatch(saveGlobalEnvironment({ environmentUid: environment.uid, variables: cloneDeep(variables) }));
},
onSubmit: () => {}
});
[dispatch, environment.uid]
);
// Restore draft values on mount or environment switch
React.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 && globalEnvironmentDraft?.variables) {
formik.setValues([
...globalEnvironmentDraft.variables,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
]);
}
}, [environment.uid, hasDraftForThisEnv, globalEnvironmentDraft?.variables]);
// Sync draft state to Redux
React.useEffect(() => {
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const savedValues = environment.variables || [];
const currentValuesJson = JSON.stringify(currentValues);
const savedValuesJson = JSON.stringify(savedValues);
const hasActualChanges = currentValuesJson !== savedValuesJson;
setIsModified(hasActualChanges);
// Get existing draft for comparison
const existingDraftVariables = hasDraftForThisEnv ? globalEnvironmentDraft?.variables : null;
const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables) : null;
if (hasActualChanges) {
// Only dispatch if draft values are actually different
if (currentValuesJson !== existingDraftJson) {
dispatch(setGlobalEnvironmentDraft({
const handleDraftChange = useCallback(
(variables) => {
dispatch(
setGlobalEnvironmentDraft({
environmentUid: environment.uid,
variables: currentValues
}));
}
} else if (hasDraftForThisEnv) {
dispatch(clearGlobalEnvironmentDraft());
}
}, [formik.values, environment.variables, environment.uid, setIsModified, dispatch, hasDraftForThisEnv, globalEnvironmentDraft?.variables]);
variables
})
);
},
[dispatch, environment.uid]
);
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(saveGlobalEnvironment({ environmentUid: environment.uid, variables: 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');
});
};
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;
React.useEffect(() => {
const handleSaveEvent = () => {
handleSaveRef.current();
};
window.addEventListener('environment-save', handleSaveEvent);
return () => {
window.removeEventListener('environment-save', handleSaveEvent);
};
}, []);
const handleDraftClear = useCallback(() => {
dispatch(clearGlobalEnvironmentDraft());
}, [dispatch]);
return (
<StyledWrapper>
<TableVirtuoso
className="table-container"
style={{ height: tableHeight }}
totalListHeightChanged={handleTotalHeightChanged}
data={formik.values}
fixedItemHeight={35}
components={{ TableRow }}
computeItemKey={(index, variable) => variable.uid}
fixedHeaderContent={() => (
<tr>
<td className="text-center"></td>
<td>Name</td>
<td>Value</td>
<td className="text-center">Secret</td>
<td></td>
</tr>
)}
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>
)}
</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 mt-5">
<div className="flex items-center gap-2">
<Button type="submit" size="sm" onClick={handleSave} data-testid="save-env">
Save
</Button>
<Button type="reset" size="sm" color="secondary" variant="ghost" onClick={handleReset} data-testid="reset-env">
Reset
</Button>
</div>
</div>
</StyledWrapper>
<EnvironmentVariablesTable
environment={environment}
collection={collection}
onSave={handleSave}
draft={hasDraftForThisEnv ? globalEnvironmentDraft : null}
onDraftChange={handleDraftChange}
onDraftClear={handleDraftClear}
setIsModified={setIsModified}
searchQuery={searchQuery}
/>
);
};

View File

@@ -94,8 +94,63 @@ 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,6 +1,7 @@
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX } from '@tabler/icons';
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch } from '@tabler/icons';
import { useState, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import useDebounce from 'hooks/useDebounce';
import { renameGlobalEnvironment, updateGlobalEnvironmentColor } from 'providers/ReduxStore/slices/global-environments';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
@@ -19,7 +20,11 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
const [isRenaming, setIsRenaming] = useState(false);
const [newName, setNewName] = useState('');
const [nameError, setNameError] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const inputRef = useRef(null);
const searchInputRef = useRef(null);
const validateEnvironmentName = (name) => {
if (!name || name.trim() === '') {
@@ -111,6 +116,23 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
}
};
const handleSearchIconClick = () => {
setIsSearchExpanded(true);
setTimeout(() => {
searchInputRef.current?.focus();
}, 50);
};
const handleClearSearch = () => {
setSearchQuery('');
};
const handleSearchBlur = () => {
if (searchQuery === '') {
setIsSearchExpanded(false);
}
};
const handleColorChange = (color) => {
dispatch(updateGlobalEnvironmentColor(environment.uid, color))
.then(() => {
@@ -178,6 +200,38 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
</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>
@@ -191,7 +245,12 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
</div>
<div className="content">
<EnvironmentVariables environment={environment} setIsModified={setIsModified} collection={collection} />
<EnvironmentVariables
environment={environment}
setIsModified={setIsModified}
collection={collection}
searchQuery={debouncedSearchQuery}
/>
</div>
</StyledWrapper>
);

View File

@@ -99,12 +99,39 @@ const StyledWrapper = styled.div`
}
}
.environments-list {
.sections-container {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0 8px;
}
.environments-list {
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};
}
}
.environment-item {
position: relative;
display: flex;
@@ -228,46 +255,46 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.text};
font-size: 13px;
padding: 2px 4px;
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
}
.inline-actions {
display: flex;
gap: 2px;
margin-left: 4px;
flex-shrink: 0;
}
.inline-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
&.save {
color: ${(props) => props.theme.colors.text.green};
&:hover {
background: ${(props) => rgba(props.theme.colors.text.green, 0.1)};
}
}
.inline-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
&.save {
color: ${(props) => props.theme.colors.text.green};
&:hover {
background: ${(props) => rgba(props.theme.colors.text.green, 0.1)};
}
&.cancel {
color: ${(props) => props.theme.colors.text.danger};
&:hover {
background: ${(props) => rgba(props.theme.colors.text.danger, 0.1)};
}
}
&.cancel {
color: ${(props) => props.theme.colors.text.danger};
&:hover {
background: ${(props) => rgba(props.theme.colors.text.danger, 0.1)};
}
}
}
@@ -281,6 +308,39 @@ 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,23 +1,45 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import usePrevious from 'hooks/usePrevious';
import useOnClickOutside from 'hooks/useOnClickOutside';
import EnvironmentDetails from './EnvironmentDetails';
import CreateEnvironment from '../CreateEnvironment';
import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX } from '@tabler/icons';
import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX, IconFileAlert } from '@tabler/icons';
import Button from 'ui/Button';
import StyledWrapper from './StyledWrapper';
import ConfirmSwitchEnv from './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 { addGlobalEnvironment, renameGlobalEnvironment, selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import {
saveWorkspaceDotEnvVariables,
saveWorkspaceDotEnvRaw,
createWorkspaceDotEnvFile,
deleteWorkspaceDotEnvFile
} from 'providers/ReduxStore/slices/workspaces/actions';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
import classnames from 'classnames';
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified, collection, setShowExportModal }) => {
const EMPTY_ARRAY = [];
const EnvironmentList = ({
environments,
activeEnvironmentUid,
selectedEnvironment,
setSelectedEnvironment,
isModified,
setIsModified,
collection,
workspace,
setShowExportModal
}) => {
const dispatch = useDispatch();
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
const [openCreateModal, setOpenCreateModal] = useState(false);
const [openImportModal, setOpenImportModal] = useState(false);
const [searchText, setSearchText] = useState('');
const [isCreatingInline, setIsCreatingInline] = useState(false);
@@ -30,10 +52,38 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
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 dotEnvFiles = useSelector((state) => {
const ws = state.workspaces.workspaces.find((w) => w.uid === workspace?.uid);
return ws?.dotEnvFiles || EMPTY_ARRAY;
});
const envUids = environments ? environments.map((env) => env.uid) : [];
const prevEnvUids = usePrevious(envUids);
useEffect(() => {
if (dotEnvFiles.length === 0) {
setSelectedDotEnvFile(null);
return;
}
const fileExists = dotEnvFiles.some((f) => f.filename === selectedDotEnvFile);
if (!selectedDotEnvFile || !fileExists) {
setSelectedDotEnvFile(dotEnvFiles[0].filename);
}
}, [dotEnvFiles]);
useEffect(() => {
if (!environments?.length) {
setSelectedEnvironment(null);
@@ -79,44 +129,34 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
}
}, [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);
@@ -127,7 +167,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
}, 50);
};
const handleActivateEnvironment = (e, env) => {
const handleActivateEnvironment = useCallback((e, env) => {
e.stopPropagation();
dispatch(selectGlobalEnvironment({ environmentUid: env.uid }))
.then(() => {
@@ -136,11 +176,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
.catch(() => {
toast.error('Failed to activate environment');
});
};
if (!selectedEnvironment) {
return null;
}
}, [dispatch]);
const validateEnvironmentName = (name, excludeUid = null) => {
if (!name || name.trim() === '') {
@@ -152,8 +188,9 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
}
const trimmedName = name.toLowerCase().trim();
const isDuplicate = globalEnvs.some((env) =>
env?.uid !== excludeUid && env?.name?.toLowerCase().trim() === trimmedName);
const isDuplicate = globalEnvs?.some(
(env) => env?.uid !== excludeUid && env?.name?.toLowerCase().trim() === trimmedName
);
if (isDuplicate) {
return 'Environment already exists';
}
@@ -162,7 +199,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
};
const handleCreateEnvClick = () => {
if (!isModified) {
if (!isModified && !isDotEnvModified) {
setIsCreatingInline(true);
setNewEnvName('');
setEnvNameError('');
@@ -174,11 +211,13 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
}
};
const handleCancelCreate = () => {
const handleCancelCreate = useCallback(() => {
setIsCreatingInline(false);
setNewEnvName('');
setEnvNameError('');
};
}, []);
useOnClickOutside(createContainerRef, handleCancelCreate, isCreatingInline);
const handleSaveNewEnv = () => {
const error = validateEnvironmentName(newEnvName);
@@ -245,14 +284,16 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
});
};
const handleCancelRename = () => {
const handleCancelRename = useCallback(() => {
setRenamingEnvUid(null);
setNewEnvName('');
setEnvNameError('');
};
}, []);
useOnClickOutside(renameContainerRef, handleCancelRename, !!renamingEnvUid);
const handleImportClick = () => {
if (!isModified) {
if (!isModified && !isDotEnvModified) {
setOpenImportModal(true);
} else {
setSwitchEnvConfirmClose(true);
@@ -271,12 +312,196 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
}
};
const filteredEnvironments = environments?.filter((env) =>
env.name.toLowerCase().includes(searchText.toLowerCase())) || [];
const handleSaveDotEnv = (variables) => {
if (!selectedDotEnvFile) return Promise.reject(new Error('No file selected'));
return dispatch(saveWorkspaceDotEnvVariables(workspace.uid, variables, selectedDotEnvFile));
};
const handleSaveDotEnvRaw = (content) => {
if (!selectedDotEnvFile) return Promise.reject(new Error('No file selected'));
return dispatch(saveWorkspaceDotEnvRaw(workspace.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(createWorkspaceDotEnvFile(workspace.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(deleteWorkspaceDotEnvFile(workspace.uid, filename))
.then(() => {
toast.success(`${filename} file deleted!`);
setIsDotEnvModified(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={setIsDotEnvModified}
dotEnvExists={selectedDotEnvData?.exists}
viewMode={dotEnvViewMode}
/>
</DotEnvFileDetails>
);
}
if (selectedEnvironment) {
return (
<EnvironmentDetails
environment={selectedEnvironment}
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
/>
);
}
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 onClose={() => setOpenCreateModal(false)} />}
{openImportModal && <ImportEnvironmentModal type="global" onClose={() => setOpenImportModal(false)} />}
<div className="environments-container">
@@ -286,45 +511,113 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
</div>
)}
{/* Left Sidebar */}
<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>
<h2 className="title">Variables</h2>
</div>
<div className="search-container">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
type="text"
placeholder="Search environments..."
placeholder="Search..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="search-input"
/>
</div>
<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}>
<div className="sections-container">
<CollapsibleSection
title="Environments"
expanded={environmentsExpanded}
onToggle={() => setEnvironmentsExpanded(!environmentsExpanded)}
actions={(
<>
<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>
</>
)}
>
<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>
))}
{isCreatingInline && (
<div className="environment-item creating" ref={createContainerRef}>
<input
ref={inputRef}
type="text"
@@ -332,6 +625,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
value={newEnvName}
onChange={handleEnvNameChange}
onKeyDown={handleEnvNameKeyDown}
placeholder="Environment name..."
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
@@ -340,7 +634,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveRename}
onClick={handleSaveNewEnv}
onMouseDown={(e) => e.preventDefault()}
title="Save"
>
@@ -348,7 +642,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelRename}
onClick={handleCancelCreate}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
@@ -356,79 +650,94 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
</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>
</>
)}
{envNameError && (isCreatingInline || renamingEnvUid) && <div className="env-error">{envNameError}</div>}
{filteredEnvironments.length === 0 && !isCreatingInline && (
<div className="no-env-file">
<span>No environments</span>
</div>
)}
</div>
))}
</CollapsibleSection>
{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"
<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)}
>
<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>
<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>
)}
</div>
)}
{envNameError && (isCreatingInline || renamingEnvUid) && (
<div className="env-error">{envNameError}</div>
)}
</CollapsibleSection>
</div>
</div>
{/* Right Content */}
<EnvironmentDetails
environment={selectedEnvironment}
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
/>
{renderContent()}
</div>
</StyledWrapper>
);

View File

@@ -1,67 +1,34 @@
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import CreateEnvironment from './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 WorkspaceEnvironments = ({ workspace }) => {
const [isModified, setIsModified] = useState(false);
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
const [tab, setTab] = useState('default');
const [showExportModal, setShowExportModal] = useState(false);
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid);
if (!globalEnvironments || !globalEnvironments.length) {
return (
<StyledWrapper>
{tab === 'create' ? (
<CreateEnvironment onClose={() => setTab('default')} />
) : tab === 'import' ? (
<ImportEnvironmentModal type="global" onClose={() => setTab('default')} />
) : (
<DefaultTab setTab={setTab} />
)}
</StyledWrapper>
);
}
return (
<StyledWrapper>
<EnvironmentList
environments={globalEnvironments}
environments={globalEnvironments || []}
activeEnvironmentUid={activeGlobalEnvironmentUid}
selectedEnvironment={selectedEnvironment}
setSelectedEnvironment={setSelectedEnvironment}
isModified={isModified}
setIsModified={setIsModified}
collection={null}
workspace={workspace}
setShowExportModal={setShowExportModal}
/>
{showExportModal && (
<ExportEnvironmentModal
onClose={() => setShowExportModal(false)}
environments={globalEnvironments}
environments={globalEnvironments || []}
environmentType="global"
/>
)}

View File

@@ -1,9 +1,11 @@
// See https://usehooks.com/useOnClickOutside/
import { useEffect } from 'react';
const useOnClickOutside = (ref, handler) => {
const useOnClickOutside = (ref, handler, enabled = true) => {
useEffect(
() => {
if (!enabled) return;
const listener = (event) => {
// Do nothing if clicking ref's element or descendant elements
if (!ref.current || ref.current.contains(event.target)) {
@@ -27,7 +29,7 @@ const useOnClickOutside = (ref, handler) => {
// ... callback/cleanup to run every render. It's not a big deal ...
// ... but to optimize you can wrap handler in useCallback before ...
// ... passing it into this hook.
[ref, handler]
[ref, handler, enabled]
);
};

View File

@@ -24,11 +24,12 @@ import {
runFolderEvent,
runRequestEvent,
scriptEnvironmentUpdateEvent,
streamDataReceived
streamDataReceived,
setDotEnvVariables
} from 'providers/ReduxStore/slices/collections';
import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUiStateSnapshot, mergeAndPersistEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { workspaceOpenedEvent, workspaceConfigUpdatedEvent } from 'providers/ReduxStore/slices/workspaces/actions';
import { workspaceDotEnvUpdateEvent } from 'providers/ReduxStore/slices/workspaces';
import { workspaceDotEnvUpdateEvent, setWorkspaceDotEnvVariables } from 'providers/ReduxStore/slices/workspaces';
import toast from 'react-hot-toast';
import { useDispatch, useStore } from 'react-redux';
import { isElectron } from 'utils/common/platform';
@@ -226,6 +227,33 @@ const useIpcEvents = () => {
dispatch(workspaceEnvUpdateEvent({ processEnvVariables: val.processEnvVariables }));
});
const removeDotEnvFileUpdateListener = ipcRenderer.on('main:dotenv-file-update', (val) => {
const { type, collectionUid, workspaceUid, filename, variables, exists, processEnvVariables } = val;
if (type === 'collection' && collectionUid) {
dispatch(setDotEnvVariables({
collectionUid,
variables,
exists,
filename
}));
if (filename === '.env') {
dispatch(processEnvUpdateEvent({ collectionUid, processEnvVariables }));
}
} else if (type === 'workspace' && workspaceUid) {
dispatch(setWorkspaceDotEnvVariables({
workspaceUid,
variables,
exists,
filename
}));
if (filename === '.env') {
dispatch(workspaceDotEnvUpdateEvent(val));
dispatch(workspaceEnvUpdateEvent({ processEnvVariables }));
}
}
});
const removeConsoleLogListener = ipcRenderer.on('main:console-log', (val) => {
console[val.type](...val.args);
dispatch(addLog({
@@ -321,6 +349,7 @@ const useIpcEvents = () => {
removeRunRequestEventListener();
removeProcessEnvUpdatesListener();
removeWorkspaceDotEnvUpdatesListener();
removeDotEnvFileUpdateListener();
removeConsoleLogListener();
removeConfigUpdatesListener();
removeShowPreferencesListener();

View File

@@ -248,4 +248,16 @@ export const getSystemProxyVariables = () => (dispatch, getState) => {
});
};
export const refreshSystemProxy = () => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:refresh-system-proxy')
.then((variables) => {
dispatch(updateSystemProxyVariables(variables));
return variables;
})
.then(resolve).catch(reject);
});
};
export default appSlice.reducer;

View File

@@ -1,6 +1,6 @@
import { collectionSchema, environmentSchema, itemSchema } from '@usebruno/schema';
import { parseQueryParams, extractPromptVariables } from '@usebruno/common/utils';
import { REQUEST_TYPES } from 'utils/common/constants';
import { REQUEST_TYPES, DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants';
import cloneDeep from 'lodash/cloneDeep';
import filter from 'lodash/filter';
import find from 'lodash/find';
@@ -1909,8 +1909,8 @@ export const saveEnvironment = (variables, environmentUid, collectionUid) => (di
Modal Save writes what the user sees:
- Non-ephemeral vars are saved as-is (without metadata)
- Ephemeral vars:
- if persistedValue exists, save that (restore original value)
- otherwise filter out (don't save script-created ephemeral vars)
- if persistedValue exists, save that (explicit persisted case)
- otherwise save the current UI value (treat as user-authored)
*/
const persisted = buildPersistedEnvVariables(variables, { mode: 'save' });
environment.variables = persisted;
@@ -2642,7 +2642,7 @@ export const importCollection = (collection, collectionLocation, options = {}) =
const state = getState();
const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid);
const collectionPath = await ipcRenderer.invoke('renderer:import-collection', collection, collectionLocation, options.format || 'bru');
const collectionPath = await ipcRenderer.invoke('renderer:import-collection', collection, collectionLocation, options.format || DEFAULT_COLLECTION_FORMAT);
if (activeWorkspace && activeWorkspace.pathname && activeWorkspace.type !== 'default') {
const workspaceCollection = {
@@ -2896,3 +2896,71 @@ export const openCollectionSettings
resolve();
});
};
export const saveDotEnvVariables = (collectionUid, variables, filename = '.env') => (dispatch, getState) => {
const { ipcRenderer } = window;
return new Promise((resolve, reject) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
if (!collection) {
return reject(new Error('Collection not found'));
}
ipcRenderer
.invoke('renderer:save-dotenv-variables', collection.pathname, variables, filename)
.then(resolve)
.catch(reject);
});
};
export const saveDotEnvRaw = (collectionUid, content, filename = '.env') => (dispatch, getState) => {
const { ipcRenderer } = window;
return new Promise((resolve, reject) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
if (!collection) {
return reject(new Error('Collection not found'));
}
ipcRenderer
.invoke('renderer:save-dotenv-raw', collection.pathname, content, filename)
.then(resolve)
.catch(reject);
});
};
export const createDotEnvFile = (collectionUid, filename = '.env') => (dispatch, getState) => {
const { ipcRenderer } = window;
return new Promise((resolve, reject) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
if (!collection) {
return reject(new Error('Collection not found'));
}
ipcRenderer
.invoke('renderer:create-dotenv-file', collection.pathname, filename)
.then(resolve)
.catch(reject);
});
};
export const deleteDotEnvFile = (collectionUid, filename = '.env') => (dispatch, getState) => {
const { ipcRenderer } = window;
return new Promise((resolve, reject) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
if (!collection) {
return reject(new Error('Collection not found'));
}
ipcRenderer
.invoke('renderer:delete-dotenv-file', collection.pathname, filename)
.then(resolve)
.catch(reject);
});
};

View File

@@ -66,6 +66,51 @@ const wsStatusCodes = {
1015: 'TLS_HANDSHAKE'
};
/**
* Preserves UIDs from existing array items when merging with new data.
* UIDs are matched by position to keep React keys stable after file reloads.
*/
const preserveUidsAtPaths = (existing, updated, paths) => {
if (!existing || !updated) return updated;
const merged = cloneDeep(updated);
paths.forEach((path) => {
const newArray = get(merged, path);
const existingArray = get(existing, path, []);
if (Array.isArray(newArray) && newArray.length) {
set(
merged,
path,
newArray.map((item, i) => (existingArray[i]?.uid ? { ...item, uid: existingArray[i].uid } : item))
);
}
});
return merged;
};
// Paths containing arrays with UIDs that need preservation
const REQUEST_UID_PATHS = [
'params',
'headers',
'vars.req',
'vars.res',
'assertions',
'body.formUrlEncoded',
'body.multipartForm',
'body.file'
];
const ROOT_UID_PATHS = ['request.headers', 'request.vars.req', 'request.vars.res'];
const mergeRequestWithPreservedUids = (existingRequest, newRequest) =>
preserveUidsAtPaths(existingRequest, newRequest, REQUEST_UID_PATHS);
const mergeRootWithPreservedUids = (existingRoot, newRoot) =>
preserveUidsAtPaths(existingRoot, newRoot, ROOT_UID_PATHS);
const initialState = {
collections: [],
collectionSortOrder: 'default',
@@ -425,6 +470,37 @@ export const collectionsSlice = createSlice({
collection.workspaceProcessEnvVariables = processEnvVariables;
});
},
setDotEnvVariables: (state, action) => {
const { collectionUid, variables, exists, filename = '.env' } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
if (!collection.dotEnvFiles) {
collection.dotEnvFiles = [];
}
const existingIndex = collection.dotEnvFiles.findIndex((f) => f.filename === filename);
if (existingIndex >= 0) {
if (exists) {
collection.dotEnvFiles[existingIndex] = { filename, variables, exists };
} else {
collection.dotEnvFiles.splice(existingIndex, 1);
}
} else if (exists) {
collection.dotEnvFiles.push({ filename, variables, exists });
}
collection.dotEnvFiles.sort((a, b) => {
if (a.filename === '.env') return -1;
if (b.filename === '.env') return 1;
return a.filename.localeCompare(b.filename);
});
const mainEnvFile = collection.dotEnvFiles.find((f) => f.filename === '.env');
collection.dotEnvVariables = mainEnvFile?.variables || [];
collection.dotEnvExists = mainEnvFile?.exists || false;
}
},
requestCancelled: (state, action) => {
const { itemUid, collectionUid, seq, timestamp } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
@@ -2561,7 +2637,7 @@ export const collectionsSlice = createSlice({
const collection = findCollectionByUid(state.collections, file.meta.collectionUid);
if (isCollectionRoot) {
if (collection) {
collection.root = file.data;
collection.root = mergeRootWithPreservedUids(collection.root, file.data);
}
return;
}
@@ -2573,7 +2649,7 @@ export const collectionsSlice = createSlice({
if (file?.data?.meta?.name) {
folderItem.name = file?.data?.meta?.name;
}
folderItem.root = file.data;
folderItem.root = mergeRootWithPreservedUids(folderItem.root, file.data);
if (file?.data?.meta?.seq) {
folderItem.seq = file.data?.meta?.seq;
}
@@ -2621,7 +2697,7 @@ export const collectionsSlice = createSlice({
currentItem.type = file.data.type;
currentItem.seq = file.data.seq;
currentItem.tags = file.data.tags;
currentItem.request = file.data.request;
currentItem.request = mergeRequestWithPreservedUids(currentItem.request, file.data.request);
currentItem.filename = file.meta.name;
currentItem.pathname = file.meta.pathname;
currentItem.settings = file.data.settings;
@@ -2700,7 +2776,7 @@ export const collectionsSlice = createSlice({
const collection = findCollectionByUid(state.collections, file.meta.collectionUid);
if (isCollectionRoot) {
if (collection) {
collection.root = file.data;
collection.root = mergeRootWithPreservedUids(collection.root, file.data);
}
return;
}
@@ -2715,7 +2791,7 @@ export const collectionsSlice = createSlice({
if (file?.data?.meta?.seq) {
folderItem.seq = file?.data?.meta?.seq;
}
folderItem.root = file.data;
folderItem.root = mergeRootWithPreservedUids(folderItem.root, file.data);
}
return;
}
@@ -2740,7 +2816,7 @@ export const collectionsSlice = createSlice({
item.type = file.data.type;
item.seq = file.data.seq;
item.tags = file.data.tags;
item.request = file.data.request;
item.request = mergeRequestWithPreservedUids(item.request, file.data.request);
item.settings = file.data.settings;
item.examples = file.data.examples;
item.filename = file.meta.name;
@@ -3485,6 +3561,7 @@ export const {
scriptEnvironmentUpdateEvent,
processEnvUpdateEvent,
workspaceEnvUpdateEvent,
setDotEnvVariables,
requestCancelled,
responseReceived,
runGrpcRequestEvent,

View File

@@ -760,3 +760,83 @@ export const importWorkspaceAction = (zipFilePath, extractLocation) => {
}
};
};
export const saveWorkspaceDotEnvVariables = (workspaceUid, variables, filename = '.env') => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const state = getState();
const workspace = state.workspaces.workspaces.find((w) => w.uid === workspaceUid);
if (!workspace) {
return reject(new Error('Workspace not found'));
}
if (!workspace.pathname) {
return reject(new Error('Workspace path not found'));
}
ipcRenderer
.invoke('renderer:save-workspace-dotenv-variables', { workspacePath: workspace.pathname, variables, filename })
.then(resolve)
.catch(reject);
});
};
export const saveWorkspaceDotEnvRaw = (workspaceUid, content, filename = '.env') => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const state = getState();
const workspace = state.workspaces.workspaces.find((w) => w.uid === workspaceUid);
if (!workspace) {
return reject(new Error('Workspace not found'));
}
if (!workspace.pathname) {
return reject(new Error('Workspace path not found'));
}
ipcRenderer
.invoke('renderer:save-workspace-dotenv-raw', { workspacePath: workspace.pathname, content, filename })
.then(resolve)
.catch(reject);
});
};
export const createWorkspaceDotEnvFile = (workspaceUid, filename = '.env') => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const state = getState();
const workspace = state.workspaces.workspaces.find((w) => w.uid === workspaceUid);
if (!workspace) {
return reject(new Error('Workspace not found'));
}
if (!workspace.pathname) {
return reject(new Error('Workspace path not found'));
}
ipcRenderer
.invoke('renderer:create-workspace-dotenv-file', { workspacePath: workspace.pathname, filename })
.then(resolve)
.catch(reject);
});
};
export const deleteWorkspaceDotEnvFile = (workspaceUid, filename = '.env') => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const state = getState();
const workspace = state.workspaces.workspaces.find((w) => w.uid === workspaceUid);
if (!workspace) {
return reject(new Error('Workspace not found'));
}
if (!workspace.pathname) {
return reject(new Error('Workspace path not found'));
}
ipcRenderer
.invoke('renderer:delete-workspace-dotenv-file', { workspacePath: workspace.pathname, filename })
.then(resolve)
.catch(reject);
});
};

View File

@@ -84,6 +84,38 @@ export const workspacesSlice = createSlice({
if (workspace) {
workspace.processEnvVariables = processEnvVariables;
}
},
setWorkspaceDotEnvVariables: (state, action) => {
const { workspaceUid, variables, exists, filename = '.env' } = action.payload;
const workspace = state.workspaces.find((w) => w.uid === workspaceUid);
if (workspace) {
if (!workspace.dotEnvFiles) {
workspace.dotEnvFiles = [];
}
const existingIndex = workspace.dotEnvFiles.findIndex((f) => f.filename === filename);
if (existingIndex >= 0) {
if (exists) {
workspace.dotEnvFiles[existingIndex] = { filename, variables, exists };
} else {
workspace.dotEnvFiles.splice(existingIndex, 1);
}
} else if (exists) {
workspace.dotEnvFiles.push({ filename, variables, exists });
}
workspace.dotEnvFiles.sort((a, b) => {
if (a.filename === '.env') return -1;
if (b.filename === '.env') return 1;
return a.filename.localeCompare(b.filename);
});
const mainEnvFile = workspace.dotEnvFiles.find((f) => f.filename === '.env');
workspace.dotEnvVariables = mainEnvFile?.variables || [];
workspace.dotEnvExists = mainEnvFile?.exists || false;
}
}
}
});
@@ -96,7 +128,8 @@ export const {
addCollectionToWorkspace,
removeCollectionFromWorkspace,
updateWorkspaceLoadingState,
workspaceDotEnvUpdateEvent
workspaceDotEnvUpdateEvent,
setWorkspaceDotEnvVariables
} = workspacesSlice.actions;
export default workspacesSlice.reducer;

View File

@@ -0,0 +1,65 @@
import React, { useState } from 'react';
import { IconChevronRight, IconChevronLeft } from '@tabler/icons';
const SubMenuItem = ({
item,
onRootClose,
submenuPlacement,
getMenuItemProps,
renderMenuItemContent,
MenuDropdownComponent
}) => {
const [submenuOpen, setSubmenuOpen] = useState(false);
const isLeftPlacement = submenuPlacement === 'left';
const submenuTippyPlacement = isLeftPlacement ? 'left-start' : 'right-start';
const ArrowIcon = isLeftPlacement ? IconChevronLeft : IconChevronRight;
const submenuItemsWithClose = item.submenu.map((subItem) => {
if (subItem.type === 'divider') return subItem;
return {
...subItem,
onClick: () => {
subItem.onClick?.();
onRootClose();
}
};
});
const itemProps = getMenuItemProps(item, {
'className': 'has-submenu',
'aria-haspopup': 'true',
'aria-expanded': submenuOpen,
'aria-current': undefined // submenu triggers don't need aria-current
});
const arrowElement = (
<span className="submenu-arrow">
<ArrowIcon size={14} />
</span>
);
return (
<div
className="submenu-trigger"
onMouseEnter={() => setSubmenuOpen(true)}
onMouseLeave={() => setSubmenuOpen(false)}
>
<MenuDropdownComponent
items={submenuItemsWithClose}
placement={submenuTippyPlacement}
opened={submenuOpen}
onChange={setSubmenuOpen}
showTickMark={false}
submenuPlacement={submenuPlacement}
appendTo={() => document.body}
offset={[0, 0]}
>
<div {...itemProps}>
{renderMenuItemContent(item, arrowElement)}
</div>
</MenuDropdownComponent>
</div>
);
};
export default SubMenuItem;

View File

@@ -1,5 +1,6 @@
import React, { forwardRef, useRef, useCallback, useState, useImperativeHandle, useEffect, useMemo } from 'react';
import Dropdown from 'components/Dropdown';
import SubMenuItem from './SubMenuItem';
// Constants
const NAVIGATION_KEYS = ['ArrowDown', 'ArrowUp', 'Home', 'End', 'Escape'];
@@ -31,6 +32,7 @@ const getNextIndex = (currentIndex, total, key, noFocus) => {
* - testId: string (optional, for testing, for items only)
* - disabled: boolean (optional, for items only)
* - className: string (optional, additional CSS classes for the item)
* - submenu: Array (optional, array of menu items for nested submenu, opens on hover)
*
* Grouped format: [{name: string, options: [{id, label, ...}]}, ...]
* Flat format: [{id, label, ...}, ...]
@@ -46,6 +48,7 @@ const getNextIndex = (currentIndex, total, key, noFocus) => {
* @param {boolean} props.showGroupDividers - Optional flag to show dividers between groups in grouped format (default: true)
* @param {string} props.groupStyle - Style for grouped items: 'action' (default, normal case) or 'select' (uppercase labels, indented items)
* @param {boolean} props.autoFocusFirstOption - Optional flag to auto-focus first option when dropdown opens (default: false)
* @param {string} props.submenuPlacement - Placement of submenus: 'right' (default) or 'left'. Controls both position and arrow direction.
* @param {Object} props.dropdownProps - Other props passed to underlying Dropdown component
* @param {React.Ref} ref - Optional ref to expose open/close methods
*/
@@ -63,6 +66,7 @@ const MenuDropdown = forwardRef(({
showGroupDividers = true,
groupStyle = 'action',
autoFocusFirstOption = false,
submenuPlacement = 'right',
'data-testid': testId = 'menu-dropdown',
...dropdownProps
}, ref) => {
@@ -264,7 +268,11 @@ const MenuDropdown = forwardRef(({
}, [isOpen, updateOpenState]);
// Close dropdown when clicking outside
const handleClickOutside = useCallback(() => {
const handleClickOutside = useCallback((instance, event) => {
// Don't close if clicking inside a submenu (another tippy popper)
if (event?.target?.closest?.('[data-tippy-root]')) {
return;
}
updateOpenState(false);
}, [updateOpenState]);
@@ -346,39 +354,75 @@ const MenuDropdown = forwardRef(({
return section;
};
// Render menu item
const renderMenuItem = (item) => {
// Get common props for menu items (shared between regular items and submenu triggers)
const getMenuItemProps = (item, extraProps = {}) => {
const selectIndentClass = item.groupStyle === 'select' ? 'dropdown-item-select' : '';
const isActive = item.id === selectedItemId;
const activeClass = isActive ? 'dropdown-item-active' : '';
// Destructure className from extraProps to avoid it being overwritten by spread
const { className: extraClassName, ...restExtraProps } = extraProps;
return {
'className': `dropdown-item ${item.disabled ? 'disabled' : ''} ${selectIndentClass} ${activeClass} ${extraClassName || ''} ${item.className || ''}`.trim(),
'role': 'menuitem',
'data-item-id': item.id,
'tabIndex': item.disabled ? -1 : 0,
'aria-label': item.ariaLabel,
'aria-disabled': item.disabled,
'aria-current': isActive ? 'true' : undefined,
'title': item.title,
'data-testid': `${testId}-${String(item.id).toLowerCase()}`,
...restExtraProps
};
};
// Render the content inside a menu item (leftSection, label, and rightSection/arrow)
const renderMenuItemContent = (item, rightContent = null) => (
<>
{renderSection(item.leftSection)}
<span className="dropdown-label">{item.label}</span>
{rightContent}
</>
);
// Render menu item
const renderMenuItem = (item) => {
if (item.submenu) {
return (
<SubMenuItem
key={item.id}
item={item}
onRootClose={() => updateOpenState(false)}
submenuPlacement={submenuPlacement}
getMenuItemProps={getMenuItemProps}
renderMenuItemContent={renderMenuItemContent}
MenuDropdownComponent={MenuDropdown}
/>
);
}
const itemProps = getMenuItemProps(item);
const rightContent = item.rightSection ? (
<div
className="dropdown-right-section"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{renderSection(item.rightSection)}
</div>
) : null;
return (
<div
key={item.id}
className={`dropdown-item ${item.disabled ? 'disabled' : ''} ${selectIndentClass} ${activeClass} ${item.className || ''}`.trim()}
role="menuitem"
data-item-id={item.id}
{...itemProps}
onClick={() => !item.disabled && handleItemClick(item)}
tabIndex={item.disabled ? -1 : 0}
aria-label={item.ariaLabel}
aria-disabled={item.disabled}
aria-current={isActive ? 'true' : undefined}
title={item.title}
data-testid={`${testId}-${String(item.id).toLowerCase()}`}
>
{renderSection(item.leftSection)}
<span className="dropdown-label">{item.label}</span>
{item.rightSection && (
<div
className="dropdown-right-section"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{renderSection(item.rightSection)}
</div>
)}
{renderMenuItemContent(item, rightContent)}
</div>
);
};

View File

@@ -76,6 +76,8 @@ const STATIC_API_HINTS = {
'bru.setNextRequest(requestName)',
'bru.getRequestVar(key)',
'bru.runRequest(requestPathName)',
'bru.sendRequest(requestConfig)',
'bru.sendRequest(requestConfig, callback)',
'bru.getAssertionResults()',
'bru.getTestResults()',
'bru.sleep(ms)',

View File

@@ -1 +1,3 @@
export const REQUEST_TYPES = ['http-request', 'graphql-request', 'grpc-request', 'ws-request'];
export const DEFAULT_COLLECTION_FORMAT = 'yml';

View File

@@ -12,31 +12,24 @@ const toPersistedEnvVarForMerge = (persistedNames) => (v) => {
return rest;
};
const isPersistableEnvVarForSave = (v) => {
if (!v) return false;
return !v.ephemeral || v.persistedValue !== undefined;
};
const toPersistedEnvVarForSave = (v) => {
const { ephemeral, persistedValue, ...rest } = v || {};
return v?.ephemeral ? (persistedValue !== undefined ? { ...rest, value: persistedValue } : rest) : rest;
};
// mode 'save': filters out ephemeral vars without persistedValue (script-created, never on disk)
// mode 'merge': same as 'save', but also includes ephemeral vars explicitly persisted this run
/*
High-level builder for persisted variables
- mode 'save': write what the user sees
- mode 'merge': write only allowed vars (non-ephemeral, ephemerals with persistedValue, or explicitly persisted this run)
*/
export const buildPersistedEnvVariables = (variables, { mode, persistedNames } = {}) => {
const src = Array.isArray(variables) ? variables : [];
if (mode === 'merge') {
const names = persistedNames instanceof Set ? persistedNames : new Set();
return src
.filter(isPersistableEnvVarForMerge(names))
.map(toPersistedEnvVarForMerge(names));
return src.filter(isPersistableEnvVarForMerge(names)).map(toPersistedEnvVarForMerge(names));
}
// default to save mode
return src
.filter(isPersistableEnvVarForSave)
.map(toPersistedEnvVarForSave);
return src.map(toPersistedEnvVarForSave);
};
export const buildEnvVariable = ({ envVariable: obj, withUuid = false }) => {

View File

@@ -18,6 +18,7 @@ const constants = require('../constants');
const { findItemInCollection, createCollectionJsonFromPathname, getCallStack, FORMAT_CONFIG } = require('../utils/collection');
const { hasExecutableTestInScript } = require('../utils/request');
const { createSkippedFileResults } = require('../utils/run');
const { getSystemProxy } = require('@usebruno/requests');
const command = 'run [paths...]';
const desc = 'Run one or more requests/folders';
@@ -374,7 +375,7 @@ const handler = async function (argv) {
result.__name__ = nameOverride || path.basename(filePath, fileExt);
} else {
const content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
const envJson = parseEnvironment(content);
const envJson = parseEnvironment(content, { format: 'bru' });
result = getEnvVars(envJson);
result.__name__ = nameOverride || path.basename(filePath, '.bru');
}
@@ -608,6 +609,15 @@ const handler = async function (argv) {
const runtime = getJsSandboxRuntime(sandbox);
// Fetch system proxy once for all requests (skip if --noproxy flag is set)
if (!noproxy) {
try {
options['cachedSystemProxy'] = await getSystemProxy();
} catch (error) {
console.warn(chalk.yellow('Failed to detect system proxy, continuing without system proxy'));
}
}
const runSingleRequestByPathname = async (relativeItemPathname) => {
const ext = FORMAT_CONFIG[collection.format].ext;
return new Promise(async (resolve, reject) => {

View File

@@ -15,7 +15,6 @@ const { SocksProxyAgent } = require('socks-proxy-agent');
const { makeAxiosInstance } = require('../utils/axios-instance');
const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-helper');
const { shouldUseProxy, PatchedHttpsProxyAgent } = require('../utils/proxy-util');
const { getSystemProxy } = require('@usebruno/requests');
const path = require('path');
const { parseDataFromResponse } = require('../utils/common');
const { getCookieStringForUrl, saveCookies } = require('../utils/cookies');
@@ -171,6 +170,37 @@ const runSingleRequest = async function (
const scriptingConfig = get(brunoConfig, 'scripts', {});
scriptingConfig.runtime = runtime;
// Build certsAndProxyConfig for bru.sendRequest
const options = getOptions();
const systemProxyConfig = options['cachedSystemProxy'];
const sendRequestInterpolationOptions = {
envVars: envVariables,
runtimeVariables,
processEnvVars,
globalEnvVars,
collectionVariables: request.collectionVariables || {},
folderVariables: request.folderVariables || {},
requestVariables: request.requestVariables || {}
};
const rawClientCertificates = get(brunoConfig, 'clientCertificates');
const rawProxyConfig = get(brunoConfig, 'proxy', {});
const certsAndProxyConfig = {
collectionPath,
options: {
noproxy: get(options, 'noproxy', false),
shouldVerifyTls: !get(options, 'insecure', false),
shouldUseCustomCaCertificate: !!options['cacert'],
customCaCertificateFilePath: options['cacert'],
shouldKeepDefaultCaCertificates: !options['ignoreTruststore']
},
clientCertificates: rawClientCertificates ? interpolateObject(rawClientCertificates, sendRequestInterpolationOptions) : undefined,
collectionLevelProxy: transformProxyConfig(interpolateObject(rawProxyConfig, sendRequestInterpolationOptions)),
systemProxyConfig
};
// Add certsAndProxyConfig to request object for bru.sendRequest
request.certsAndProxyConfig = certsAndProxyConfig;
// run pre request script
const requestScriptFile = get(request, 'script.req');
const collectionName = collection?.brunoConfig?.name;
@@ -238,9 +268,9 @@ const runSingleRequest = async function (
request.url = `http://${request.url}`;
}
const options = getOptions();
const insecure = get(options, 'insecure', false);
const noproxy = get(options, 'noproxy', false);
const cachedSystemProxy = get(options, 'cachedSystemProxy', null);
const httpsAgentRequestFields = {};
if (insecure) {
@@ -310,10 +340,11 @@ const runSingleRequest = async function (
proxyMode = 'on';
} else if (!collectionProxyDisabled && collectionProxyInherit) {
// Inherit from system proxy
const systemProxy = await getSystemProxy();
const { http_proxy, https_proxy } = systemProxy;
if (http_proxy?.length || https_proxy?.length) {
proxyMode = 'system';
if (cachedSystemProxy) {
const { http_proxy, https_proxy } = cachedSystemProxy;
if (http_proxy?.length || https_proxy?.length) {
proxyMode = 'system';
}
}
// else: no system proxy available, proxyMode stays 'off'
}
@@ -357,8 +388,7 @@ const runSingleRequest = async function (
}
} else if (proxyMode === 'system') {
try {
const systemProxy = await getSystemProxy();
const { http_proxy, https_proxy, no_proxy } = systemProxy;
const { http_proxy, https_proxy, no_proxy } = cachedSystemProxy || {};
const shouldUseSystemProxy = shouldUseProxy(request.url, no_proxy || '');
const parsedUrl = new URL(request.url);
const isHttpsRequest = parsedUrl.protocol === 'https:';
@@ -505,7 +535,7 @@ const runSingleRequest = async function (
const proxyConfig = get(brunoConfig, 'proxy');
const interpolatedClientCertificates = clientCertificates ? interpolateObject(clientCertificates, oauth2InterpolationOptions) : undefined;
const interpolatedProxyConfig = proxyConfig ? interpolateObject(proxyConfig, oauth2InterpolationOptions) : undefined;
const systemProxyConfig = await getSystemProxy();
const systemProxyConfig = cachedSystemProxy;
const { httpAgent: oauth2HttpAgent, httpsAgent: oauth2HttpsAgent } = await getHttpHttpsAgents({
requestUrl: oauth2RequestUrl,

View File

@@ -3,9 +3,10 @@ info:
name: Test OpenCollection
extensions:
ignore:
- node_modules
- .git
bruno:
ignore:
- node_modules
- .git
request:
headers:

View File

@@ -435,6 +435,8 @@ const populateRequestBody = ({ body, bodySchema, contentType }) => {
* @returns {Object} Bruno example object
*/
const createBrunoExample = ({ brunoRequestItem, exampleValue, exampleName, exampleDescription, statusCode, contentType, requestBodySchema = null, requestBodyContentType = null }) => {
const sanitized = String(exampleName ?? '').replace(/\r?\n/g, ' ').trim();
const name = sanitized || `${statusCode} Response`;
// Deep copy the body to avoid shared references
const bodyCopy = {
mode: brunoRequestItem.request.body.mode,
@@ -449,7 +451,7 @@ const createBrunoExample = ({ brunoRequestItem, exampleValue, exampleName, examp
const brunoExample = {
uid: uuid(),
itemUid: brunoRequestItem.uid,
name: exampleName,
name,
description: exampleDescription,
type: 'http-request',
request: {

View File

@@ -204,6 +204,9 @@ export interface BrunoConfig {
passphrase?: string;
}>;
};
scripts?: {
additionalContextRoots?: string[];
};
}
export interface BrunoCollectionRoot {

View File

@@ -572,7 +572,8 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
brunoRequestItem.examples = [];
i.response.forEach((response, responseIndex) => {
const exampleName = response.name || `Example ${responseIndex + 1}`;
const sanitized = String(response.name ?? '').replace(/\r?\n/g, ' ').trim();
const exampleName = sanitized || `Example ${responseIndex + 1}`;
// Convert originalRequest to Bruno request format
const originalRequest = response.originalRequest || {};

View File

@@ -16,30 +16,22 @@ const {
parseCollection,
parseFolder
} = require('@usebruno/filestore');
const { parseDotEnv } = require('@usebruno/filestore');
const { uuid } = require('../utils/common');
const { getRequestUid } = require('../cache/requestUids');
const { decryptStringSafe } = require('../utils/encryption');
const { setDotEnvVars } = require('../store/process-env');
const { setBrunoConfig } = require('../store/bruno-config');
const EnvironmentSecretsStore = require('../store/env-secrets');
const UiStateSnapshot = require('../store/ui-state-snapshot');
const { parseFileMeta, hydrateRequestWithUuid } = require('../utils/collection');
const { parseLargeRequestWithRedaction } = require('../utils/parse');
const { transformBrunoConfigAfterRead } = require('../utils/transformBrunoConfig');
const dotEnvWatcher = require('./dotenv-watcher');
const MAX_FILE_SIZE = 2.5 * 1024 * 1024;
const environmentSecretsStore = new EnvironmentSecretsStore();
const isDotEnvFile = (pathname, collectionPath) => {
const dirname = path.dirname(pathname);
const basename = path.basename(pathname);
return dirname === collectionPath && basename === '.env';
};
const isBrunoConfigFile = (pathname, collectionPath) => {
const dirname = path.dirname(pathname);
const basename = path.basename(pathname);
@@ -227,24 +219,6 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
}
}
if (isDotEnvFile(pathname, collectionPath)) {
try {
const content = fs.readFileSync(pathname, 'utf8');
const jsonData = parseDotEnv(content);
setDotEnvVars(collectionUid, jsonData);
const payload = {
collectionUid,
processEnvVariables: {
...jsonData
}
};
win.webContents.send('main:process-env-update', payload);
} catch (err) {
console.error(err);
}
}
if (isEnvironmentsFolder(pathname, collectionPath)) {
return addEnvironmentFile(win, pathname, collectionUid, collectionPath);
}
@@ -470,26 +444,6 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
return;
}
if (isDotEnvFile(pathname, collectionPath)) {
try {
const content = fs.readFileSync(pathname, 'utf8');
const jsonData = parseDotEnv(content);
setDotEnvVars(collectionUid, jsonData);
const payload = {
collectionUid,
processEnvVariables: {
...jsonData
}
};
win.webContents.send('main:process-env-update', payload);
} catch (err) {
console.error(err);
}
return;
}
if (isEnvironmentsFolder(pathname, collectionPath)) {
return changeEnvironmentFile(win, pathname, collectionUid, collectionPath);
}
@@ -759,6 +713,12 @@ class CollectionWatcher {
ignored: (filepath) => {
const normalizedPath = normalizeAndResolvePath(filepath);
const relativePath = path.relative(watchPath, normalizedPath);
const basename = path.basename(filepath);
// Ignore .env files - handled by dotenv-watcher
if (basename === '.env' || basename.startsWith('.env.')) {
return true;
}
// Check if any path segment matches a default ignore pattern (handles symlinks)
const pathSegments = relativePath.split(path.sep);
@@ -811,6 +771,8 @@ class CollectionWatcher {
});
this.watchers[watchPath] = watcher;
dotEnvWatcher.addCollectionWatcher(win, watchPath, collectionUid);
}, 100);
}
@@ -824,6 +786,8 @@ class CollectionWatcher {
this.watchers[watchPath] = null;
}
dotEnvWatcher.removeCollectionWatcher(watchPath);
const tempDirectoryPath = this.tempDirectoryMap[watchPath];
if (tempDirectoryPath && this.watchers[tempDirectoryPath]) {
this.watchers[tempDirectoryPath].close();

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