Compare commits

..

1 Commits

Author SHA1 Message Date
Sid
09bfcee2ec Update React coding standards in CODING_STANDARDS.md
Added new coding standards for React components and hooks usage.
2026-01-28 20:00:19 +05:30
217 changed files with 2458 additions and 12250 deletions

View File

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

View File

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

View File

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

7
.prettierrc.json Normal file
View File

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

2
package-lock.json generated
View File

@@ -35669,4 +35669,4 @@
}
}
}
}
}

View File

@@ -60,6 +60,7 @@
"dev:watch": "node ./scripts/dev-hot-reload.js",
"dev:web": "npm run dev --workspace=packages/bruno-app",
"build:web": "npm run build --workspace=packages/bruno-app",
"prettier:web": "npm run prettier --workspace=packages/bruno-app",
"dev:electron": "npm run dev --workspace=packages/bruno-electron",
"dev:electron:debug": "npm run debug --workspace=packages/bruno-electron",
"storybook": "npm run storybook --workspace=packages/bruno-app",
@@ -82,6 +83,7 @@
"test:codegen": "node playwright/codegen.ts",
"test:e2e": "playwright test --project=default",
"test:e2e:ssl": "playwright test --project=ssl",
"test:prettier:web": "npm run test:prettier --workspace=packages/bruno-app",
"lint": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" npx eslint",
"lint:fix": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" npx eslint --fix",
"prepare": "husky"
@@ -102,4 +104,4 @@
"dependencies": {
"ajv": "^8.17.1"
}
}
}

View File

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

View File

@@ -8,6 +8,8 @@
"build": "rsbuild build -m production",
"preview": "rsbuild preview",
"test": "jest",
"test:prettier": "prettier --check \"./src/**/*.{js,jsx,json,ts,tsx}\"",
"prettier": "prettier --write \"./src/**/*.{js,jsx,json,ts,tsx}\"",
"storybook": "storybook dev -p 6006 --config-dir storybook",
"build-storybook": "storybook build --config-dir storybook"
},
@@ -130,4 +132,4 @@
"form-data": "4.0.4"
}
}
}
}

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,6 @@ import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace';
import ImportWorkspace from 'components/WorkspaceSidebar/ImportWorkspace';
import IconBottombarToggle from 'components/Icons/IconBottombarToggle/index';
import AppMenu from './AppMenu';
import StyledWrapper from './StyledWrapper';
import ResponseLayoutToggle from 'components/ResponsePane/ResponseLayoutToggle';
import { isMacOS, isWindowsOS, isLinuxOS } from 'utils/common/platform';
@@ -248,9 +247,8 @@ const AppTitleBar = () => {
)}
<div className="titlebar-content">
{/* Left section: Home + Workspace */}
<div className="titlebar-left">
{showWindowControls && <AppMenu />}
<ActionIcon onClick={handleHomeClick} label="Home" size="lg" className="home-button">
<IconHome size={16} stroke={1.5} />
</ActionIcon>

View File

@@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
import React, { createRef } from 'react';
import React from 'react';
import { isEqual, escapeRegExp } from 'lodash';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { setupAutoComplete, showRootHints } from 'utils/codemirror/autocomplete';
@@ -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/index';
import CodeMirrorSearch from 'components/CodeMirrorSearch';
const CodeMirror = require('codemirror');
window.jsonlint = jsonlint;
@@ -34,7 +34,6 @@ export default class CodeEditor extends React.Component {
this.cachedValue = props.value || '';
this.variables = {};
this.searchResultsCountElementId = 'search-results-count';
this.searchBarRef = createRef();
this.lintOptions = {
esversion: 11,
@@ -95,14 +94,14 @@ export default class CodeEditor extends React.Component {
}
},
'Cmd-F': (cm) => {
this.setState({ searchBarVisible: true }, () => {
this.searchBarRef.current?.focus();
});
if (!this.state.searchBarVisible) {
this.setState({ searchBarVisible: true });
}
},
'Ctrl-F': (cm) => {
this.setState({ searchBarVisible: true }, () => {
this.searchBarRef.current?.focus();
});
if (!this.state.searchBarVisible) {
this.setState({ searchBarVisible: true });
}
},
'Cmd-H': 'replace',
'Ctrl-H': 'replace',
@@ -310,10 +309,6 @@ export default class CodeEditor extends React.Component {
fontSize={this.props.fontSize}
>
<CodeMirrorSearch
ref={(node) => {
if (!node) return;
this.searchBarRef.current = node;
}}
visible={this.state.searchBarVisible}
editor={this.editor}
onClose={() => this.setState({ searchBarVisible: false })}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { IconRegex, IconArrowUp, IconArrowDown, IconX, IconLetterCase, IconLetterW } from '@tabler/icons';
import ToolHint from 'components/ToolHint';
import StyledWrapper from './StyledWrapper';
@@ -8,7 +8,7 @@ function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');
}
const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
const CodeMirrorSearch = ({ visible, editor, onClose }) => {
const [searchText, setSearchText] = useState('');
const [regex, setRegex] = useState(false);
const [caseSensitive, setCaseSensitive] = useState(false);
@@ -19,7 +19,6 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
const searchMarks = useRef([]);
const searchLineHighlight = useRef(null);
const searchMatches = useRef([]);
const inputRef = useRef(null);
const debouncedSearchText = useDebounce(searchText, 150);
@@ -107,14 +106,6 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
}
}, [debouncedSearchText, regex, caseSensitive, wholeWord, editor, memoizedMatches]);
useImperativeHandle(ref, () => ({
focus: () => {
if (inputRef.current) {
inputRef.current.focus();
}
}
}));
useEffect(() => {
doSearch(0, debouncedSearchText);
}, [debouncedSearchText, doSearch]);
@@ -177,7 +168,6 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
<StyledWrapper>
<div className="bruno-search-bar">
<input
ref={inputRef}
autoFocus
type="text"
value={searchText}
@@ -206,6 +196,6 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
</div>
</StyledWrapper>
);
});
};
export default CodeMirrorSearch;

View File

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

View File

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

View File

@@ -1,24 +0,0 @@
import React from 'react';
import { useTheme } from 'providers/Theme';
const ColorBadge = ({ color, size = 10, showEmptyBorder = true }) => {
const sizeValue = typeof size === 'string' ? size : `${size}px`;
const { theme } = useTheme();
const showBorder = !color && showEmptyBorder;
return (
<div
className="flex-shrink-0 rounded-full"
style={{
width: sizeValue,
height: sizeValue,
backgroundColor: color || 'transparent',
border: showBorder ? '1px solid' : 'none',
borderColor: showBorder ? theme.background.surface1 : 'transparent'
}}
/>
);
};
export default ColorBadge;

View File

@@ -1,7 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
`;
export default StyledWrapper;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -173,18 +173,6 @@ const Wrapper = styled.div`
background-color: ${(props) => props.theme.dropdown.separator};
margin: 0.25rem 0;
}
.submenu-trigger {
position: relative;
}
.submenu-arrow {
color: ${(props) => props.theme.dropdown.mutedText};
flex-shrink: 0;
display: flex;
align-items: center;
margin-left: auto;
}
`;
export default Wrapper;

View File

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

View File

@@ -6,13 +6,8 @@ const StyledWrapper = styled.div`
flex: 1;
overflow: hidden;
&.is-resizing {
cursor: col-resize !important;
user-select: none;
}
.table-container {
overflow: auto;
overflow-y: auto;
border-radius: ${(props) => props.theme.border.radius.base};
border: solid 1px ${(props) => props.theme.border.border0};
}
@@ -29,7 +24,6 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.table.thead.color} !important;
background: ${(props) => props.theme.sidebar.bg};
user-select: none;
overflow: visible;
border: none !important;
@@ -40,36 +34,10 @@ const StyledWrapper = styled.div`
border-bottom: solid 1px ${(props) => props.theme.border.border0};
border-right: solid 1px ${(props) => props.theme.border.border0};
vertical-align: middle;
position: relative;
overflow: visible;
&:last-child {
border-right: none;
}
.column-name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-right: 4px;
}
.resize-handle {
position: absolute;
right: 0;
top: 0;
width: 4px;
height: 100%;
cursor: col-resize;
background: transparent;
z-index: 100;
&:hover,
&.resizing {
background: ${(props) => props.theme.colors.accent};
}
}
}
}
@@ -93,32 +61,10 @@ const StyledWrapper = styled.div`
border-bottom: solid 1px ${(props) => props.theme.border.border0};
border-right: solid 1px ${(props) => props.theme.border.border0};
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:last-child {
border-right: none;
}
/* Handle CodeMirror editors overflow */
.cm-editor {
max-width: 100%;
.cm-scroller {
overflow: hidden !important;
}
.cm-content {
max-width: 100%;
}
.cm-line {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
}

View File

@@ -1,11 +1,9 @@
import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { IconTrash, IconAlertCircle, IconGripVertical, IconMinusVertical } from '@tabler/icons';
import { Tooltip } from 'react-tooltip';
import { uuid } from 'utils/common';
import StyledWrapper from './StyledWrapper';
const MIN_COLUMN_WIDTH = 80;
const EditableTable = ({
columns,
rows,
@@ -25,101 +23,7 @@ const EditableTable = ({
const tableRef = useRef(null);
const emptyRowUidRef = useRef(null);
const [hoveredRow, setHoveredRow] = useState(null);
const [resizing, setResizing] = useState(null);
const [tableHeight, setTableHeight] = useState(0);
const [columnWidths, setColumnWidths] = useState(() => {
const initialWidths = {};
columns.forEach((col) => {
initialWidths[col.key] = col.width || 'auto';
});
return initialWidths;
});
const handleResizeStart = useCallback((e, columnKey) => {
e.preventDefault();
e.stopPropagation();
const currentCell = e.target.closest('td');
const nextCell = currentCell?.nextElementSibling;
if (!currentCell || !nextCell) return;
const columnIndex = columns.findIndex((col) => col.key === columnKey);
if (columnIndex >= columns.length - 1) return;
const startX = e.clientX;
const startWidth = currentCell.offsetWidth;
const nextColumnKey = columns[columnIndex + 1].key;
const nextColumnStartWidth = nextCell.offsetWidth;
setResizing(columnKey);
const handleMouseMove = (moveEvent) => {
const diff = moveEvent.clientX - startX;
const maxGrow = nextColumnStartWidth - MIN_COLUMN_WIDTH;
const maxShrink = startWidth - MIN_COLUMN_WIDTH;
const clampedDiff = Math.max(-maxShrink, Math.min(maxGrow, diff));
setColumnWidths((prev) => ({
...prev,
[columnKey]: `${startWidth + clampedDiff}px`,
[nextColumnKey]: `${nextColumnStartWidth - clampedDiff}px`
}));
};
const handleMouseUp = () => {
// Convert pixel widths to percentages for responsive scaling
const table = tableRef.current?.querySelector('table');
if (table) {
const tableWidth = table.offsetWidth;
const headerCells = table.querySelectorAll('thead td');
const newWidths = {};
headerCells.forEach((cell, cellIndex) => {
const checkboxOffset = showCheckbox ? 1 : 0;
const colIndex = cellIndex - checkboxOffset;
if (colIndex >= 0 && colIndex < columns.length) {
const colKey = columns[colIndex]?.key;
if (colKey) {
const percentage = (cell.offsetWidth / tableWidth) * 100;
newWidths[colKey] = `${percentage}%`;
}
}
});
if (Object.keys(newWidths).length > 0) {
setColumnWidths((prev) => ({ ...prev, ...newWidths }));
}
}
setResizing(null);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}, [columns, showCheckbox]);
// Track table height for resize handles
useEffect(() => {
const table = tableRef.current?.querySelector('table');
if (!table) return;
const updateHeight = () => {
setTableHeight(table.offsetHeight);
};
updateHeight();
const resizeObserver = new ResizeObserver(updateHeight);
resizeObserver.observe(table);
return () => resizeObserver.disconnect();
}, [rows.length]);
const getColumnWidth = useCallback((column) => {
return columnWidths[column.key] || column.width || 'auto';
}, [columnWidths]);
const [dragStart, setDragStart] = useState(null);
const createEmptyRow = useCallback(() => {
const newUid = uuid();
@@ -234,9 +138,15 @@ const EditableTable = ({
onChange(filteredRows);
}, [rows, onChange]);
const getColumnWidth = useCallback((column) => {
if (column.width) return column.width;
return 'auto';
}, []);
const handleDragStart = useCallback((e, index) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', index);
setDragStart(index);
}, []);
const handleDragOver = useCallback((e, index) => {
@@ -252,17 +162,15 @@ const EditableTable = ({
const reorderableRows = showAddRow ? rowsWithEmpty.slice(0, -1) : rowsWithEmpty;
const updatedOrder = [...reorderableRows];
const [movedRow] = updatedOrder.splice(fromIndex, 1);
if (!movedRow) {
setHoveredRow(null);
return;
}
updatedOrder.splice(toIndex, 0, movedRow);
onReorder({ updateReorderedItem: updatedOrder.map((row) => row.uid) });
}
setDragStart(null);
setHoveredRow(null);
}, [onReorder, rowsWithEmpty, showAddRow]);
const handleDragEnd = useCallback(() => {
setDragStart(null);
setHoveredRow(null);
}, []);
@@ -312,7 +220,7 @@ const EditableTable = ({
className="mousetrap"
value={value || ''}
readOnly={column.readOnly}
placeholder={!value ? column.placeholder || column.name : ''}
placeholder={isEmpty ? column.placeholder || column.name : ''}
onChange={(e) => handleValueChange(row.uid, column.key, e.target.value)}
/>
{errorIcon}
@@ -323,7 +231,7 @@ const EditableTable = ({
const reorderableRowCount = showAddRow ? rowsWithEmpty.length - 1 : rowsWithEmpty.length;
return (
<StyledWrapper className={`${showCheckbox ? 'has-checkbox' : 'no-checkbox'} ${resizing ? 'is-resizing' : ''}`}>
<StyledWrapper className={showCheckbox ? 'has-checkbox' : 'no-checkbox'}>
<div className="table-container" ref={tableRef} data-testid={testId}>
<table>
<thead>
@@ -331,19 +239,12 @@ const EditableTable = ({
{showCheckbox && (
<td className="text-center">{checkboxLabel}</td>
)}
{columns.map((column, colIndex) => (
{columns.map((column) => (
<td
key={column.key}
style={{ width: getColumnWidth(column) }}
>
<span className="column-name">{column.name}</span>
{colIndex < columns.length - 1 && (
<div
className={`resize-handle ${resizing === column.key ? 'resizing' : ''}`}
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
onMouseDown={(e) => handleResizeStart(e, column.key)}
/>
)}
{column.name}
</td>
))}
{showDelete && (

View File

@@ -1,546 +0,0 @@
import React, { useCallback, useRef, useState, useEffect, useMemo } from 'react';
import { TableVirtuoso } from 'react-virtuoso';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useSelector } from 'react-redux';
import MultiLineEditor from 'components/MultiLineEditor/index';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { variableNameRegex } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables } from 'utils/collections';
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,340 +0,0 @@
import React, { useCallback, useRef, useMemo, useEffect, useState } from 'react';
import { useTheme } from 'providers/Theme';
import { uuid } from 'utils/common';
import { useFormik } from 'formik';
import { variableNameRegex } from 'utils/common/regex';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import DotEnvTableView from './DotEnvTableView';
import DotEnvRawView from './DotEnvRawView';
import DotEnvEmptyState from './DotEnvEmptyState';
import { variablesToRaw, rawToVariables, MIN_TABLE_HEIGHT } from './utils';
const DotEnvFileEditor = ({
variables,
onSave,
onSaveRaw,
isModified,
setIsModified,
dotEnvExists,
rawContent,
viewMode = 'table',
collection,
item
}) => {
const { displayedTheme } = useTheme();
const [tableHeight, setTableHeight] = useState(MIN_TABLE_HEIGHT);
// Derive a single baseline raw value for consistent dirty-tracking
const baselineRaw = rawContent ?? variablesToRaw(variables || []);
const initialRawValue = baselineRaw;
const [rawValue, setRawValue] = useState(initialRawValue);
const [prevViewMode, setPrevViewMode] = useState(viewMode);
const [isSaving, setIsSaving] = useState(false);
const formikRef = useRef(null);
const initialValues = useMemo(() => {
const vars = (variables || []).map((v) => ({
...v,
uid: v.uid || uuid()
}));
return [
...vars,
{
uid: uuid(),
name: '',
value: ''
}
];
}, [variables]);
const formik = useFormik({
enableReinitialize: true,
initialValues: initialValues,
validate: (values) => {
const errors = {};
values.forEach((variable, index) => {
const isLastRow = index === values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
if (isLastRow && isEmptyRow) {
return;
}
if (!variable.name || variable.name.trim() === '') {
if (!errors[index]) errors[index] = {};
errors[index].name = 'Name cannot be empty';
} else if (!variableNameRegex.test(variable.name)) {
if (!errors[index]) errors[index] = {};
errors[index].name
= 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.';
}
});
return Object.keys(errors).length > 0 ? errors : {};
},
onSubmit: () => {}
});
formikRef.current = formik;
// Sync raw value with external changes
useEffect(() => {
setRawValue(baselineRaw);
}, [baselineRaw]);
// Handle view mode switching
useEffect(() => {
if (viewMode !== prevViewMode) {
if (viewMode === 'raw' && prevViewMode === 'table') {
const currentVars = formikRef.current.values.filter((v) => v.name && v.name.trim() !== '');
const newRawValue = variablesToRaw(currentVars);
setRawValue(newRawValue);
} else if (viewMode === 'table' && prevViewMode === 'raw') {
const parsedVars = rawToVariables(rawValue);
const newValues = [
...parsedVars,
{ uid: uuid(), name: '', value: '' }
];
formikRef.current.setValues(newValues);
}
setPrevViewMode(viewMode);
}
}, [viewMode, prevViewMode, rawValue]);
const normalizeForComparison = (vars) => {
return vars
.filter((v) => v.name && v.name.trim() !== '')
.map(({ name, value }) => ({ name, value: value || '' }));
};
const savedValuesJson = useMemo(() => {
return JSON.stringify(normalizeForComparison(variables || []));
}, [variables]);
useEffect(() => {
if (viewMode === 'raw') {
const hasRawChanges = rawValue !== baselineRaw;
setIsModified(hasRawChanges);
} else {
const currentValuesJson = JSON.stringify(normalizeForComparison(formik.values));
const hasActualChanges = currentValuesJson !== savedValuesJson;
setIsModified(hasActualChanges);
}
}, [formik.values, savedValuesJson, setIsModified, viewMode, rawValue, baselineRaw]);
// Ref for stable formik.values access
const valuesRef = useRef(formik.values);
valuesRef.current = formik.values;
const handleRemoveVar = useCallback((id) => {
const currentValues = valuesRef.current;
if (!currentValues || currentValues.length === 0) {
return;
}
const lastRow = currentValues[currentValues.length - 1];
const isLastEmptyRow = lastRow?.uid === id && (!lastRow.name || lastRow.name.trim() === '');
if (isLastEmptyRow) {
return;
}
const filteredValues = currentValues.filter((variable) => variable.uid !== id);
const hasEmptyLastRow
= filteredValues.length > 0
&& (!filteredValues[filteredValues.length - 1].name
|| filteredValues[filteredValues.length - 1].name.trim() === '');
const newValues = hasEmptyLastRow
? filteredValues
: [
...filteredValues,
{ uid: uuid(), name: '', value: '' }
];
formikRef.current.setValues(newValues);
}, []);
const handleNameChange = useCallback((index, e) => {
formik.handleChange(e);
const isLastRow = index === valuesRef.current.length - 1;
if (isLastRow) {
const newVariable = { uid: uuid(), name: '', value: '' };
setTimeout(() => {
formik.setValues((prev) => {
const lastRow = prev[prev.length - 1];
if (lastRow?.name?.trim()) {
return [...prev, newVariable];
}
return prev;
});
}, 0);
}
}, []);
const handleNameBlur = useCallback((index) => {
formik.setFieldTouched(`${index}.name`, true, true);
}, []);
const handleNameKeyDown = useCallback((index, e) => {
if (e.key === 'Enter') {
e.preventDefault();
formik.setFieldTouched(`${index}.name`, true, true);
}
}, []);
const handleSave = useCallback(() => {
if (isSaving) return;
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const hasValidationErrors = variablesToSave.some((variable) => {
if (!variable.name || variable.name.trim() === '') {
return true;
}
if (!variableNameRegex.test(variable.name)) {
return true;
}
return false;
});
if (hasValidationErrors) {
toast.error('Please fix validation errors before saving');
return;
}
setIsSaving(true);
onSave(variablesToSave)
.then(() => {
toast.success('Changes saved successfully');
const newValues = [
...variablesToSave,
{ uid: uuid(), name: '', value: '' }
];
formik.resetForm({ values: newValues });
setIsModified(false);
})
.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

@@ -1,59 +0,0 @@
import { uuid } from 'utils/common';
export const variablesToRaw = (variables) => {
return variables
.filter((v) => v.name && v.name.trim() !== '')
.map((v) => {
const value = v.value || '';
if (value.includes('\n') || value.includes('"') || value.includes('\'')) {
const escapedValue = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
return `${v.name}="${escapedValue}"`;
}
return `${v.name}=${value}`;
})
.join('\n');
};
export const rawToVariables = (rawContent) => {
if (!rawContent || rawContent.trim() === '') {
return [];
}
const variables = [];
const lines = rawContent.split('\n');
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine.startsWith('#')) {
continue;
}
const equalIndex = trimmedLine.indexOf('=');
if (equalIndex === -1) {
continue;
}
const name = trimmedLine.substring(0, equalIndex).trim();
let value = trimmedLine.substring(equalIndex + 1);
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
value = value.slice(1, -1);
value = value.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}
if (name) {
variables.push({
uid: uuid(),
name,
value,
enabled: true,
secret: false
});
}
}
return variables;
};
export const MIN_TABLE_HEIGHT = 35 * 2;

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { IconPlus, IconDownload, IconSettings } from '@tabler/icons';
import ToolHint from 'components/ToolHint';
import ColorBadge from 'components/ColorBadge';
const EnvironmentListContent = ({
environments,
@@ -39,7 +38,6 @@ const EnvironmentListContent = ({
data-tooltip-content={env.name}
data-tooltip-hidden={env.name?.length < 90}
>
<ColorBadge color={env.color} size={8} showEmptyBorder={false} />
<span className="max-w-100% truncate no-wrap">{env.name}</span>
</div>
))}

View File

@@ -33,7 +33,8 @@ const Wrapper = styled.div`
}
.env-separator {
background-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.separator};
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.separator};
margin: 0 0.35rem;
}
.env-text-inactive {

View File

@@ -13,166 +13,6 @@ import ImportEnvironmentModal from 'components/Environments/Common/ImportEnviron
import CreateGlobalEnvironment from 'components/WorkspaceHome/WorkspaceEnvironments/CreateEnvironment';
import ToolHint from 'components/ToolHint';
import StyledWrapper from './StyledWrapper';
import { transparentize, toColorString, parseToRgb } from 'polished';
const TABS = [
{ id: 'collection', label: 'Collection', icon: <IconDatabase size={16} strokeWidth={1.5} /> },
{ id: 'global', label: 'Global', icon: <IconWorld size={16} strokeWidth={1.5} /> }
];
const EMPTY_STATE_DESCRIPTIONS = {
collection: 'Create your first environment to begin working with your collection.',
global: 'Create your first global environment to begin working across collections.'
};
/**
* Generates background color with transparency for environment badges
*/
const getEnvBackgroundColor = (color) => (color ? transparentize(1 - 0.12, color) : 'transparent');
/**
* Calculates the style for an environment badge section
*/
const getEnvBadgeStyle = (environment, position, hasOtherEnv) => {
const color = environment?.color;
const isLeft = position === 'left';
// Determine border radius based on position and whether other env exists
let borderRadius = '0.3rem';
if (hasOtherEnv) {
borderRadius = isLeft ? '0.3rem 0 0 0.3rem' : '0 0.3rem 0.3rem 0';
}
// Determine padding based on position
const padding = isLeft
? hasOtherEnv
? '0.25rem 0.5rem 0.25rem 0.5rem'
: '0.25rem 0.3rem 0.25rem 0.5rem'
: '0.25rem 0.3rem 0.25rem 0.5rem';
return {
backgroundColor: getEnvBackgroundColor(color),
padding,
borderRadius
};
};
/**
* Calculates dropdown width based on longest environment name
*/
const calculateDropdownWidth = (environments, globalEnvironments) => {
const allEnvironments = [...environments, ...globalEnvironments];
if (allEnvironments.length === 0) return 0;
const maxCharLength = Math.max(...allEnvironments.map((env) => env.name?.length || 0));
// 8 pixels per character (rough estimate for average character width)
return maxCharLength * 8;
};
/**
* Displays a single environment with icon, name, and optional color styling
*/
const EnvironmentBadge = ({ environment, icon: Icon }) => {
if (!environment) return null;
const colorStyle = environment.color ? { color: environment.color } : {};
return (
<>
<Icon size={14} strokeWidth={1.5} className="env-icon" style={colorStyle} />
<ToolHint
text={environment.name}
toolhintId={`env-${environment.uid}`}
place="bottom-start"
delayShow={1000}
hidden={environment.name?.length < 7}
>
<span className="env-text max-w-24 truncate overflow-hidden" style={colorStyle}>
{environment.name}
</span>
</ToolHint>
</>
);
};
/**
* Dropdown trigger component showing active environments
*/
const DropdownTrigger = forwardRef(({ collectionEnv, globalEnv }, ref) => {
const hasAnyEnv = collectionEnv || globalEnv;
// Empty state - no environments selected
if (!hasAnyEnv) {
return (
<div
ref={ref}
className="current-environment flex align-center justify-center cursor-pointer bg-transparent no-environments"
data-testid="environment-selector-trigger"
>
<span className="env-text-inactive max-w-36 truncate no-wrap">No Environment</span>
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
</div>
);
}
// Only collection env selected - caret goes with collection env
if (collectionEnv && !globalEnv) {
return (
<div
ref={ref}
className="current-environment flex align-center justify-center cursor-pointer bg-transparent"
style={{ padding: 0 }}
data-testid="environment-selector-trigger"
>
<div className="flex items-center" style={getEnvBadgeStyle(collectionEnv, 'left', false)}>
<EnvironmentBadge environment={collectionEnv} icon={IconDatabase} />
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
</div>
</div>
);
}
// Only global env selected - caret goes with global env
if (!collectionEnv && globalEnv) {
return (
<div
ref={ref}
className="current-environment flex align-center justify-center cursor-pointer bg-transparent"
style={{ padding: 0 }}
data-testid="environment-selector-trigger"
>
<div className="flex items-center" style={getEnvBadgeStyle(globalEnv, 'right', false)}>
<EnvironmentBadge environment={globalEnv} icon={IconWorld} />
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
</div>
</div>
);
}
// Both environments selected
return (
<div
ref={ref}
className="current-environment flex align-center justify-center cursor-pointer bg-transparent"
style={{ padding: 0 }}
data-testid="environment-selector-trigger"
>
{/* Collection Environment Section */}
<div className="flex items-center" style={getEnvBadgeStyle(collectionEnv, 'left', true)}>
<EnvironmentBadge environment={collectionEnv} icon={IconDatabase} />
</div>
{/* Separator */}
<div className="env-separator" style={{ width: '1px', alignSelf: 'stretch' }} />
{/* Global Environment Section + Caret */}
<div className="flex items-center" style={getEnvBadgeStyle(globalEnv, 'right', true)}>
<EnvironmentBadge environment={globalEnv} icon={IconWorld} />
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
</div>
</div>
);
});
const EnvironmentSelector = ({ collection }) => {
const dispatch = useDispatch();
@@ -195,82 +35,159 @@ const EnvironmentSelector = ({ collection }) => {
? find(environments, (e) => e.uid === activeEnvironmentUid)
: null;
const dropdownWidth = useMemo(
() => calculateDropdownWidth(environments, globalEnvironments),
[environments, globalEnvironments]
);
const tabs = [
{ id: 'collection', label: 'Collection', icon: <IconDatabase size={16} strokeWidth={1.5} /> },
{ id: 'global', label: 'Global', icon: <IconWorld size={16} strokeWidth={1.5} /> }
];
const description = EMPTY_STATE_DESCRIPTIONS[activeTab];
const onDropdownCreate = (ref) => {
dropdownTippyRef.current = ref;
};
const hideDropdown = () => dropdownTippyRef.current?.hide();
// Get description based on active tab
const description
= activeTab === 'collection'
? 'Create your first environment to begin working with your collection.'
: 'Create your first global environment to begin working across collections.';
// Environment selection handler
const handleEnvironmentSelect = (environment) => {
const action
= activeTab === 'collection'
? selectEnvironment(environment?.uid || null, collection.uid)
: selectGlobalEnvironment({ environmentUid: environment?.uid || null });
? selectEnvironment(environment ? environment.uid : null, collection.uid)
: selectGlobalEnvironment({ environmentUid: environment ? environment.uid : null });
dispatch(action)
.then(() => {
toast.success(environment ? `Environment changed to ${environment.name}` : 'No Environments are active now');
hideDropdown();
if (environment) {
toast.success(`Environment changed to ${environment.name}`);
} else {
toast.success('No Environments are active now');
}
dropdownTippyRef.current.hide();
})
.catch(() => {
.catch((err) => {
toast.error('An error occurred while selecting the environment');
});
};
// Settings handler - opens environment settings tab
const handleSettingsClick = () => {
const isCollection = activeTab === 'collection';
dispatch(
addTab({
uid: `${collection.uid}-${isCollection ? 'environment' : 'global-environment'}-settings`,
collectionUid: collection.uid,
type: isCollection ? 'environment-settings' : 'global-environment-settings'
})
);
hideDropdown();
if (activeTab === 'collection') {
dispatch(
addTab({
uid: `${collection.uid}-environment-settings`,
collectionUid: collection.uid,
type: 'environment-settings'
})
);
} else {
dispatch(
addTab({
uid: `${collection.uid}-global-environment-settings`,
collectionUid: collection.uid,
type: 'global-environment-settings'
})
);
}
dropdownTippyRef.current.hide();
};
// Create handler
const handleCreateClick = () => {
if (activeTab === 'collection') {
setShowCreateCollectionModal(true);
} else {
setShowCreateGlobalModal(true);
}
hideDropdown();
dropdownTippyRef.current.hide();
};
// Import handler
const handleImportClick = () => {
if (activeTab === 'collection') {
setShowImportCollectionModal(true);
} else {
setShowImportGlobalModal(true);
}
hideDropdown();
dropdownTippyRef.current.hide();
};
const openEnvironmentSettingsTab = (type) => {
dispatch(
addTab({
uid: `${collection.uid}-${type}-settings`,
collectionUid: collection.uid,
type: `${type}-settings`
})
// Calculate dropdown width based on the longest environment name.
// To prevent resizing while switching between collection and global environments.
const dropdownWidth = useMemo(() => {
const allEnvironments = [...environments, ...globalEnvironments];
if (allEnvironments.length === 0) return 0;
const maxCharLength = Math.max(...allEnvironments.map((env) => env.name?.length || 0));
// 8 pixels per character: This is a rough estimate for the average character width in most fonts
// (monospace fonts are typically 8-10px, proportional fonts vary but 8px is a safe average)
return maxCharLength * 8;
}, [environments, globalEnvironments]);
// Create icon component for dropdown trigger
const Icon = forwardRef((props, ref) => {
const hasAnyEnv = activeGlobalEnvironment || activeCollectionEnvironment;
const displayContent = hasAnyEnv ? (
<>
{activeCollectionEnvironment && (
<>
<div className="flex items-center">
<IconDatabase size={14} strokeWidth={1.5} className="env-icon" />
<ToolHint
text={activeCollectionEnvironment.name}
toolhintId={`collection-env-${activeCollectionEnvironment.uid}`}
place="bottom-start"
delayShow={1000}
hidden={activeCollectionEnvironment.name?.length < 7}
>
<span className="env-text max-w-24 truncate overflow-hidden">{activeCollectionEnvironment.name}</span>
</ToolHint>
</div>
{activeGlobalEnvironment && <span className="env-separator">|</span>}
</>
)}
{activeGlobalEnvironment && (
<div className="flex items-center">
<IconWorld size={14} strokeWidth={1.5} className="env-icon" />
<ToolHint
text={activeGlobalEnvironment.name}
toolhintId={`global-env-${activeGlobalEnvironment.uid}`}
place="bottom-start"
delayShow={1000}
hidden={activeGlobalEnvironment.name?.length < 7}
>
<span className="env-text max-w-24 truncate overflow-hidden">{activeGlobalEnvironment.name}</span>
</ToolHint>
</div>
)}
</>
) : (
<span className="env-text-inactive max-w-36 truncate no-wrap">No Environment</span>
);
};
return (
<div
ref={ref}
className={`current-environment flex align-center justify-center cursor-pointer bg-transparent ${
!hasAnyEnv ? 'no-environments' : ''
}`}
data-testid="environment-selector-trigger"
>
{displayContent}
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
</div>
);
});
return (
<StyledWrapper width={dropdownWidth}>
<div className="environment-selector flex align-center cursor-pointer">
<Dropdown
onCreate={(ref) => (dropdownTippyRef.current = ref)}
icon={<DropdownTrigger collectionEnv={activeCollectionEnvironment} globalEnv={activeGlobalEnvironment} />}
placement="bottom-end"
>
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
{/* Tab Headers */}
<div className="tab-header flex pt-3 pb-2 px-3">
{TABS.map((tab) => (
{tabs.map((tab) => (
<button
key={tab.id}
className={`tab-button whitespace-nowrap pb-[0.375rem] border-b-[0.125rem] bg-transparent flex align-center cursor-pointer transition-all duration-200 mr-[1.25rem] ${
@@ -305,7 +222,15 @@ const EnvironmentSelector = ({ collection }) => {
{showCreateGlobalModal && (
<CreateGlobalEnvironment
onClose={() => setShowCreateGlobalModal(false)}
onEnvironmentCreated={() => openEnvironmentSettingsTab('global-environment')}
onEnvironmentCreated={() => {
dispatch(
addTab({
uid: `${collection.uid}-global-environment-settings`,
collectionUid: collection.uid,
type: 'global-environment-settings'
})
);
}}
/>
)}
@@ -313,7 +238,15 @@ const EnvironmentSelector = ({ collection }) => {
<ImportEnvironmentModal
type="global"
onClose={() => setShowImportGlobalModal(false)}
onEnvironmentCreated={() => openEnvironmentSettingsTab('global-environment')}
onEnvironmentCreated={() => {
dispatch(
addTab({
uid: `${collection.uid}-global-environment-settings`,
collectionUid: collection.uid,
type: 'global-environment-settings'
})
);
}}
/>
)}
@@ -321,7 +254,15 @@ const EnvironmentSelector = ({ collection }) => {
<CreateEnvironment
collection={collection}
onClose={() => setShowCreateCollectionModal(false)}
onEnvironmentCreated={() => openEnvironmentSettingsTab('environment')}
onEnvironmentCreated={() => {
dispatch(
addTab({
uid: `${collection.uid}-environment-settings`,
collectionUid: collection.uid,
type: 'environment-settings'
})
);
}}
/>
)}
@@ -330,7 +271,15 @@ const EnvironmentSelector = ({ collection }) => {
type="collection"
collection={collection}
onClose={() => setShowImportCollectionModal(false)}
onEnvironmentCreated={() => openEnvironmentSettingsTab('environment')}
onEnvironmentCreated={() => {
dispatch(
addTab({
uid: `${collection.uid}-environment-settings`,
collectionUid: collection.uid,
type: 'environment-settings'
})
);
}}
/>
)}
</StyledWrapper>

View File

@@ -1,15 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
button.submit {
color: white;
background-color: var(--color-background-danger) !important;
border: inherit !important;
&:hover {
border: inherit !important;
}
}
`;
export default Wrapper;

View File

@@ -1,30 +0,0 @@
import React from 'react';
import Portal from 'components/Portal/index';
import Modal from 'components/Modal/index';
import StyledWrapper from './StyledWrapper';
const DeleteDotEnvFile = ({ onClose, onConfirm, filename = '.env' }) => {
const handleConfirm = () => {
onConfirm();
onClose();
};
return (
<Portal>
<StyledWrapper>
<Modal
size="sm"
title={`Delete ${filename} File`}
confirmText="Delete"
handleConfirm={handleConfirm}
handleCancel={onClose}
confirmButtonColor="danger"
>
Are you sure you want to delete <span className="font-medium">{filename}</span> file?
</Modal>
</StyledWrapper>
</Portal>
);
};
export default DeleteDotEnvFile;

View File

@@ -6,11 +6,6 @@ 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;
@@ -37,6 +32,10 @@ const Wrapper = styled.div`
&:nth-child(5) {
width: 60px;
}
&:nth-child(2) {
width: 30%;
}
}
thead {
@@ -49,26 +48,10 @@ 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};
}
}
}
}
@@ -164,6 +147,21 @@ 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

@@ -1,20 +1,57 @@
import React, { useMemo, useCallback } from 'react';
import React, { useCallback, useRef, useMemo, useEffect } from 'react';
import { TableVirtuoso } from 'react-virtuoso';
import cloneDeep from 'lodash/cloneDeep';
import { get } from 'lodash';
import { useDispatch } from 'react-redux';
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import MultiLineEditor from 'components/MultiLineEditor/index';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { variableNameRegex } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { setEnvironmentsDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
import { flattenItems, isItemARequest } from 'utils/collections';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables, flattenItems, isItemARequest } from 'utils/collections';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import EnvironmentVariablesTable from 'components/EnvironmentVariablesTable';
import { sensitiveFields } from './constants';
const EnvironmentVariables = ({ environment, setIsModified, collection, searchQuery = '' }) => {
const TableRow = React.memo(({ children, item }) => <tr key={item.uid} data-testid={`env-var-row-${item.name}`}>{children}</tr>, (prevProps, nextProps) => {
const prevUid = prevProps?.item?.uid;
const nextUid = nextProps?.item?.uid;
return prevUid === nextUid && prevProps.children === nextProps.children;
});
const MIN_H = 35 * 2; // 2 rows worth of height
const EnvironmentVariables = ({ environment, setIsModified, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const [tableHeight, setTableHeight] = React.useState(MIN_H);
const environmentsDraft = collection?.environmentsDraft;
const hasDraftForThisEnv = environmentsDraft?.environmentUid === environment.uid;
const handleTotalHeightChanged = React.useCallback((h) => {
setTableHeight(h);
}, []);
// Track environment changes for draft restoration
const prevEnvUidRef = React.useRef(null);
const mountedRef = React.useRef(false);
let _collection = collection ? cloneDeep(collection) : {};
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
if (_collection) {
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
}
// Check for non-secret variables used in sensitive fields
const nonSecretSensitiveVarUsageMap = useMemo(() => {
const result = {};
@@ -60,59 +97,428 @@ const EnvironmentVariables = ({ environment, setIsModified, collection, searchQu
return result;
}, [collection, environment]);
const hasSensitiveUsage = useCallback((name) => !!nonSecretSensitiveVarUsageMap[name], [nonSecretSensitiveVarUsageMap]);
const hasSensitiveUsage = (name) => !!nonSecretSensitiveVarUsageMap[name];
const handleSave = useCallback(
(variables) => {
return dispatch(saveEnvironment(cloneDeep(variables), environment.uid, collection.uid));
},
[dispatch, environment.uid, collection.uid]
);
const handleDraftChange = useCallback(
(variables) => {
dispatch(
setEnvironmentsDraft({
collectionUid: collection.uid,
environmentUid: environment.uid,
variables
})
);
},
[dispatch, collection.uid, environment.uid]
);
const handleDraftClear = useCallback(() => {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
}, [dispatch, collection.uid]);
const renderExtraValueContent = useCallback(
(variable) => {
if (!variable.secret && hasSensitiveUsage(variable.name)) {
return (
<SensitiveFieldWarning
fieldName={variable.name}
warningMessage="This variable is used in sensitive fields. Mark it as a secret for security"
/>
);
// Initial values based only on saved environment variables (not draft)
// Draft restoration happens in a separate effect to avoid infinite loops
const initialValues = React.useMemo(() => {
const vars = environment.variables || [];
return [
...vars,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
return null;
];
}, [environment.uid, environment.variables]);
const formik = useFormik({
enableReinitialize: true,
initialValues: initialValues,
validationSchema: Yup.array().of(
Yup.object({
enabled: Yup.boolean(),
name: Yup.string()
.when('$isLastRow', {
is: true,
then: (schema) => schema.optional(),
otherwise: (schema) =>
schema
.required('Name cannot be empty')
.matches(
variableNameRegex,
'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.'
)
.trim()
}),
secret: Yup.boolean(),
type: Yup.string(),
uid: Yup.string(),
value: Yup.mixed().nullable()
})
),
validate: (values) => {
const errors = {};
values.forEach((variable, index) => {
const isLastRow = index === values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
if (isLastRow && isEmptyRow) {
return;
}
if (!variable.name || variable.name.trim() === '') {
if (!errors[index]) errors[index] = {};
errors[index].name = 'Name cannot be empty';
} else if (!variableNameRegex.test(variable.name)) {
if (!errors[index]) errors[index] = {};
errors[index].name
= 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.';
}
});
return Object.keys(errors).length > 0 ? errors : {};
},
[hasSensitiveUsage]
);
onSubmit: () => {}
});
// Restore draft values on mount or environment switch
useEffect(() => {
const isMount = !mountedRef.current;
const envChanged = prevEnvUidRef.current !== null && prevEnvUidRef.current !== environment.uid;
prevEnvUidRef.current = environment.uid;
mountedRef.current = true;
if ((isMount || envChanged) && hasDraftForThisEnv && environmentsDraft?.variables) {
formik.setValues([
...environmentsDraft.variables,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
]);
}
}, [environment.uid, hasDraftForThisEnv, environmentsDraft?.variables]);
const savedValuesJson = useMemo(() => {
return JSON.stringify(environment.variables || []);
}, [environment.variables]);
useEffect(() => {
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const currentValuesJson = JSON.stringify(currentValues);
const hasActualChanges = currentValuesJson !== savedValuesJson;
setIsModified(hasActualChanges);
}, [formik.values, savedValuesJson, setIsModified]);
useEffect(() => {
const timeoutId = setTimeout(() => {
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const currentValuesJson = JSON.stringify(currentValues);
const hasActualChanges = currentValuesJson !== savedValuesJson;
// Get existing draft for comparison
const existingDraftVariables = hasDraftForThisEnv ? environmentsDraft?.variables : null;
const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables) : null;
if (hasActualChanges) {
// Only dispatch if draft values are actually different
if (currentValuesJson !== existingDraftJson) {
dispatch(setEnvironmentsDraft({
collectionUid: collection.uid,
environmentUid: environment.uid,
variables: currentValues
}));
}
} else if (hasDraftForThisEnv) {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
}
}, 300);
return () => clearTimeout(timeoutId);
}, [formik.values, savedValuesJson, environment.uid, collection.uid, dispatch, hasDraftForThisEnv, environmentsDraft?.variables]);
const ErrorMessage = ({ name, index }) => {
const meta = formik.getFieldMeta(name);
const id = `error-${name}-${index}`;
const isLastRow = index === formik.values.length - 1;
const variable = formik.values[index];
const isEmptyRow = !variable?.name || variable.name.trim() === '';
if (isLastRow && isEmptyRow) {
return null;
}
if (!meta.error || !meta.touched) {
return null;
}
return (
<span>
<IconAlertCircle id={id} className="text-red-600 cursor-pointer" size={20} />
<Tooltip className="tooltip-mod" anchorId={id} html={meta.error || ''} />
</span>
);
};
const handleRemoveVar = useCallback((id) => {
const currentValues = formik.values;
if (!currentValues || currentValues.length === 0) {
return;
}
const lastRow = currentValues[currentValues.length - 1];
const isLastEmptyRow = lastRow?.uid === id && (!lastRow.name || lastRow.name.trim() === '');
if (isLastEmptyRow) {
return;
}
const filteredValues = currentValues.filter((variable) => variable.uid !== id);
const hasEmptyLastRow
= filteredValues.length > 0
&& (!filteredValues[filteredValues.length - 1].name
|| filteredValues[filteredValues.length - 1].name.trim() === '');
const newValues = hasEmptyLastRow
? filteredValues
: [
...filteredValues,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
formik.setValues(newValues);
}, [formik.values]);
const handleNameChange = (index, e) => {
formik.handleChange(e);
const isLastRow = index === formik.values.length - 1;
if (isLastRow) {
const newVariable = {
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
};
setTimeout(() => {
formik.setFieldValue(formik.values.length, newVariable, false);
}, 0);
}
};
const handleNameBlur = (index) => {
formik.setFieldTouched(`${index}.name`, true, true);
};
const handleNameKeyDown = (index, e) => {
if (e.key === 'Enter') {
e.preventDefault();
formik.setFieldTouched(`${index}.name`, true, true);
}
};
const handleSave = () => {
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const savedValues = environment.variables || [];
const hasChanges = JSON.stringify(variablesToSave) !== JSON.stringify(savedValues);
if (!hasChanges) {
toast.error('No changes to save');
return;
}
const hasValidationErrors = variablesToSave.some((variable) => {
if (!variable.name || variable.name.trim() === '') {
return true;
}
if (!variableNameRegex.test(variable.name)) {
return true;
}
return false;
});
if (hasValidationErrors) {
toast.error('Please fix validation errors before saving');
return;
}
dispatch(saveEnvironment(cloneDeep(variablesToSave), environment.uid, collection.uid))
.then(() => {
toast.success('Changes saved successfully');
const newValues = [
...variablesToSave,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
formik.resetForm({ values: newValues });
setIsModified(false);
})
.catch((error) => {
console.error(error);
toast.error('An error occurred while saving the changes');
});
};
const handleReset = () => {
const originalVars = environment.variables || [];
const resetValues = [
...originalVars,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
formik.resetForm({ values: resetValues });
setIsModified(false);
};
const handleSaveRef = useRef(handleSave);
handleSaveRef.current = handleSave;
useEffect(() => {
const handleSaveEvent = () => {
handleSaveRef.current();
};
window.addEventListener('environment-save', handleSaveEvent);
return () => {
window.removeEventListener('environment-save', handleSaveEvent);
};
}, []);
return (
<EnvironmentVariablesTable
environment={environment}
collection={collection}
onSave={handleSave}
draft={hasDraftForThisEnv ? environmentsDraft : null}
onDraftChange={handleDraftChange}
onDraftClear={handleDraftClear}
setIsModified={setIsModified}
renderExtraValueContent={renderExtraValueContent}
searchQuery={searchQuery}
/>
<StyledWrapper>
<TableVirtuoso
className="table-container"
style={{ height: tableHeight }}
components={{ TableRow }}
data={formik.values}
totalListHeightChanged={handleTotalHeightChanged}
fixedHeaderContent={() => (
<tr>
<td className="text-center"></td>
<td>Name</td>
<td>Value</td>
<td className="text-center">Secret</td>
<td></td>
</tr>
)}
fixedItemHeight={35}
computeItemKey={(index, variable) => variable.uid}
itemContent={(index, variable) => {
const isLastRow = index === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
return (
<>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${index}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
)}
</td>
<td>
<div className="flex items-center">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${index}.name`}
name={`${index}.name`}
value={variable.name}
placeholder={isLastEmptyRow ? 'Name' : ''}
onChange={(e) => handleNameChange(index, e)}
onBlur={() => handleNameBlur(index)}
onKeyDown={(e) => handleNameKeyDown(index, e)}
/>
<ErrorMessage name={`${index}.name`} index={index} />
</div>
</td>
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${index}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
onSave={handleSave}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
<Tooltip
anchorId={`${variable.uid}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
/>
</span>
)}
{!variable.secret && hasSensitiveUsage(variable.name) && (
<SensitiveFieldWarning
fieldName={variable.name}
warningMessage="This variable is used in sensitive fields. Mark it as a secret for security"
/>
)}
</td>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${index}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</>
);
}}
/>
<div className="button-container">
<div className="flex items-center">
<button type="button" className="submit" onClick={handleSave} data-testid="save-env">
Save
</button>
<button type="button" className="submit reset ml-2" onClick={handleReset} data-testid="reset-env">
Reset
</button>
</div>
</div>
</StyledWrapper>
);
};

View File

@@ -94,63 +94,8 @@ const StyledWrapper = styled.div`
.actions {
display: flex;
align-items: center;
gap: 2px;
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
.search-icon {
position: absolute;
left: 8px;
color: ${(props) => props.theme.colors.text.muted};
pointer-events: none;
}
.search-input {
width: 200px;
padding: 5px 32px 5px 32px;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.sm};
background: ${(props) => props.theme.input.bg};
color: ${(props) => props.theme.text};
font-size: ${(props) => props.theme.font.size.base};
outline: none;
transition: border-color 0.15s ease;
&:focus {
border-color: ${(props) => props.theme.input.focusBorder};
}
&::placeholder {
color: ${(props) => props.theme.input.placeholder.color};
opacity: ${(props) => props.theme.input.placeholder.opacity};
}
}
.clear-search {
position: absolute;
right: 1px;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
cursor: pointer;
border-radius: ${(props) => props.theme.border.radius.sm};
transition: all 0.15s ease;
&:hover {
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
}
}
button {
display: inline-flex;
align-items: center;

View File

@@ -1,14 +1,12 @@
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch } from '@tabler/icons';
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX } from '@tabler/icons';
import { useState, useRef } from 'react';
import { useDispatch } from 'react-redux';
import useDebounce from 'hooks/useDebounce';
import { renameEnvironment, updateEnvironmentColor } from 'providers/ReduxStore/slices/collections/actions';
import { renameEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
import CopyEnvironment from 'components/Environments/EnvironmentSettings/CopyEnvironment';
import DeleteEnvironment from 'components/Environments/EnvironmentSettings/DeleteEnvironment';
import EnvironmentVariables from './EnvironmentVariables';
import ColorPicker from 'components/ColorPicker';
import StyledWrapper from './StyledWrapper';
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
@@ -20,11 +18,7 @@ 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() === '') {
@@ -117,33 +111,6 @@ 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(() => {
toast.success('Environment color updated!');
})
.catch(() => {
toast.error('An error occurred while updating the environment color');
});
};
return (
<StyledWrapper>
{openDeleteModal && (
@@ -190,46 +157,11 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
</div>
</>
) : (
<div className="flex items-center gap-2">
<h2 className="title">{environment.name}</h2>
<ColorPicker color={environment.color} onChange={handleColorChange} />
</div>
<h2 className="title">{environment.name}</h2>
)}
</div>
{nameError && isRenaming && <div className="title-error">{nameError}</div>}
<div className="actions">
{isSearchExpanded ? (
<div className="search-input-wrapper">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
ref={searchInputRef}
type="text"
placeholder="Search variables..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onBlur={handleSearchBlur}
className="search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{searchQuery && (
<button
className="clear-search"
onClick={handleClearSearch}
onMouseDown={(e) => e.preventDefault()}
title="Clear search"
>
<IconX size={14} strokeWidth={1.5} />
</button>
)}
</div>
) : (
<button onClick={handleSearchIconClick} title="Search variables">
<IconSearch size={15} strokeWidth={1.5} />
</button>
)}
<button onClick={handleRenameClick} title="Rename">
<IconEdit size={15} strokeWidth={1.5} />
</button>
@@ -243,12 +175,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
</div>
<div className="content">
<EnvironmentVariables
environment={environment}
setIsModified={setIsModified}
collection={collection}
searchQuery={debouncedSearchQuery}
/>
<EnvironmentVariables environment={environment} setIsModified={setIsModified} collection={collection} />
</div>
</StyledWrapper>
);

View File

@@ -99,37 +99,10 @@ const StyledWrapper = styled.div`
}
}
.sections-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0 8px;
}
.environments-list {
flex: 1;
overflow-y: auto;
padding: 0 4px;
}
.btn-action {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
color: ${(props) => props.theme.text};
}
padding: 0 8px;
}
.environment-item {
@@ -137,7 +110,6 @@ const StyledWrapper = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px 8px;
margin-bottom: 1px;
font-size: 13px;
@@ -308,39 +280,6 @@ const StyledWrapper = styled.div`
background: ${(props) => `${props.theme.colors.text.danger}15`};
border-radius: 4px;
}
.no-env-file {
padding: 8px 12px;
font-size: 12px;
color: ${(props) => props.theme.colors.text.muted};
font-style: italic;
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 10%;
color: ${(props) => props.theme.colors.text.muted};
svg {
opacity: 0.3;
margin-bottom: 8px;
}
.title {
font-size: 13px;
font-weight: 500;
margin-bottom: 12px;
color: ${(props) => props.theme.colors.text.muted};
}
.actions {
display: flex;
gap: 8px;
}
}
`;
export default StyledWrapper;

View File

@@ -1,32 +1,16 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import usePrevious from 'hooks/usePrevious';
import useOnClickOutside from 'hooks/useOnClickOutside';
import EnvironmentDetails from './EnvironmentDetails';
import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX, IconFileAlert } from '@tabler/icons';
import Button from 'ui/Button';
import CreateEnvironment from 'components/Environments/EnvironmentSettings/CreateEnvironment';
import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import ConfirmSwitchEnv from 'components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/ConfirmSwitchEnv';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import CollapsibleSection from 'components/Environments/CollapsibleSection';
import DotEnvFileEditor from 'components/Environments/DotEnvFileEditor';
import DotEnvFileDetails from 'components/Environments/DotEnvFileDetails';
import ColorBadge from 'components/ColorBadge';
import { isEqual } from 'lodash';
import { useDispatch, useSelector } from 'react-redux';
import {
addEnvironment,
renameEnvironment,
selectEnvironment,
saveDotEnvVariables,
saveDotEnvRaw,
createDotEnvFile,
deleteDotEnvFile
} from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import { addEnvironment, renameEnvironment, selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
import classnames from 'classnames';
const EMPTY_ARRAY = [];
const EnvironmentList = ({
environments,
@@ -40,6 +24,7 @@ const EnvironmentList = ({
}) => {
const dispatch = useDispatch();
const [openCreateModal, setOpenCreateModal] = useState(false);
const [openImportModal, setOpenImportModal] = useState(false);
const [searchText, setSearchText] = useState('');
const [isCreatingInline, setIsCreatingInline] = useState(false);
@@ -52,40 +37,10 @@ const EnvironmentList = ({
const [switchEnvConfirmClose, setSwitchEnvConfirmClose] = useState(false);
const [originalEnvironmentVariables, setOriginalEnvironmentVariables] = useState([]);
const [environmentsExpanded, setEnvironmentsExpanded] = useState(true);
const [dotEnvExpanded, setDotEnvExpanded] = useState(false);
const [activeView, setActiveView] = useState('environment');
const [isDotEnvModified, setIsDotEnvModified] = useState(false);
const [dotEnvViewMode, setDotEnvViewMode] = useState('table');
const [selectedDotEnvFile, setSelectedDotEnvFile] = useState(null);
const [isCreatingDotEnvInline, setIsCreatingDotEnvInline] = useState(false);
const [newDotEnvName, setNewDotEnvName] = useState('.env');
const [dotEnvNameError, setDotEnvNameError] = useState('');
const dotEnvInputRef = useRef(null);
const dotEnvCreateContainerRef = useRef(null);
const 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);
@@ -131,34 +86,44 @@ const EnvironmentList = ({
}
}, [envUids, environments, prevEnvUids]);
useEffect(() => {
if (!renamingEnvUid) return;
const handleClickOutside = (event) => {
if (renameContainerRef.current && !renameContainerRef.current.contains(event.target)) {
handleCancelRename();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [renamingEnvUid]);
useEffect(() => {
if (!isCreatingInline) return;
const handleClickOutside = (event) => {
if (createContainerRef.current && !createContainerRef.current.contains(event.target)) {
handleCancelCreate();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isCreatingInline]);
const handleEnvironmentClick = (env) => {
if (activeView === 'dotenv' && isDotEnvModified) {
setSwitchEnvConfirmClose(true);
return;
}
if (!isModified) {
setSelectedEnvironment(env);
setActiveView('environment');
setEnvironmentsExpanded(true);
} else {
setSwitchEnvConfirmClose(true);
}
};
const handleDotEnvClick = (filename) => {
if (isModified) {
setSwitchEnvConfirmClose(true);
return;
}
if (activeView === 'dotenv' && isDotEnvModified && selectedDotEnvFile !== filename) {
setSwitchEnvConfirmClose(true);
return;
}
setSelectedDotEnvFile(filename);
setActiveView('dotenv');
setDotEnvExpanded(true);
};
const handleEnvironmentDoubleClick = (env) => {
setRenamingEnvUid(env.uid);
setNewEnvName(env.name);
@@ -169,7 +134,7 @@ const EnvironmentList = ({
}, 50);
};
const handleActivateEnvironment = useCallback((e, env) => {
const handleActivateEnvironment = (e, env) => {
e.stopPropagation();
dispatch(selectEnvironment(env.uid, collection.uid))
.then(() => {
@@ -178,7 +143,11 @@ const EnvironmentList = ({
.catch(() => {
toast.error('Failed to activate environment');
});
}, [dispatch, collection.uid]);
};
if (!selectedEnvironment) {
return null;
}
const validateEnvironmentName = (name, excludeUid = null) => {
if (!name || name.trim() === '') {
@@ -201,7 +170,7 @@ const EnvironmentList = ({
};
const handleCreateEnvClick = () => {
if (!isModified && !isDotEnvModified) {
if (!isModified) {
setIsCreatingInline(true);
setNewEnvName('');
setEnvNameError('');
@@ -213,13 +182,11 @@ const EnvironmentList = ({
}
};
const handleCancelCreate = useCallback(() => {
const handleCancelCreate = () => {
setIsCreatingInline(false);
setNewEnvName('');
setEnvNameError('');
}, []);
useOnClickOutside(createContainerRef, handleCancelCreate, isCreatingInline);
};
const handleSaveNewEnv = () => {
const error = validateEnvironmentName(newEnvName);
@@ -286,16 +253,14 @@ const EnvironmentList = ({
});
};
const handleCancelRename = useCallback(() => {
const handleCancelRename = () => {
setRenamingEnvUid(null);
setNewEnvName('');
setEnvNameError('');
}, []);
useOnClickOutside(renameContainerRef, handleCancelRename, !!renamingEnvUid);
};
const handleImportClick = () => {
if (!isModified && !isDotEnvModified) {
if (!isModified) {
setOpenImportModal(true);
} else {
setSwitchEnvConfirmClose(true);
@@ -314,197 +279,12 @@ const EnvironmentList = ({
}
};
const handleSaveDotEnv = (variables) => {
if (!selectedDotEnvFile) return Promise.reject(new Error('No file selected'));
return dispatch(saveDotEnvVariables(collection.uid, variables, selectedDotEnvFile));
};
const handleSaveDotEnvRaw = (content) => {
if (!selectedDotEnvFile) return Promise.reject(new Error('No file selected'));
return dispatch(saveDotEnvRaw(collection.uid, content, selectedDotEnvFile));
};
const handleCreateDotEnvInlineClick = () => {
if (isModified || isDotEnvModified) {
setSwitchEnvConfirmClose(true);
return;
}
setIsCreatingDotEnvInline(true);
setNewDotEnvName('.env');
setDotEnvNameError('');
setTimeout(() => {
dotEnvInputRef.current?.focus();
const input = dotEnvInputRef.current;
if (input) {
input.setSelectionRange(input.value.length, input.value.length);
}
}, 50);
};
const handleCancelDotEnvCreate = useCallback(() => {
setIsCreatingDotEnvInline(false);
setNewDotEnvName('.env');
setDotEnvNameError('');
}, []);
useOnClickOutside(dotEnvCreateContainerRef, handleCancelDotEnvCreate, isCreatingDotEnvInline);
const validateDotEnvName = (name) => {
if (!name || name.trim() === '') {
return 'Name is required';
}
if (!name.startsWith('.env')) {
return 'File name must start with .env';
}
const validPattern = /^\.env[a-zA-Z0-9._-]*$/;
if (!validPattern.test(name)) {
return 'Invalid file name';
}
const exists = dotEnvFiles.some((f) => f.filename === name);
if (exists) {
return 'File already exists';
}
return null;
};
const handleSaveNewDotEnv = () => {
const error = validateDotEnvName(newDotEnvName);
if (error) {
setDotEnvNameError(error);
return;
}
dispatch(createDotEnvFile(collection.uid, newDotEnvName))
.then(() => {
toast.success(`${newDotEnvName} file created!`);
setIsCreatingDotEnvInline(false);
setNewDotEnvName('.env');
setDotEnvNameError('');
setSelectedDotEnvFile(newDotEnvName);
setActiveView('dotenv');
setDotEnvExpanded(true);
})
.catch((error) => {
toast.error(error.message || 'Failed to create .env file');
});
};
const handleDotEnvNameChange = (e) => {
const value = e.target.value;
if (!value.startsWith('.env')) {
setNewDotEnvName('.env');
} else {
setNewDotEnvName(value);
}
if (dotEnvNameError) {
setDotEnvNameError('');
}
};
const handleDotEnvNameKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveNewDotEnv();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancelDotEnvCreate();
} else if (e.key === 'Backspace') {
const input = e.target;
if (input.selectionStart <= 4 && input.selectionEnd <= 4) {
e.preventDefault();
}
}
};
const handleDeleteDotEnvFile = (filename) => {
dispatch(deleteDotEnvFile(collection.uid, filename))
.then(() => {
toast.success(`${filename} file deleted!`);
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)} />
)}
@@ -518,111 +298,42 @@ const EnvironmentList = ({
<div className="sidebar">
<div className="sidebar-header">
<h2 className="title">Variables</h2>
<h2 className="title">Environments</h2>
<div className="flex items-center gap-2">
<button className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
<IconPlus size={16} strokeWidth={1.5} />
</button>
<button className="btn-action" onClick={() => handleImportClick()} title="Import environment">
<IconDownload size={16} strokeWidth={1.5} />
</button>
<button className="btn-action" onClick={() => handleExportClick()} title="Export environment">
<IconUpload size={16} strokeWidth={1.5} />
</button>
</div>
</div>
<div className="search-container">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
type="text"
placeholder="Search..."
placeholder="Search environments..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="search-input"
/>
</div>
<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}>
<div className="environments-list">
{filteredEnvironments.map((env) => (
<div
key={env.uid}
id={env.uid}
className={`environment-item ${selectedEnvironment.uid === env.uid ? 'active' : ''} ${renamingEnvUid === env.uid ? 'renaming' : ''} ${activeEnvironmentUid === env.uid ? 'activated' : ''}`}
onClick={() => renamingEnvUid !== env.uid && handleEnvironmentClick(env)}
onDoubleClick={() => handleEnvironmentDoubleClick(env)}
>
{renamingEnvUid === env.uid ? (
<div className="rename-container" ref={renameContainerRef}>
<input
ref={inputRef}
type="text"
@@ -630,7 +341,6 @@ const EnvironmentList = ({
value={newEnvName}
onChange={handleEnvNameChange}
onKeyDown={handleEnvNameKeyDown}
placeholder="Environment name..."
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
@@ -639,7 +349,7 @@ const EnvironmentList = ({
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveNewEnv}
onClick={handleSaveRename}
onMouseDown={(e) => e.preventDefault()}
title="Save"
>
@@ -647,7 +357,7 @@ const EnvironmentList = ({
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelCreate}
onClick={handleCancelRename}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
@@ -655,94 +365,75 @@ const EnvironmentList = ({
</button>
</div>
</div>
)}
{envNameError && (isCreatingInline || renamingEnvUid) && <div className="env-error">{envNameError}</div>}
{filteredEnvironments.length === 0 && !isCreatingInline && (
<div className="no-env-file">
<span>No environments</span>
</div>
) : (
<>
<span className="environment-name">{env.name}</span>
<div className="environment-actions">
{activeEnvironmentUid === env.uid ? (
<div className="activated-checkmark" title="Active environment">
<IconCheck size={16} strokeWidth={2} />
</div>
) : (
<button
className="activate-btn"
onClick={(e) => handleActivateEnvironment(e, env)}
title="Activate environment"
>
<IconCheck size={16} strokeWidth={2} />
</button>
)}
</div>
</>
)}
</div>
</CollapsibleSection>
))}
<CollapsibleSection
title=".env Files"
expanded={dotEnvExpanded}
onToggle={() => setDotEnvExpanded(!dotEnvExpanded)}
badge={dotEnvFiles.length}
actions={(
<button
className="btn-action"
onClick={handleCreateDotEnvInlineClick}
title="Create .env file"
>
<IconPlus size={14} strokeWidth={1.5} />
</button>
)}
>
<div className="environments-list">
{dotEnvFiles.map((file) => (
<div
key={file.filename}
className={classnames('environment-item', {
active: activeView === 'dotenv' && selectedDotEnvFile === file.filename
})}
onClick={() => handleDotEnvClick(file.filename)}
{isCreatingInline && (
<div className="environment-item creating" ref={createContainerRef}>
<input
ref={inputRef}
type="text"
className="environment-name-input"
value={newEnvName}
onChange={handleEnvNameChange}
onKeyDown={handleEnvNameKeyDown}
placeholder="Environment name..."
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveNewEnv}
onMouseDown={(e) => e.preventDefault()}
title="Save"
>
<span className="environment-name">{file.filename}</span>
</div>
))}
{isCreatingDotEnvInline && (
<div className="environment-item creating" ref={dotEnvCreateContainerRef}>
<input
ref={dotEnvInputRef}
type="text"
className="environment-name-input"
value={newDotEnvName}
onChange={handleDotEnvNameChange}
onKeyDown={handleDotEnvNameKeyDown}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveNewDotEnv}
onMouseDown={(e) => e.preventDefault()}
title="Create"
>
<IconCheck size={14} strokeWidth={2} />
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelDotEnvCreate}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
<IconX size={14} strokeWidth={2} />
</button>
</div>
</div>
)}
{dotEnvNameError && isCreatingDotEnvInline && <div className="env-error">{dotEnvNameError}</div>}
{dotEnvFiles.length === 0 && !isCreatingDotEnvInline && (
<div className="no-env-file">
<span>No .env files</span>
</div>
)}
<IconCheck size={14} strokeWidth={2} />
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelCreate}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
<IconX size={14} strokeWidth={2} />
</button>
</div>
</div>
</CollapsibleSection>
)}
{envNameError && (isCreatingInline || renamingEnvUid) && <div className="env-error">{envNameError}</div>}
</div>
</div>
{renderContent()}
<EnvironmentDetails
environment={selectedEnvironment}
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
/>
</div>
</StyledWrapper>
);

View File

@@ -1,7 +1,26 @@
import React, { useState } from 'react';
import CreateEnvironment from 'components/Environments/EnvironmentSettings/CreateEnvironment';
import EnvironmentList from './EnvironmentList';
import StyledWrapper from './StyledWrapper';
import { IconFileAlert } from '@tabler/icons';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal';
import Button from 'ui/Button';
const DefaultTab = ({ setTab }) => (
<div className="empty-state">
<IconFileAlert size={48} strokeWidth={1.5} />
<div className="title">No Environments</div>
<div className="actions">
<Button size="sm" color="secondary" onClick={() => setTab('create')}>
Create Environment
</Button>
<Button size="sm" color="secondary" onClick={() => setTab('import')}>
Import Environment
</Button>
</div>
</div>
);
const EnvironmentSettings = ({ collection }) => {
const [isModified, setIsModified] = useState(false);
@@ -11,8 +30,23 @@ const EnvironmentSettings = ({ collection }) => {
if (!environments.length) return null;
return environments.find((env) => env.uid === collection?.activeEnvironmentUid) || environments[0];
});
const [tab, setTab] = useState('default');
const [showExportModal, setShowExportModal] = useState(false);
if (!environments || !environments.length) {
return (
<StyledWrapper>
{tab === 'create' ? (
<CreateEnvironment collection={collection} onClose={() => setTab('default')} />
) : tab === 'import' ? (
<ImportEnvironmentModal type="collection" collection={collection} onClose={() => setTab('default')} />
) : (
<DefaultTab setTab={setTab} />
)}
</StyledWrapper>
);
}
return (
<StyledWrapper>
<EnvironmentList

View File

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

View File

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

View File

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

View File

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

View File

@@ -93,9 +93,12 @@ const Beta = ({ close }) => {
return (
<StyledWrapper>
<div className="section-header">Beta Features</div>
<form onSubmit={formik.handleSubmit}>
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="mb-6">
<div className="flex items-center mb-2">
<IconFlask size={20} className="mr-2 text-orange-500" />
<h2 className="text-lg font-medium">Beta Features</h2>
</div>
<p className="text-gray-500 dark:text-gray-400 mb-4 text-wrap">
Beta features are experimental previews that may change before full release. Try them and share feedback.
</p>

View File

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

View File

@@ -1,11 +1,6 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
color: ${(props) => props.theme.text};
.text-link {
@@ -23,10 +18,6 @@ const StyledWrapper = styled.div`
font-size: 0.8125rem;
}
}
.default-collection-location-input {
max-width: 28rem;
}
`;
export default StyledWrapper;

View File

@@ -174,9 +174,8 @@ 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 mb-2">
<div className="flex items-center my-2">
<input
id="sslVerification"
type="checkbox"

View File

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

View File

@@ -1,11 +1,6 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
.settings-label {
width: 100px;
}
@@ -30,41 +25,6 @@ const StyledWrapper = styled.div`
label {
color: ${(props) => props.theme.colors.text.yellow};
}
.system-proxy-title {
color: ${(props) => props.theme.text};
}
.system-proxy-description {
color: ${(props) => props.theme.colors.text.muted};
}
.system-proxy-error-container {
background: ${(props) => props.theme.status.danger.background};
border: 1px solid ${(props) => props.theme.status.danger.border};
width: fit-content;
}
.system-proxy-error-text {
color: ${(props) => props.theme.status.danger.text};
}
.system-proxy-source-label {
color: ${(props) => props.theme.colors.text.muted};
}
.system-proxy-source-value {
color: ${(props) => props.theme.text};
}
.system-proxy-info-text {
color: ${(props) => props.theme.colors.text.muted};
}
.system-proxy-value {
color: ${(props) => props.theme.colors.text.purple};
opacity: 0.8;
}
}
`;

View File

@@ -1,101 +0,0 @@
import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { IconLoader2, IconRefresh } from '@tabler/icons';
import { getSystemProxyVariables, refreshSystemProxy } from 'providers/ReduxStore/slices/app';
import StyledWrapper from '../StyledWrapper';
const SystemProxy = () => {
const dispatch = useDispatch();
const systemProxyVariables = useSelector((state) => state.app.systemProxyVariables);
const { source, http_proxy, https_proxy, no_proxy } = systemProxyVariables || {};
const [isFetching, setIsFetching] = useState(true);
const [error, setError] = useState(null);
const fetchProxy = (forceRefresh = false) => {
setIsFetching(true);
setError(null);
const action = forceRefresh ? refreshSystemProxy : getSystemProxyVariables;
dispatch(action())
.then(() => setError(null))
.catch((err) => setError(err.message || String(err)))
.finally(() => setIsFetching(false));
};
useEffect(() => {
fetchProxy(false);
}, [dispatch]);
const handleRefresh = () => {
fetchProxy(true);
};
return (
<StyledWrapper>
<div className="mb-3 text-muted system-proxy-settings space-y-4">
<div className="flex items-start justify-start flex-col gap-2 mt-2">
<div className="flex flex-row items-center gap-2">
<div>
<h2 className="text-xs system-proxy-title flex flex-row">
System Proxy {isFetching ? <IconLoader2 className="animate-spin ml-1" size={16} strokeWidth={1.5} /> : null}
</h2>
<small className="system-proxy-description">
Below values are sourced from your system proxy settings.
</small>
</div>
</div>
</div>
{error && (
<div className="mb-2 p-3 system-proxy-error-container rounded">
<small className="system-proxy-error-text">
Error loading system proxy settings: {error}
</small>
</div>
)}
{source && (
<div className="mb-2">
<small className="font-medium flex flex-row gap-2">
<div className="system-proxy-source-label text-xs">
Proxy source:
</div>
<div className="system-proxy-source-value">
{source}
</div>
</small>
</div>
)}
<small className="system-proxy-info-text">
These values cannot be directly updated in Bruno. Please refer to your OS documentation to update these.
</small>
<div className="flex flex-col justify-start items-start pt-2">
<div className="mb-1 flex items-center">
<label className="settings-label">
http_proxy
</label>
<div className="system-proxy-value">{http_proxy || '-'}</div>
</div>
<div className="mb-1 flex items-center">
<label className="settings-label">
https_proxy
</label>
<div className="system-proxy-value">{https_proxy || '-'}</div>
</div>
<div className="mb-1 flex items-center">
<label className="settings-label">
no_proxy
</label>
<div className="system-proxy-value">{no_proxy || '-'}</div>
</div>
</div>
<span
className="text-link cursor-pointer hover:underline default-collection-location-browse flex flex-row items-center"
onClick={handleRefresh}
>
<IconRefresh size={14} strokeWidth={1.5} className="mr-1" />
Refresh
</span>
</div>
</StyledWrapper>
);
};
export default SystemProxy;

View File

@@ -9,11 +9,13 @@ import StyledWrapper from './StyledWrapper';
import { useDispatch, useSelector } from 'react-redux';
import { IconEye, IconEyeOff } from '@tabler/icons';
import { useState } from 'react';
import SystemProxy from './SystemProxy';
const ProxySettings = ({ close }) => {
const preferences = useSelector((state) => state.app.preferences);
const systemProxyEnvVariables = useSelector((state) => state.app.systemProxyEnvVariables);
const { http_proxy, https_proxy, no_proxy } = systemProxyEnvVariables || {};
const dispatch = useDispatch();
console.log(preferences);
const proxySchema = Yup.object({
disabled: Yup.boolean().optional(),
@@ -113,7 +115,6 @@ const ProxySettings = ({ close }) => {
return (
<StyledWrapper>
<div className="section-header">Proxy Settings</div>
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="mb-3 flex items-center mt-2">
<label className="settings-label" htmlFor="protocol">
@@ -166,7 +167,30 @@ const ProxySettings = ({ close }) => {
</div>
{formik.values.disabled === false && formik.values.inherit === true ? (
<div className="mb-3 pt-1 text-muted system-proxy-settings">
<SystemProxy />
<small>
Below values are sourced from your system environment variables and cannot be directly updated in Bruno.<br />
Please refer to your OS documentation to change these values.
</small>
<div className="flex flex-col justify-start items-start pt-2">
<div className="mb-1 flex items-center">
<label className="settings-label" htmlFor="http_proxy">
http_proxy
</label>
<div className="opacity-80">{http_proxy || '-'}</div>
</div>
<div className="mb-1 flex items-center">
<label className="settings-label" htmlFor="https_proxy">
https_proxy
</label>
<div className="opacity-80">{https_proxy || '-'}</div>
</div>
<div className="mb-1 flex items-center">
<label className="settings-label" htmlFor="no_proxy">
no_proxy
</label>
<div className="opacity-80">{no_proxy || '-'}</div>
</div>
</div>
</div>
) : null}
{formik.values.disabled === false && formik.values.inherit === false ? (

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: 6px 0 8px 0;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}

View File

@@ -1,11 +1,6 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
color: ${(props) => props.theme.text};
.rows {
svg {

View File

@@ -8,9 +8,8 @@ const Support = () => {
return (
<StyledWrapper>
<div className="section-header">Support</div>
<div className="rows">
<div className="mb-2">
<div className="mt-2">
<a href="https://docs.usebruno.com" target="_blank" className="flex items-end">
<IconBook size={18} strokeWidth={2} />
<span className="label ml-2">{t('COMMON.DOCUMENTATION')}</span>

View File

@@ -3,7 +3,7 @@ import { rgba } from 'polished';
const StyledWrapper = styled.div`
.appearance-container {
padding-bottom: 16px;
padding: 8px 0 16px 0;
}
.theme-mode-option {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,8 @@ import CollectionToolBar from './CollectionToolBar';
import RequestTab from './RequestTab';
import StyledWrapper from './StyledWrapper';
import DraggableTab from './DraggableTab';
import CreateTransientRequest from 'components/CreateTransientRequest';
import CreateUntitledRequest from 'components/CreateUntitledRequest';
import { IconPlus } from '@tabler/icons';
import ActionIcon from 'ui/ActionIcon/index';
const RequestTabs = () => {
@@ -160,7 +161,12 @@ const RequestTabs = () => {
</div>
{activeCollection && (
<CreateTransientRequest collectionUid={activeCollection.uid} />
<ActionIcon onClick={() => setNewRequestModalOpen(true)} aria-label="New Request" size="lg" style={{ marginBottom: '3px' }}>
<IconPlus
size={18}
strokeWidth={1.5}
/>
</ActionIcon>
)}
<div className={classnames('scroll-chevrons', { hidden: !showChevrons })}>

View File

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

View File

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

View File

@@ -206,7 +206,7 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
collection={collection}
item={item}
readOnly={!editMode}
placeholder={!value ? 'Value' : ''}
placeholder={isLastEmptyRow ? 'Value' : ''}
/>
</div>
{!hasTextValue && !isLastEmptyRow && (
@@ -228,11 +228,11 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
placeholder: 'Auto',
width: '30%',
readOnly: !editMode,
render: ({ value, onChange }) => (
render: ({ row, value, onChange, isLastEmptyRow }) => (
<SingleLineEditor
onSave={() => {}}
theme={storedTheme}
placeholder={!value ? 'Auto' : ''}
placeholder={isLastEmptyRow ? '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: ({ value, onChange }) => (
render: ({ row, value, onChange, isLastEmptyRow }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
@@ -115,7 +115,7 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
collection={collection}
variablesAutocomplete={true}
readOnly={!editMode}
placeholder={!value ? 'Name' : ''}
placeholder={isLastEmptyRow ? 'Name' : ''}
/>
)
},
@@ -125,7 +125,7 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
placeholder: 'Value',
width: '60%',
readOnly: !editMode,
render: ({ value, onChange }) => (
render: ({ row, value, onChange, isLastEmptyRow }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
@@ -135,7 +135,7 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
collection={collection}
variablesAutocomplete={true}
readOnly={!editMode}
placeholder={!value ? 'Value' : ''}
placeholder={isLastEmptyRow ? 'Value' : ''}
/>
)
}
@@ -154,7 +154,7 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
placeholder: 'Value',
width: '60%',
readOnly: !editMode,
render: ({ value, onChange }) => (
render: ({ row, value, onChange, isLastEmptyRow }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
@@ -164,7 +164,7 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
collection={collection}
variablesAutocomplete={true}
readOnly={!editMode}
placeholder={!value ? 'Value' : ''}
placeholder={isLastEmptyRow ? 'Value' : ''}
/>
)
}

View File

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

View File

@@ -47,49 +47,6 @@ 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',
@@ -138,7 +95,7 @@ const ResponsePaneActions = ({ item, collection, responseSize, selectedFormat, s
}
];
if (!['http-request', 'graphql-request'].includes(item.type)) {
if (item.type !== 'http-request') {
return null;
}
@@ -146,7 +103,7 @@ const ResponsePaneActions = ({ item, collection, responseSize, selectedFormat, s
<StyledWrapper className="response-pane-actions-wrapper">
<div className="actions-dropdown">
<MenuDropdown
items={item.type !== 'graphql-request' ? menuItems : gqlMenuItems}
items={menuItems}
placement="bottom-end"
data-testid="response-actions-menu"
>
@@ -162,7 +119,7 @@ const ResponsePaneActions = ({ item, collection, responseSize, selectedFormat, s
data={data}
dataBuffer={dataBuffer}
/>
{item.type !== 'graphql-request' && <ResponseBookmark ref={bookmarkButtonRef} item={item} collection={collection} responseSize={responseSize} />}
<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

@@ -1,123 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { pluralizeWord } from 'utils/common';
import { IconAlertTriangle, IconDeviceFloppy } from '@tabler/icons';
import { clearAllSaveTransientRequestModals } from 'providers/ReduxStore/slices/collections';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import toast from 'react-hot-toast';
import Modal from 'components/Modal';
import Button from 'ui/Button';
import SaveTransientRequest from './index';
const SaveTransientRequestContainer = () => {
const dispatch = useDispatch();
const modals = useSelector((state) => state.collections.saveTransientRequestModals);
const [openItemUid, setOpenItemUid] = useState(null);
// Reset openItemUid if the modal no longer exists in the array
useEffect(() => {
if (openItemUid && !modals.find((modal) => modal.item.uid === openItemUid)) {
setOpenItemUid(null);
}
}, [modals, openItemUid]);
const handleDiscardAll = () => {
// Close all tabs for the transient requests (this will also delete the transient files)
const tabUids = modals.map((modal) => modal.item.uid);
dispatch(closeTabs({ tabUids }));
// Clear all modals
dispatch(clearAllSaveTransientRequestModals());
// Show success message
toast.success(`Discarded ${modals.length} ${pluralizeWord('request', modals.length)}`);
};
const handleCancel = () => {
// Clear all modals on close
dispatch(clearAllSaveTransientRequestModals());
};
const handleOpenSpecificModal = (itemUid) => {
setOpenItemUid(itemUid);
};
// If a specific modal is open, show it
if (openItemUid) {
const modalToOpen = modals.find((modal) => modal.item.uid === openItemUid);
if (modalToOpen) {
return (
<SaveTransientRequest
item={modalToOpen.item}
collection={modalToOpen.collection}
isOpen={true}
/>
);
}
}
// Show list of multiple modals
return (
<Modal
size="md"
title="Unsaved Transient Requests"
hideFooter={true}
disableEscapeKey={true}
disableCloseOnOutsideClick={true}
handleCancel={handleCancel}
>
<div className="flex items-center">
<IconAlertTriangle size={32} strokeWidth={1.5} className="text-yellow-600" />
<h1 className="ml-2 text-lg font-medium">You have unsaved transient requests</h1>
</div>
<p className="mt-4">
You have <span className="font-medium">{modals.length}</span>{' '}
{pluralizeWord('request', modals.length)} that need to be saved.
</p>
<div className="mt-4">
<p className="text-sm font-medium mb-2">
Transient {pluralizeWord('Request', modals.length)} ({modals.length})
</p>
<p className="text-xs text-orange-600 mb-3">
These requests need to be saved before you can proceed.
</p>
<div className="space-y-2 max-h-96 overflow-y-auto pr-1">
{modals.map((modal) => {
const { item, collection } = modal;
return (
<div
key={item.uid}
className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded border border-gray-200"
>
<div className="flex flex-col flex-1 min-w-0 mr-3">
<span className="text-sm text-gray-700 truncate">{item.name}</span>
<span className="text-xs text-gray-500 truncate">
{collection.name}
</span>
</div>
<Button
color="primary"
variant="ghost"
size="sm"
onClick={() => handleOpenSpecificModal(item.uid)}
icon={<IconDeviceFloppy size={14} strokeWidth={1.5} />}
>
Save
</Button>
</div>
);
})}
</div>
</div>
<div className="flex justify-end mt-6 pt-4 border-t">
<Button color="danger" onClick={handleDiscardAll}>
Discard All
</Button>
</div>
</Modal>
);
};
export default SaveTransientRequestContainer;

View File

@@ -1,287 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.save-request-form {
display: flex;
flex-direction: column;
gap: 24px;
}
.form-section {
display: flex;
flex-direction: column;
}
.form-label {
display: block;
font-weight: 500;
margin-bottom: 8px;
color: ${(props) => props.theme.text};
}
.form-input {
display: block;
width: 100%;
line-height: 1.42857143;
padding: 0.45rem;
border-radius: ${(props) => props.theme.border.radius.sm};
background-color: ${(props) => props.theme.input.bg};
border: 1px solid ${(props) => props.theme.input.border};
color: ${(props) => props.theme.text};
transition: border-color ease-in-out 0.1s;
&:focus {
border: solid 1px ${(props) => props.theme.input.focusBorder} !important;
outline: none !important;
}
}
.collections-section {
display: flex;
flex-direction: column;
}
.collections-label {
display: block;
font-weight: 500;
margin-bottom: 8px;
color: ${(props) => props.theme.text};
}
.collection-name {
display: flex;
align-items: center;
font-size: 14px;
margin-bottom: 12px;
color: ${(props) => props.theme.colors.text.muted};
}
.collection-name-clickable {
cursor: pointer;
}
.collection-name-breadcrumb {
cursor: pointer;
}
.collection-name-chevron {
margin: 0 4px;
}
.search-container {
margin-bottom: 12px;
}
.folder-list {
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: ${(props) => props.theme.border.radius.sm};
max-height: 256px;
overflow-y: auto;
background-color: ${(props) => props.theme.modal.body.bg};
padding: 8px 8px;
}
.folder-list-items {
display: flex;
flex-direction: column;
gap: 4px;
list-style: none;
padding: 0;
margin: 0;
border-radius: ${(props) => props.theme.border.radius.sm};
}
.folder-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
cursor: pointer;
transition: background-color 0.15s ease;
color: ${(props) => props.theme.text};
border-radius: ${(props) => props.theme.border.radius.sm};
user-select: none;
&:hover {
background-color: ${(props) => props.theme.plainGrid.hoverBg};
}
&.selected {
background-color: ${(props) => props.theme.plainGrid.hoverBg};
}
}
.folder-item-content {
display: flex;
align-items: center;
gap: 8px;
}
.folder-item-name {
color: ${(props) => props.theme.text};
}
.folder-empty-state {
padding: 16px 12px;
text-align: center;
font-size: 14px;
color: ${(props) => props.theme.colors.text.muted};
}
.custom-modal-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0px;
background-color: ${(props) => props.theme.modal.body.bg};
border-top: 1px solid ${(props) => props.theme.border.border0};
border-bottom-left-radius: ${(props) => props.theme.border.radius.base};
border-bottom-right-radius: ${(props) => props.theme.border.radius.base};
}
.footer-left {
display: flex;
align-items: center;
}
.footer-right {
display: flex;
align-items: center;
gap: 8px;
}
.text-muted {
color: ${(props) => props.theme.colors.text.muted};
}
.new-folder-item {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border-top: 1px solid ${(props) => props.theme.border.border1};
margin-top: 4px;
padding-top: 12px;
}
.new-folder-content {
display: flex;
align-items: flex-start;
gap: 8px;
}
.new-folder-inputs {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.new-folder-name-input-wrapper {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
}
.new-folder-name-label {
font-size: 12px;
font-weight: 500;
color: ${(props) => props.theme.colors.text.muted};
}
.new-folder-input-row {
display: flex;
align-items: center;
gap: 8px;
}
.new-folder-input {
flex: 1;
padding: 6px 8px;
border-radius: ${(props) => props.theme.border.radius.sm};
background-color: ${(props) => props.theme.input.bg};
border: 1px solid ${(props) => props.theme.input.border};
color: ${(props) => props.theme.text};
font-size: 14px;
transition: border-color ease-in-out 0.1s;
&:focus {
border: solid 1px ${(props) => props.theme.input.focusBorder} !important;
outline: none !important;
}
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
}
.new-folder-actions {
display: flex;
align-items: center;
gap: 4px;
}
.new-folder-action-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border: none;
background: transparent;
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
border-radius: ${(props) => props.theme.border.radius.sm};
transition: all 0.15s ease;
&:hover {
background-color: ${(props) => props.theme.plainGrid.hoverBg};
color: ${(props) => props.theme.text};
}
&:active {
opacity: 0.7;
}
}
.new-folder-filesystem-wrapper {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 4px;
}
.new-folder-filesystem-label {
font-size: 12px;
font-weight: 500;
color: ${(props) => props.theme.colors.text.muted};
}
.new-folder-toggle-filesystem-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
margin-top: 4px;
border: none;
background: transparent;
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
border-radius: ${(props) => props.theme.border.radius.sm};
font-size: 12px;
transition: all 0.15s ease;
align-self: flex-start;
&:hover {
background-color: ${(props) => props.theme.plainGrid.hoverBg};
color: ${(props) => props.theme.text};
}
}
.new-folder-error {
color: ${(props) => props.theme.colors.danger};
font-size: 12px;
margin-top: 4px;
}
`;
export default StyledWrapper;

View File

@@ -1,413 +0,0 @@
import React, { useState, useMemo, useEffect, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import Modal from 'components/Modal';
import SearchInput from 'components/SearchInput';
import Button from 'ui/Button';
import { IconFolder, IconChevronRight, IconCheck, IconX, IconEye, IconEyeOff } from '@tabler/icons';
import filter from 'lodash/filter';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import useCollectionFolderTree from 'hooks/useCollectionFolderTree';
import { removeSaveTransientRequestModal, deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
import { newFolder } from 'providers/ReduxStore/slices/collections/actions';
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 }) => {
const dispatch = useDispatch();
const latestCollection = useSelector((state) =>
collectionProp ? findCollectionByUid(state.collections.collections, collectionProp.uid) : null
);
const latestItem = latestCollection && itemProp ? findItemInCollection(latestCollection, itemProp.uid) : itemProp;
const item = itemProp;
const collection = collectionProp;
const handleClose = () => {
if (onClose) {
onClose();
return;
}
// Remove from Redux array
dispatch(removeSaveTransientRequestModal({ itemUid: item.uid }));
};
const [requestName, setRequestName] = useState(item?.name || '');
const [searchText, setSearchText] = useState('');
const [showNewFolderInput, setShowNewFolderInput] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [newFolderDirectoryName, setNewFolderDirectoryName] = useState('');
const [showFilesystemName, setShowFilesystemName] = useState(false);
const newFolderInputRef = useRef(null);
const {
currentFolders,
breadcrumbs,
selectedFolderUid,
navigateIntoFolder,
navigateToRoot,
navigateToBreadcrumb,
getCurrentParentFolder,
getCurrentSelectedFolder,
reset,
isAtRoot
} = useCollectionFolderTree(collection?.uid);
const resetForm = () => {
setRequestName(item.name || '');
setSearchText('');
reset();
setShowNewFolderInput(false);
setNewFolderName('');
setNewFolderDirectoryName('');
setShowFilesystemName(false);
};
useEffect(() => {
isOpen && item && resetForm();
}, [isOpen, item]);
useEffect(() => {
if (showNewFolderInput && newFolderInputRef.current) {
newFolderInputRef.current.focus();
}
}, [showNewFolderInput]);
const filteredFolders = useMemo(() => {
if (!searchText.trim()) {
return currentFolders;
}
const searchLower = searchText.toLowerCase();
return filter(currentFolders, (folder) => folder.name.toLowerCase().includes(searchLower));
}, [currentFolders, searchText]);
const handleCancel = () => {
resetForm();
handleClose();
};
const handleConfirm = async () => {
if (!item || !collection || !latestItem) {
return;
}
try {
const { ipcRenderer } = window;
const selectedFolder = getCurrentSelectedFolder();
const targetDirname = selectedFolder ? selectedFolder.pathname : collection.pathname;
const trimmedName = requestName.trim();
if (!trimmedName || trimmedName.length === 0) {
toast.error('Request name is required');
return;
}
const sanitizedFilename = sanitizeName(trimmedName);
const itemToSave = latestItem.draft ? { ...latestItem, ...latestItem.draft } : { ...latestItem };
itemToSave.name = sanitizedFilename;
delete itemToSave.draft;
const transformedItem = transformRequestToSaveToFilesystem(itemToSave);
await itemSchema.validate(transformedItem);
const format = collection.format || DEFAULT_COLLECTION_FORMAT;
const targetFilename = resolveRequestFilename(sanitizedFilename, format);
await ipcRenderer.invoke('renderer:save-transient-request', {
sourcePathname: item.pathname,
targetDirname,
targetFilename,
request: transformedItem,
format
});
dispatch(
closeTabs({
tabUids: [item.uid]
})
);
dispatch({
type: 'collections/deleteItem',
payload: {
itemUid: item.uid,
collectionUid: collection.uid
}
});
toast.success('Request saved successfully');
handleClose();
} catch (err) {
toast.error(err?.message || 'Failed to save request');
console.error('Error saving request:', err);
}
};
const handleShowNewFolder = () => {
setShowNewFolderInput(true);
setNewFolderName('');
setNewFolderDirectoryName('');
setShowFilesystemName(false);
};
const handleCancelNewFolder = () => {
setShowNewFolderInput(false);
setNewFolderName('');
setNewFolderDirectoryName('');
setShowFilesystemName(false);
};
const handleNewFolderNameChange = (value) => {
setNewFolderName(value);
if (!showFilesystemName) {
setNewFolderDirectoryName(sanitizeName(value));
}
};
const handleDirectoryNameChange = (value) => {
setNewFolderDirectoryName(value);
};
const handleCreateNewFolder = async () => {
const directoryName = newFolderDirectoryName.trim() || sanitizeName(newFolderName.trim());
const parentFolder = getCurrentParentFolder();
try {
await dispatch(newFolder(newFolderName.trim(), directoryName, collection?.uid, parentFolder?.uid));
toast.success('New folder created!');
handleCancelNewFolder();
} catch (err) {
const errorMessage = err?.message || 'An error occurred while adding the folder';
toast.error(errorMessage);
}
};
const handleFolderClick = (folderUid) => {
navigateIntoFolder(folderUid);
setSearchText('');
};
if (!isOpen) {
return null;
}
return (
<StyledWrapper>
<Modal
size="md"
title="Save Request"
handleCancel={handleCancel}
handleConfirm={handleConfirm}
confirmText="Save"
cancelText="Cancel"
hideFooter={true}
>
<div className="save-request-form">
<div className="form-section">
<label htmlFor="request-name" className="form-label">
Request name
</label>
<input
id="request-name"
type="text"
className="form-input textbox"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={requestName}
onChange={(e) => setRequestName(e.target.value)}
autoFocus={true}
onFocus={(e) => e.target.select()}
/>
</div>
<div className="collections-section">
<div className="collections-label">Save to Collections</div>
{collection && (
<div
className={`collection-name ${!isAtRoot ? 'collection-name-clickable' : ''}`}
onClick={!isAtRoot ? navigateToRoot : undefined}
>
<span>{collection.name}</span>
{breadcrumbs.length > 0 && (
<>
{breadcrumbs.map((breadcrumb, index) => (
<React.Fragment key={breadcrumb.uid}>
<IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />
<span
className="collection-name-breadcrumb"
onClick={(e) => {
e.stopPropagation();
navigateToBreadcrumb(index);
setSearchText('');
}}
>
{breadcrumb.name}
</span>
</React.Fragment>
))}
</>
)}
{isAtRoot && <IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />}
</div>
)}
<div className="search-container">
<SearchInput
searchText={searchText}
setSearchText={setSearchText}
placeholder="Search for folder"
autoFocus={false}
/>
</div>
<div className="folder-list">
{filteredFolders.length > 0 || showNewFolderInput ? (
<ul className="folder-list-items">
{filteredFolders.map((folder) => (
<li
key={folder.uid}
className={`folder-item ${selectedFolderUid === folder.uid ? 'selected' : ''}`}
onClick={() => handleFolderClick(folder.uid)}
>
<div className="folder-item-content">
<IconFolder size={16} strokeWidth={1.5} />
<span className="folder-item-name">{folder.name}</span>
</div>
<IconChevronRight size={16} strokeWidth={1.5} />
</li>
))}
{showNewFolderInput && (
<li className="new-folder-item">
<div className="new-folder-content">
<IconFolder size={16} strokeWidth={1.5} />
<div className="new-folder-inputs">
<div className="new-folder-name-input-wrapper">
{showFilesystemName && (
<label className="new-folder-name-label">New Folder name (in bruno)</label>
)}
<div className="new-folder-input-row">
<input
ref={newFolderInputRef}
type="text"
className="new-folder-input"
placeholder="Untitled new folder"
value={newFolderName}
onChange={(e) => handleNewFolderNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCreateNewFolder();
} else if (e.key === 'Escape') {
handleCancelNewFolder();
}
}}
/>
<div className="new-folder-actions">
<button
type="button"
className="new-folder-action-btn"
onClick={handleCancelNewFolder}
title="Cancel"
>
<IconX size={16} strokeWidth={1.5} />
</button>
<button
type="button"
className="new-folder-action-btn"
onClick={handleCreateNewFolder}
title="Create folder"
>
<IconCheck size={16} strokeWidth={1.5} />
</button>
</div>
</div>
</div>
{showFilesystemName && (
<div className="new-folder-filesystem-wrapper">
<label className="new-folder-filesystem-label">Name on filesystem</label>
<input
type="text"
className="new-folder-input"
value={newFolderDirectoryName}
onChange={(e) => handleDirectoryNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCreateNewFolder();
}
}}
/>
</div>
)}
</div>
</div>
<button
type="button"
className="new-folder-toggle-filesystem-btn"
onClick={() => {
setShowFilesystemName(!showFilesystemName);
setNewFolderDirectoryName(sanitizeName(newFolderName));
}}
>
{showFilesystemName ? (
<>
<IconEyeOff size={16} strokeWidth={1.5} />
<span>Hide filesystem name</span>
</>
) : (
<>
<IconEye size={16} strokeWidth={1.5} />
<span>Show filesystem name</span>
</>
)}
</button>
</li>
)}
</ul>
) : (
<div className="folder-empty-state">
{searchText.trim() ? 'No folders found' : 'No folders available'}
</div>
)}
</div>
</div>
</div>
<div className="custom-modal-footer">
<div className="footer-left">
{!showNewFolderInput && (
<Button
type="button"
color="primary"
variant="ghost"
icon={<IconFolder size={16} strokeWidth={1.5} />}
onClick={handleShowNewFolder}
>
New Folder
</Button>
)}
</div>
<div className="footer-right">
<Button type="button" color="secondary" variant="ghost" onClick={handleCancel}>
Cancel
</Button>
<Button type="button" color="primary" onClick={handleConfirm}>
Save
</Button>
</div>
</div>
</Modal>
</StyledWrapper>
);
};
export default SaveTransientRequest;

View File

@@ -1,4 +1,5 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
.tabs {
@@ -27,157 +28,44 @@ const StyledWrapper = styled.div`
}
}
.section-title {
.beta-badge-corner {
position: absolute;
top: 0;
right: 0;
padding: 0.25rem 0.5rem;
font-size: 0.625rem;
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: ${(props) => props.theme.colors.text.subtext0};
margin-bottom: 0.75rem;
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};
}
.opencollection-link {
color: ${(props) => props.theme.textLink};
text-decoration: none;
.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)};
}
&:hover {
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) {
background-color: ${(props) => props.theme.background.mantle};
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,27 +1,22 @@
import React, { useState, useMemo } from 'react';
import React, { useMemo } from 'react';
import Modal from 'components/Modal';
import Button from 'ui/Button';
import { IconCheck, IconAlertTriangle, IconFileExport } from '@tabler/icons';
import { IconUpload, IconLoader2, IconAlertTriangle } 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 toast from 'react-hot-toast';
const EXPORT_FORMATS = {
ZIP: 'zip',
YAML: 'yaml',
POSTMAN: 'postman'
};
import { useApp } from 'providers/App';
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();
@@ -45,157 +40,110 @@ const ShareCollection = ({ onClose, collectionUid }) => {
};
}, [collection]);
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 handleExportYaml = () => {
const handleExportBrunoCollection = () => {
const collectionCopy = cloneDeep(collection);
exportOpenCollection(transformCollectionToSaveToExportAsFile(collectionCopy));
exportBrunoCollection(transformCollectionToSaveToExportAsFile(collectionCopy), version);
onClose();
};
const handleExportPostman = () => {
const handleExportPostmanCollection = () => {
const collectionCopy = cloneDeep(collection);
exportPostmanCollection(collectionCopy);
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 handleExportOpenCollection = () => {
const collectionCopy = cloneDeep(collection);
exportOpenCollection(transformCollectionToSaveToExportAsFile(collectionCopy), version);
onClose();
};
const isDisabled = isCollectionLoading || isExporting;
return (
<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"
>
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 */}
<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={`format-card ${selectedFormat === EXPORT_FORMATS.ZIP ? 'selected' : ''} ${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={() => !isDisabled && setSelectedFormat(EXPORT_FORMATS.ZIP)}
className={`share-button ${
isCollectionLoading
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer'
}`}
onClick={isCollectionLoading ? undefined : handleExportBrunoCollection}
>
<div className="card-header">
<span className="card-title">Bruno Collection (ZIP)</span>
<span className="recommended-badge">Recommended</span>
<div className="mr-3 p-1 rounded-full">
{isCollectionLoading ? <IconLoader2 size={28} className="animate-spin" /> : <Bruno width={28} />}
</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 className="flex-1">
<div className="font-medium">Bruno Collection</div>
<div className="text-xs">{isCollectionLoading ? 'Loading collection...' : 'Export in Bruno format'}</div>
</div>
<p className="best-for">Best for: Team collaboration, version control, publishing</p>
</div>
{/* Single File YAML Option */}
<div
className={`format-card ${selectedFormat === EXPORT_FORMATS.YAML ? 'selected' : ''} ${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={() => !isDisabled && setSelectedFormat(EXPORT_FORMATS.YAML)}
className={`share-button relative ${
isCollectionLoading
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer'
}`}
onClick={isCollectionLoading ? undefined : handleExportOpenCollection}
>
<div className="card-header">
<span className="card-title">Single File (YAML)</span>
<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>
<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 className="flex-1">
<div className="font-medium">OpenCollection</div>
<div className="text-xs">{isCollectionLoading ? 'Loading collection...' : 'Export in OpenCollection format'}</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={`other-format-card ${selectedFormat === EXPORT_FORMATS.POSTMAN ? 'selected' : ''} ${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={() => !isDisabled && setSelectedFormat(EXPORT_FORMATS.POSTMAN)}
className={`flex !flex-col share-button no-padding ${
isCollectionLoading
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer'
}`}
onClick={isCollectionLoading ? undefined : handleExportPostmanCollection}
>
<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>
{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>
</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 || !editName.trim()}
confirmDisabled={!editName.trim()}
>
<div>
<label htmlFor="renameExampleName" className="block font-medium">

View File

@@ -1,34 +1,8 @@
import { buildHarRequest } from 'utils/codegenerator/har';
import { getAuthHeaders } from 'utils/codegenerator/auth';
import { getAllVariables, getTreePathFromCollectionToItem, mergeHeaders } from 'utils/collections/index';
import { resolveInheritedAuth } from 'utils/auth';
import { get } from 'lodash';
import { interpolateAuth, interpolateHeaders, interpolateBody, interpolateParams } from './interpolation';
const addCurlAuthFlags = (curlCommand, auth) => {
if (!auth || !curlCommand) return curlCommand;
const authMode = auth.mode;
if (authMode === 'digest' || authMode === 'ntlm') {
const username = get(auth, `${authMode}.username`, '');
const password = get(auth, `${authMode}.password`, '');
const credentials = password ? `${username}:${password}` : username;
const authFlag = authMode === 'digest' ? '--digest' : '--ntlm';
// Escape single quotes for shell safety: ' becomes '\''
const escapedCredentials = credentials.replace(/'/g, `'\\''`);
const curlMatch = curlCommand.match(/^(curl(?:\.exe)?)/i);
if (curlMatch) {
const curlCmd = curlMatch[1];
const restOfCommand = curlCommand.slice(curlCmd.length);
return `${curlCmd} ${authFlag} --user '${escapedCredentials}'${restOfCommand}`;
}
}
return curlCommand;
};
const generateSnippet = ({ language, item, collection, shouldInterpolate = false }) => {
try {
// Get HTTPSnippet dynamically so mocks can be applied in tests
@@ -37,12 +11,6 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false
const variables = getAllVariables(collection, item);
const request = item.request;
let effectiveAuth = request.auth;
if (request.auth?.mode === 'inherit') {
const resolvedRequest = resolveInheritedAuth(item, collection);
effectiveAuth = resolvedRequest.auth;
}
// Get the request tree path and merge headers
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
let headers = mergeHeaders(collection, request, requestTreePath);
@@ -72,12 +40,7 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false
// Generate snippet using HTTPSnippet
const snippet = new HTTPSnippet(harRequest);
let result = snippet.convert(language.target, language.client);
// For curl target, add special auth flags for digest/ntlm
if (language.target === 'shell' && language.client === 'curl') {
result = addCurlAuthFlags(result, effectiveAuth);
}
const result = snippet.convert(language.target, language.client);
return result;
} catch (error) {

View File

@@ -827,82 +827,3 @@ describe('generateSnippet with OAuth2 authentication', () => {
);
});
});
describe('generateSnippet digest and NTLM auth curl export', () => {
const language = { target: 'shell', client: 'curl' };
const baseCollection = {
root: {
request: {
headers: [],
auth: { mode: 'none' }
}
}
};
it('should add --digest flag and --user for digest auth', () => {
const item = {
uid: 'digest-req',
request: {
method: 'GET',
url: 'https://example.com/api',
headers: [],
body: { mode: 'none' },
auth: {
mode: 'digest',
digest: {
username: 'myuser',
password: 'mypass'
}
}
}
};
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toMatch(/^curl --digest --user 'myuser:mypass'/);
});
it('should add --ntlm flag and --user for NTLM auth', () => {
const item = {
uid: 'ntlm-req',
request: {
method: 'GET',
url: 'https://example.com/api',
headers: [],
body: { mode: 'none' },
auth: {
mode: 'ntlm',
ntlm: {
username: 'myuser',
password: 'mypass'
}
}
}
};
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toMatch(/^curl --ntlm --user 'myuser:mypass'/);
});
it('should handle digest auth with username only (no password)', () => {
const item = {
uid: 'digest-no-pass',
request: {
method: 'GET',
url: 'https://example.com/api',
headers: [],
body: { mode: 'none' },
auth: {
mode: 'digest',
digest: {
username: 'myuser',
password: ''
}
}
}
};
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toMatch(/^curl --digest --user 'myuser'/);
});
});

View File

@@ -502,8 +502,8 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
setCreateExampleModalOpen(false);
};
const folderItems = sortByNameThenSequence(filter(item.items, (i) => isItemAFolder(i) && !i.isTransient));
const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i) && !i.isTransient));
const folderItems = sortByNameThenSequence(filter(item.items, (i) => isItemAFolder(i)));
const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i)));
const handleGenerateCode = () => {
if (
@@ -535,7 +535,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
const handleCopyItem = () => {
dispatch(copyRequest(item));
const itemType = isFolder ? 'Folder' : 'Request';
toast.success(`${itemType} copied`);
toast.success(`${itemType} copied to clipboard`);
};
const handlePasteItem = () => {

View File

@@ -1,89 +1,49 @@
import React, { useMemo } from 'react';
import React from 'react';
import filter from 'lodash/filter';
import { useDispatch, useSelector } from 'react-redux';
import { flattenItems, isItemARequest, hasRequestChanges, findCollectionByUid } from 'utils/collections';
import { useDispatch } from 'react-redux';
import { flattenItems, isItemARequest, hasRequestChanges } from 'utils/collections';
import { pluralizeWord } from 'utils/common';
import { saveRequest, saveMultipleRequests } from 'providers/ReduxStore/slices/collections/actions';
import { saveMultipleRequests } from 'providers/ReduxStore/slices/collections/actions';
import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
import { removeCollection } from 'providers/ReduxStore/slices/collections/actions';
import { IconAlertTriangle, IconDeviceFloppy } from '@tabler/icons';
import { IconAlertTriangle } from '@tabler/icons';
import Modal from 'components/Modal';
import toast from 'react-hot-toast';
import Button from 'ui/Button';
const MAX_UNSAVED_REQUESTS_TO_SHOW = 5;
const ConfirmCollectionCloseDrafts = ({ onClose, collection, collectionUid }) => {
const MAX_UNSAVED_REQUESTS_TO_SHOW = 5;
const dispatch = useDispatch();
const latestCollection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid));
const activeCollection = latestCollection || collection;
const currentDrafts = useMemo(() => {
if (!activeCollection) return [];
const items = flattenItems(activeCollection.items);
return items
?.filter((item) => isItemARequest(item) && hasRequestChanges(item) && !item.isTransient)
.map((item) => {
return {
...item,
collectionUid: collectionUid
};
});
}, [activeCollection, collectionUid]);
const currentTransientDrafts = useMemo(() => {
if (!activeCollection) return [];
const items = flattenItems(activeCollection.items);
return items
?.filter((item) => isItemARequest(item) && hasRequestChanges(item) && item.isTransient)
.map((item) => {
return {
...item,
collectionUid: collectionUid
};
});
}, [activeCollection, collectionUid]);
const allDrafts = useMemo(() => {
return [...currentDrafts, ...currentTransientDrafts];
}, [currentDrafts, currentTransientDrafts]);
// Get all draft items in the collection
const currentDrafts = React.useMemo(() => {
if (!collection) return [];
const items = flattenItems(collection.items);
const collectionDrafts = filter(items, (item) => isItemARequest(item) && hasRequestChanges(item));
return collectionDrafts.map((draft) => ({
...draft,
collectionUid: collectionUid
}));
}, [collection, collectionUid]);
const handleSaveAll = () => {
// If there are transient drafts, we can't proceed with batch save
if (currentTransientDrafts.length > 0) {
toast.error('Please save or discard transient requests first');
return;
}
// Save only non-transient drafts
if (currentDrafts.length > 0) {
dispatch(saveMultipleRequests(currentDrafts))
.then(() => {
dispatch(removeCollection(collectionUid))
.then(() => {
toast.success('Collection removed from workspace');
onClose();
})
.catch(() => toast.error('An error occurred while removing the collection'));
})
.catch(() => {
toast.error('Failed to save requests!');
});
} else {
// No non-transient drafts, just remove the collection
dispatch(removeCollection(collectionUid))
.then(() => {
toast.success('Collection removed from workspace');
onClose();
})
.catch(() => toast.error('An error occurred while removing the collection'));
}
dispatch(saveMultipleRequests(currentDrafts))
.then(() => {
dispatch(removeCollection(collectionUid))
.then(() => {
toast.success('Collection removed from workspace');
onClose();
})
.catch(() => toast.error('An error occurred while removing the collection'));
})
.catch(() => {
toast.error('Failed to save requests!');
});
};
const handleDiscardAll = () => {
// Discard all drafts (both regular and transient)
allDrafts.forEach((draft) => {
// Discard all drafts
currentDrafts.forEach((draft) => {
dispatch(deleteRequestDraft({
collectionUid: collectionUid,
itemUid: draft.uid
@@ -99,11 +59,7 @@ const ConfirmCollectionCloseDrafts = ({ onClose, collection, collectionUid }) =>
.catch(() => toast.error('An error occurred while removing the collection'));
};
const handleSaveTransient = (draft) => {
dispatch(saveRequest(draft.uid, collectionUid));
};
if (!currentDrafts.length && !currentTransientDrafts.length) {
if (!currentDrafts.length) {
return null;
}
@@ -124,82 +80,38 @@ const ConfirmCollectionCloseDrafts = ({ onClose, collection, collectionUid }) =>
<h1 className="ml-2 text-lg font-medium">Hold on..</h1>
</div>
<p className="mt-4">
You have unsaved changes in <span className="font-medium">{allDrafts.length}</span>{' '}
{pluralizeWord('request', allDrafts.length)}.
Do you want to save the changes you made to the following{' '}
<span className="font-medium">{currentDrafts.length}</span> {pluralizeWord('request', currentDrafts.length)}?
</p>
{/* Regular (saved) requests with changes */}
{currentDrafts.length > 0 && (
<div className="mt-4">
<p className="text-sm font-medium mb-2">
Saved {pluralizeWord('Request', currentDrafts.length)} ({currentDrafts.length})
</p>
<ul className="ml-2">
{currentDrafts.slice(0, MAX_UNSAVED_REQUESTS_TO_SHOW).map((item) => {
return (
<li key={item.uid} className="mt-1 text-xs text-gray-600">
{item.filename || item.name}
</li>
);
})}
</ul>
{currentDrafts.length > MAX_UNSAVED_REQUESTS_TO_SHOW && (
<p className="ml-2 mt-1 text-xs text-gray-500">
...{currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW} additional{' '}
{pluralizeWord('request', currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW)} not shown
</p>
)}
</div>
)}
<ul className="mt-4">
{currentDrafts.slice(0, MAX_UNSAVED_REQUESTS_TO_SHOW).map((item) => {
return (
<li key={item.uid} className="mt-1 text-xs">
{item.filename}
</li>
);
})}
</ul>
{/* Transient (unsaved) requests */}
{currentTransientDrafts.length > 0 && (
<div className="mt-4">
<p className="text-sm font-medium mb-2">
Transient {pluralizeWord('Request', currentTransientDrafts.length)} ({currentTransientDrafts.length})
</p>
<p className="text-xs text-orange-600 mb-3">
These requests need to be saved individually before closing the collection.
</p>
<div className="space-y-2 max-h-64 overflow-y-auto pr-1">
{currentTransientDrafts.map((item) => {
return (
<div
key={item.uid}
className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded border border-gray-200"
>
<span className="text-sm text-gray-700 truncate mr-3">{item.name}</span>
<Button
color="primary"
variant="ghost"
size="sm"
onClick={() => handleSaveTransient(item)}
icon={<IconDeviceFloppy size={14} strokeWidth={1.5} />}
>
Save
</Button>
</div>
);
})}
</div>
</div>
{currentDrafts.length > MAX_UNSAVED_REQUESTS_TO_SHOW && (
<p className="mt-1 text-xs">
...{currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW} additional{' '}
{pluralizeWord('request', currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW)} not shown
</p>
)}
<div className="flex justify-between mt-6">
<div>
<Button color="danger" onClick={handleDiscardAll}>
Discard All and Remove
Discard and Remove
</Button>
</div>
<div>
<Button className="mr-2" color="secondary" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
onClick={handleSaveAll}
disabled={currentTransientDrafts.length > 0}
title={currentTransientDrafts.length > 0 ? 'Please save or discard transient requests first' : ''}
>
<Button onClick={handleSaveAll}>
{currentDrafts.length > 1 ? 'Save All and Remove' : 'Save and Remove'}
</Button>
</div>

View File

@@ -276,8 +276,8 @@ const Collection = ({ collection, searchText }) => {
return items.sort((a, b) => a.seq - b.seq);
};
const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i) && !i.isTransient));
const folderItems = sortByNameThenSequence(filter(collection.items, (i) => isItemAFolder(i) && !i.isTransient));
const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i)));
const folderItems = sortByNameThenSequence(filter(collection.items, (i) => isItemAFolder(i)));
const menuItems = [
{

View File

@@ -1,10 +1,28 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
.advanced-options {
.caret {
color: ${(props) => props.theme.textLink};
fill: ${(props) => props.theme.textLink};
.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;
}
}

View File

@@ -1,4 +1,4 @@
import React, { useRef, useEffect, useState, forwardRef } from 'react';
import React, { useRef, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useFormik } from 'formik';
import * as Yup from 'yup';
@@ -8,12 +8,11 @@ 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 { IconArrowBackUp, IconEdit, IconCaretDown } from '@tabler/icons';
import { useState } from 'react';
import { IconArrowBackUp, IconEdit, IconExternalLink } 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';
@@ -24,11 +23,7 @@ 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';
@@ -40,7 +35,7 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) =>
collectionName: '',
collectionFolderName: '',
collectionLocation: defaultLocation || '',
format: DEFAULT_COLLECTION_FORMAT
format: 'bru'
},
validationSchema: Yup.object({
collectionName: Yup.string()
@@ -91,20 +86,6 @@ 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>
@@ -228,61 +209,62 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) =>
</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-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 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>
<div className="flex justify-end">
<Button type="button" color="secondary" variant="ghost" onClick={onClose} className="mr-2">
</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}>
Cancel
</Button>
</span>
<span>
<Button type="submit">
Create
</Button>
</div>
</span>
</div>
</form>
</Modal>

View File

@@ -16,7 +16,6 @@ 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) => {
@@ -93,7 +92,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
const inputRef = useRef();
const dispatch = useDispatch();
const [groupingType, setGroupingType] = useState('tags');
const [collectionFormat, setCollectionFormat] = useState(DEFAULT_COLLECTION_FORMAT);
const [collectionFormat, setCollectionFormat] = useState('bru');
const dropdownTippyRef = useRef();
const isOpenApi = format === 'openapi';

View File

@@ -1,22 +1,11 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
const Wrapper = styled.div`
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
.raw-editor-container {
flex: 1;
overflow: hidden;
border-radius: 8px;
border: solid 1px ${(props) => props.theme.border.border0};
.CodeMirror {
font-size: ${(props) => props.theme.font.size.base};
}
}
.table-container {
overflow-y: auto;
border-radius: 8px;
@@ -27,20 +16,24 @@ const StyledWrapper = styled.div`
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
td {
vertical-align: middle;
padding: 2px 10px;
&:first-child {
width: 35%;
&:nth-child(1) {
width: 25px;
border-right: none;
}
&:nth-child(4) {
width: 80px;
}
&:nth-child(5) {
width: 60px;
}
&.delete-col {
width: 40px;
text-align: center;
padding: 2px 4px;
&:nth-child(2) {
width: 30%;
}
}
@@ -49,30 +42,30 @@ const StyledWrapper = 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;
}
@@ -108,78 +101,12 @@ const StyledWrapper = 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 StyledWrapper;
export default Wrapper;

View File

@@ -1,54 +1,456 @@
import React, { useCallback, useRef, useMemo } from 'react';
import React, { useCallback, useRef } 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 EnvironmentVariablesTable from 'components/EnvironmentVariablesTable';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables } from 'utils/collections';
import Button from 'ui/Button';
const EnvironmentVariables = ({ environment, setIsModified, collection, searchQuery = '' }) => {
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 dispatch = useDispatch();
const { globalEnvironmentDraft } = useSelector((state) => state.globalEnvironments);
const { storedTheme } = useTheme();
const { globalEnvironments, activeGlobalEnvironmentUid, globalEnvironmentDraft } = useSelector(
(state) => state.globalEnvironments
);
const hasDraftForThisEnv = globalEnvironmentDraft?.environmentUid === environment.uid;
const handleSave = useCallback(
(variables) => {
return dispatch(saveGlobalEnvironment({ environmentUid: environment.uid, variables: cloneDeep(variables) }));
},
[dispatch, environment.uid]
);
const [tableHeight, setTableHeight] = React.useState(MIN_H);
const handleDraftChange = useCallback(
(variables) => {
dispatch(
setGlobalEnvironmentDraft({
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 : {};
},
onSubmit: () => {}
});
// 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({
environmentUid: environment.uid,
variables
})
);
},
[dispatch, environment.uid]
);
variables: currentValues
}));
}
} else if (hasDraftForThisEnv) {
dispatch(clearGlobalEnvironmentDraft());
}
}, [formik.values, environment.variables, environment.uid, setIsModified, dispatch, hasDraftForThisEnv, globalEnvironmentDraft?.variables]);
const handleDraftClear = useCallback(() => {
dispatch(clearGlobalEnvironmentDraft());
}, [dispatch]);
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);
};
}, []);
return (
<EnvironmentVariablesTable
environment={environment}
collection={collection}
onSave={handleSave}
draft={hasDraftForThisEnv ? globalEnvironmentDraft : null}
onDraftChange={handleDraftChange}
onDraftClear={handleDraftClear}
setIsModified={setIsModified}
searchQuery={searchQuery}
/>
<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>
);
};

View File

@@ -94,63 +94,8 @@ const StyledWrapper = styled.div`
.actions {
display: flex;
align-items: center;
gap: 2px;
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
.search-icon {
position: absolute;
left: 8px;
color: ${(props) => props.theme.colors.text.muted};
pointer-events: none;
}
.search-input {
width: 200px;
padding: 5px 32px 5px 32px;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.sm};
background: ${(props) => props.theme.input.bg};
color: ${(props) => props.theme.text};
font-size: ${(props) => props.theme.font.size.base};
outline: none;
transition: border-color 0.15s ease;
&:focus {
border-color: ${(props) => props.theme.input.focusBorder};
}
&::placeholder {
color: ${(props) => props.theme.input.placeholder.color};
opacity: ${(props) => props.theme.input.placeholder.opacity};
}
}
.clear-search {
position: absolute;
right: 1px;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
cursor: pointer;
border-radius: ${(props) => props.theme.border.radius.sm};
transition: all 0.15s ease;
&:hover {
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
}
}
button {
display: inline-flex;
align-items: center;

View File

@@ -1,14 +1,12 @@
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch } from '@tabler/icons';
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX } from '@tabler/icons';
import { useState, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import useDebounce from 'hooks/useDebounce';
import { renameGlobalEnvironment, updateGlobalEnvironmentColor } from 'providers/ReduxStore/slices/global-environments';
import { renameGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
import CopyEnvironment from '../../CopyEnvironment';
import DeleteEnvironment from '../../DeleteEnvironment';
import EnvironmentVariables from './EnvironmentVariables';
import ColorPicker from 'components/ColorPicker';
import StyledWrapper from './StyledWrapper';
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
@@ -20,11 +18,7 @@ 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() === '') {
@@ -116,33 +110,6 @@ 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(() => {
toast.success('Environment color updated!');
})
.catch(() => {
toast.error('An error occurred while updating the environment color');
});
};
return (
<StyledWrapper>
{openDeleteModal && (
@@ -192,46 +159,11 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
</div>
</>
) : (
<div className="flex items-center gap-2">
<h2 className="title">{environment.name}</h2>
<ColorPicker color={environment.color} onChange={handleColorChange} />
</div>
<h2 className="title">{environment.name}</h2>
)}
</div>
{nameError && isRenaming && <div className="title-error">{nameError}</div>}
<div className="actions">
{isSearchExpanded ? (
<div className="search-input-wrapper">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
ref={searchInputRef}
type="text"
placeholder="Search variables..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onBlur={handleSearchBlur}
className="search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{searchQuery && (
<button
className="clear-search"
onClick={handleClearSearch}
onMouseDown={(e) => e.preventDefault()}
title="Clear search"
>
<IconX size={14} strokeWidth={1.5} />
</button>
)}
</div>
) : (
<button onClick={handleSearchIconClick} title="Search variables">
<IconSearch size={15} strokeWidth={1.5} />
</button>
)}
<button onClick={handleRenameClick} title="Rename">
<IconEdit size={15} strokeWidth={1.5} />
</button>
@@ -245,12 +177,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
</div>
<div className="content">
<EnvironmentVariables
environment={environment}
setIsModified={setIsModified}
collection={collection}
searchQuery={debouncedSearchQuery}
/>
<EnvironmentVariables environment={environment} setIsModified={setIsModified} collection={collection} />
</div>
</StyledWrapper>
);

View File

@@ -99,37 +99,10 @@ const StyledWrapper = styled.div`
}
}
.sections-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0 8px;
}
.environments-list {
flex: 1;
overflow-y: auto;
padding: 0 4px;
}
.btn-action {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
color: ${(props) => props.theme.text};
}
padding: 0 8px;
}
.environment-item {
@@ -137,7 +110,6 @@ const StyledWrapper = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px 8px;
margin-bottom: 1px;
font-size: 13px;
@@ -255,46 +227,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)};
}
}
}
}
@@ -308,39 +280,6 @@ const StyledWrapper = styled.div`
background: ${(props) => `${props.theme.colors.text.danger}15`};
border-radius: 4px;
}
.no-env-file {
padding: 8px 12px;
font-size: 12px;
color: ${(props) => props.theme.colors.text.muted};
font-style: italic;
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 10%;
color: ${(props) => props.theme.colors.text.muted};
svg {
opacity: 0.3;
margin-bottom: 8px;
}
.title {
font-size: 13px;
font-weight: 500;
margin-bottom: 12px;
color: ${(props) => props.theme.colors.text.muted};
}
.actions {
display: flex;
gap: 8px;
}
}
`;
export default StyledWrapper;

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