Compare commits

..

1 Commits

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-02 19:06:25 +00:00
390 changed files with 3589 additions and 16897 deletions

View File

@@ -9,7 +9,6 @@ on:
permissions:
contents: read
pull-requests: write
issues: write
checks: write
jobs:
@@ -73,7 +72,7 @@ jobs:
- name: Post PR comment
if: hashFiles('pr-comment.md') != ''
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const fs = require('fs');

1
.gitignore vendored
View File

@@ -49,7 +49,6 @@ bruno.iml
.idea
.vscode
.cursor
.claude
# Playwright
/blob-report/

1247
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -100,7 +100,6 @@
}
},
"dependencies": {
"ajv": "^8.17.1",
"git-url-parse": "^14.1.0"
"ajv": "^8.17.1"
}
}

View File

@@ -88,7 +88,7 @@
"shell-quote": "^1.8.3",
"strip-json-comments": "^5.0.1",
"styled-components": "^5.3.3",
"swagger-ui-react": "^5.31.0",
"swagger-ui-react": "5.17.12",
"system": "^2.0.1",
"url": "^0.11.3",
"xml-formatter": "^3.5.0",
@@ -130,4 +130,4 @@
"form-data": "4.0.4"
}
}
}
}

View File

@@ -4,11 +4,10 @@ import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import { useDispatch, useSelector } from 'react-redux';
import { savePreferences, showManageWorkspacePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { savePreferences, showHomePage, showManageWorkspacePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { closeConsole, openConsole } from 'providers/ReduxStore/slices/logs';
import { openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import { sortWorkspaces, toggleWorkspacePin } from 'utils/workspaces';
import { focusTab } from 'providers/ReduxStore/slices/tabs';
import Bruno from 'components/Bruno';
import MenuDropdown from 'ui/MenuDropdown';
@@ -130,10 +129,7 @@ const AppTitleBar = () => {
});
const handleHomeClick = () => {
const scratchCollectionUid = activeWorkspace?.scratchCollectionUid;
if (scratchCollectionUid) {
dispatch(focusTab({ uid: `${scratchCollectionUid}-overview` }));
}
dispatch(showHomePage());
};
const handleWorkspaceSwitch = (workspaceUid) => {

View File

@@ -233,17 +233,10 @@ export default class CodeEditor extends React.Component {
CodeMirror.signal(this.editor, 'change', this.editor);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
const nextValue = this.props.value ?? '';
const currentValue = this.editor.getValue();
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
this.cachedValue = currentValue;
} else {
const cursor = this.editor.getCursor();
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
this.editor.setCursor(cursor);
}
const cursor = this.editor.getCursor();
this.cachedValue = this.props.value;
this.editor.setValue(this.props.value);
this.editor.setCursor(cursor);
}
if (this.editor) {

View File

@@ -7,7 +7,6 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
import StatusDot from 'components/StatusDot';
import { flattenItems, isItemARequest } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
@@ -74,10 +73,6 @@ const Script = ({ collection }) => {
dispatch(saveCollectionSettings(collection.uid));
};
const items = flattenItems(collection.items || []);
const hasPreRequestScriptError = items.some((i) => isItemARequest(i) && i.preRequestScriptErrorMessage);
const hasPostResponseScriptError = items.some((i) => isItemARequest(i) && i.postResponseScriptErrorMessage);
return (
<StyledWrapper className="w-full flex flex-col h-full">
<div className="text-xs mb-4 text-muted">
@@ -88,15 +83,11 @@ const Script = ({ collection }) => {
<TabsList>
<TabsTrigger value="pre-request">
Pre Request
{requestScript && requestScript.trim().length > 0 && (
<StatusDot type={hasPreRequestScriptError ? 'error' : 'default'} />
)}
{requestScript && requestScript.trim().length > 0 && <StatusDot />}
</TabsTrigger>
<TabsTrigger value="post-response">
Post Response
{responseScript && responseScript.trim().length > 0 && (
<StatusDot type={hasPostResponseScriptError ? 'error' : 'default'} />
)}
{responseScript && responseScript.trim().length > 0 && <StatusDot />}
</TabsTrigger>
</TabsList>

View File

@@ -1,17 +1,21 @@
import React from 'react';
import { useTheme } from 'providers/Theme';
const ColorBadge = ({ color, size = 10 }) => {
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'
backgroundColor: color || 'transparent',
border: showBorder ? '1px solid' : 'none',
borderColor: showBorder ? theme.background.surface1 : 'transparent'
}}
/>
);

View File

@@ -134,15 +134,15 @@ const ColorPicker = ({ color, onChange, icon }) => {
))}
</div>
<div className="flex items-center gap-2 mt-2 pt-0.5">
<div className="flex items-center gap-2 mt-2 pt-2">
<div
className="w-5 h-5 rounded-full flex-shrink-0 cursor-pointer"
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 flex"
className="flex-1"
value={sliderPosition}
onChange={handleSliderChange}
onMouseUp={handleSliderEnd}

View File

@@ -4,7 +4,6 @@ const StyledWrapper = styled.div`
.hue-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border-radius: 2px;
outline: none;

View File

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

View File

@@ -9,7 +9,6 @@ import { useDispatch, useSelector } from 'react-redux';
import { flattenItems, isItemARequest, isItemTransientRequest } from 'utils/collections';
import filter from 'lodash/filter';
import { get } from 'lodash';
import { formatIpcError } from 'utils/common/error';
const REQUEST_TYPE = {
HTTP: 'http',
@@ -58,7 +57,7 @@ const CreateTransientRequest = ({ collectionUid }) => {
const collection = useMemo(() => {
return collections?.find((c) => c.uid === collectionUid);
}, [collections, collectionUid]);
}, [collections]);
const collectionPresets = useMemo(() => {
return get(collection, collection?.draft?.brunoConfig ? 'draft.brunoConfig.presets' : 'brunoConfig.presets', {
@@ -104,7 +103,7 @@ const CreateTransientRequest = ({ collectionUid }) => {
itemUid: null,
isTransient: true
})
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
}, [dispatch, collection, collectionPresets.requestUrl]);
const handleCreateGraphQLRequest = useCallback(() => {
@@ -131,7 +130,7 @@ const CreateTransientRequest = ({ collectionUid }) => {
}
}
})
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
}, [dispatch, collection, collectionPresets.requestUrl]);
const handleCreateWebSocketRequest = useCallback(() => {
@@ -150,7 +149,7 @@ const CreateTransientRequest = ({ collectionUid }) => {
itemUid: null,
isTransient: true
})
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
}, [dispatch, collection, collectionPresets.requestUrl]);
const handleCreateGrpcRequest = useCallback(() => {
@@ -168,7 +167,7 @@ const CreateTransientRequest = ({ collectionUid }) => {
itemUid: null,
isTransient: true
})
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
}, [dispatch, collection, collectionPresets.requestUrl]);
const handleItemClick = (type) => {

View File

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

View File

@@ -85,17 +85,6 @@ const Wrapper = styled.div`
justify-content: center;
}
.dropdown-tab-count {
margin-left: auto;
font-size: 11px;
font-weight: 500;
padding: 1px 6px;
border-radius: 10px;
background: ${(props) => props.theme.dropdown.hoverBg};
min-width: 18px;
text-align: center;
}
&:hover:not(:disabled):not(.disabled) {
background-color: ${(props) => props.theme.dropdown.hoverBg};
}

View File

@@ -129,7 +129,6 @@ const StyledWrapper = styled.div`
text-align: center;
vertical-align: middle;
line-height: 1;
text-overflow: clip;
input[type='checkbox'] {
vertical-align: baseline;
@@ -139,9 +138,6 @@ const StyledWrapper = styled.div`
.tooltip-mod {
max-width: 200px !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
white-space: normal !important;
}
input[type='text'] {

View File

@@ -13,7 +13,6 @@ import { variableNameRegex } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables } from 'utils/collections';
import { stripEnvVarUid } from 'utils/environments';
const MIN_H = 35 * 2;
const MIN_COLUMN_WIDTH = 80;
@@ -89,11 +88,10 @@ const EnvironmentVariablesTable = ({
}, []);
const handleTotalHeightChanged = useCallback((h) => {
setTableHeight(h + 2);
setTableHeight(h);
}, []);
const prevEnvUidRef = useRef(null);
const prevEnvVariablesRef = useRef(environment.variables);
const mountedRef = useRef(false);
let _collection = collection ? cloneDeep(collection) : {};
@@ -169,13 +167,11 @@ const EnvironmentVariablesTable = ({
useEffect(() => {
const isMount = !mountedRef.current;
const envChanged = prevEnvUidRef.current !== null && prevEnvUidRef.current !== environment.uid;
const variablesReloaded = !isMount && !envChanged && prevEnvVariablesRef.current !== environment.variables;
prevEnvUidRef.current = environment.uid;
prevEnvVariablesRef.current = environment.variables;
mountedRef.current = true;
if ((isMount || envChanged || variablesReloaded) && hasDraftForThisEnv && draft?.variables) {
if ((isMount || envChanged) && hasDraftForThisEnv && draft?.variables) {
formik.setValues([
...draft.variables,
{
@@ -188,16 +184,16 @@ const EnvironmentVariablesTable = ({
}
]);
}
}, [environment.uid, environment.variables, hasDraftForThisEnv, draft?.variables]);
}, [environment.uid, hasDraftForThisEnv, draft?.variables]);
const savedValuesJson = useMemo(() => {
return JSON.stringify((environment.variables || []).map(stripEnvVarUid));
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.map(stripEnvVarUid));
const currentValuesJson = JSON.stringify(currentValues);
const hasActualChanges = currentValuesJson !== savedValuesJson;
setIsModified(hasActualChanges);
}, [formik.values, savedValuesJson, setIsModified]);
@@ -206,11 +202,11 @@ const EnvironmentVariablesTable = ({
useEffect(() => {
const timeoutId = setTimeout(() => {
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const currentValuesJson = JSON.stringify(currentValues.map(stripEnvVarUid));
const currentValuesJson = JSON.stringify(currentValues);
const hasActualChanges = currentValuesJson !== savedValuesJson;
const existingDraftVariables = hasDraftForThisEnv ? draft?.variables : null;
const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables.map(stripEnvVarUid)) : null;
const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables) : null;
if (hasActualChanges) {
if (currentValuesJson !== existingDraftJson) {
@@ -322,8 +318,7 @@ const EnvironmentVariablesTable = ({
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const savedValues = environment.variables || [];
// Compare without UIDs since they can be different but the actual data is the same
const hasChanges = JSON.stringify(variablesToSave.map(stripEnvVarUid)) !== JSON.stringify(savedValues.map(stripEnvVarUid));
const hasChanges = JSON.stringify(variablesToSave) !== JSON.stringify(savedValues);
if (!hasChanges) {
toast.error('No changes to save');
return;
@@ -446,8 +441,8 @@ const EnvironmentVariablesTable = ({
</tr>
)}
fixedItemHeight={35}
computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`}
itemContent={(virtualIndex, { variable, index: actualIndex }) => {
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;
@@ -477,7 +472,7 @@ const EnvironmentVariablesTable = ({
id={`${actualIndex}.name`}
name={`${actualIndex}.name`}
value={variable.name}
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? '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)}
@@ -495,14 +490,7 @@ const EnvironmentVariablesTable = ({
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => {
formik.setFieldValue(`${actualIndex}.value`, newValue, true);
// Clear ephemeral metadata when user manually edits the value
if (variable.ephemeral) {
formik.setFieldValue(`${actualIndex}.ephemeral`, undefined, false);
formik.setFieldValue(`${actualIndex}.persistedValue`, undefined, false);
}
}}
onChange={(newValue) => formik.setFieldValue(`${actualIndex}.value`, newValue, true)}
onSave={handleSave}
/>
</div>

View File

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

View File

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

View File

@@ -28,7 +28,7 @@ const DotEnvTableView = ({
isSaving
}) => {
const handleTotalHeightChanged = useCallback((h) => {
onHeightChange(h + 2);
onHeightChange(h);
}, [onHeightChange]);
// Use refs for stable access to formik values in callbacks

View File

@@ -217,12 +217,10 @@ const DotEnvFileEditor = ({
];
formik.resetForm({ values: newValues });
setIsModified(false);
window.dispatchEvent(new Event('dotenv-save-complete'));
})
.catch((error) => {
console.error(error);
toast.error('An error occurred while saving the changes');
window.dispatchEvent(new Event('dotenv-save-failed'));
})
.finally(() => {
setIsSaving(false);
@@ -242,12 +240,10 @@ const DotEnvFileEditor = ({
.then(() => {
toast.success('Changes saved successfully');
setIsModified(false);
window.dispatchEvent(new Event('dotenv-save-complete'));
})
.catch((error) => {
console.error(error);
toast.error('An error occurred while saving the changes');
window.dispatchEvent(new Event('dotenv-save-failed'));
})
.finally(() => {
setIsSaving(false);

View File

@@ -39,7 +39,7 @@ const EnvironmentListContent = ({
data-tooltip-content={env.name}
data-tooltip-hidden={env.name?.length < 90}
>
<ColorBadge color={env.color} size={8} />
<ColorBadge color={env.color} size={8} showEmptyBorder={false} />
<span className="max-w-100% truncate no-wrap">{env.name}</span>
</div>
))}

View File

@@ -135,7 +135,13 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
};
const handleColorChange = (color) => {
dispatch(updateEnvironmentColor(environment.uid, color, collection.uid));
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 (

View File

@@ -22,7 +22,6 @@ import {
createDotEnvFile,
deleteDotEnvFile
} from 'providers/ReduxStore/slices/collections/actions';
import { setEnvironmentsDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
import classnames from 'classnames';
@@ -73,24 +72,11 @@ const EnvironmentList = ({
const envUids = environments ? environments.map((env) => env.uid) : [];
const prevEnvUids = usePrevious(envUids);
const handleDotEnvModifiedChange = useCallback((modified) => {
setIsDotEnvModified(modified);
if (modified) {
dispatch(setEnvironmentsDraft({
collectionUid: collection.uid,
environmentUid: `dotenv:${selectedDotEnvFile}`,
variables: []
}));
} else {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
}
}, [dispatch, collection.uid, selectedDotEnvFile]);
useEffect(() => {
if (dotEnvFiles.length === 0) {
setSelectedDotEnvFile(null);
setActiveView('environment');
handleDotEnvModifiedChange(false);
setIsDotEnvModified(false);
return;
}
@@ -438,7 +424,7 @@ const EnvironmentList = ({
dispatch(deleteDotEnvFile(collection.uid, filename))
.then(() => {
toast.success(`${filename} file deleted!`);
handleDotEnvModifiedChange(false);
setIsDotEnvModified(false);
if (selectedDotEnvFile === filename) {
const remainingFiles = dotEnvFiles.filter((f) => f.filename !== filename);
if (remainingFiles.length > 0) {
@@ -481,7 +467,7 @@ const EnvironmentList = ({
onSave={handleSaveDotEnv}
onSaveRaw={handleSaveDotEnvRaw}
isModified={isDotEnvModified}
setIsModified={handleDotEnvModifiedChange}
setIsModified={setIsDotEnvModified}
dotEnvExists={selectedDotEnvData?.exists}
viewMode={dotEnvViewMode}
collection={collection}

View File

@@ -34,36 +34,23 @@ class ErrorBoundary extends Component {
const serializeArgs = (args) => {
return args.map((arg) => {
const seen = new WeakSet();
const replacer = (key, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
return '[Circular Reference]';
}
seen.add(value);
if (value instanceof Error || Object.prototype.toString.call(value) === '[object Error]' || (typeof value.message === 'string' && typeof value.stack === 'string')) {
const error = {};
Object.getOwnPropertyNames(value).forEach((prop) => {
error[prop] = value[prop];
});
return error;
}
}
return value;
};
try {
if (arg === null) return 'null';
if (arg === undefined) return 'undefined';
if (typeof arg === 'string' || typeof arg === 'number' || typeof arg === 'boolean') {
return arg;
}
if (arg instanceof Error) {
return {
__type: 'Error',
name: arg.name,
message: arg.message,
stack: arg.stack
};
}
if (typeof arg === 'object') {
try {
return JSON.parse(JSON.stringify(arg, replacer));
return JSON.parse(JSON.stringify(arg));
} catch {
return String(arg);
}

View File

@@ -1,10 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.danger};
pre {
color: ${(props) => props.theme.colors.danger};
}
`;
export default StyledWrapper;

View File

@@ -1,34 +0,0 @@
import React from 'react';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import { useState } from 'react';
import StyledWrapper from './StyledWrapper';
const IpcErrorModal = ({ error }) => {
const [showModal, setShowModal] = useState(true);
return (
<>
{showModal ? (
<StyledWrapper>
<Portal>
<Modal
size="sm"
title="Error"
hideFooter={true}
hideCancel={true}
handleCancel={() => {
setShowModal(false);
}}
disableCloseOnOutsideClick={true}
disableEscapeKey={true}
>
<pre className="w-full flex flex-wrap whitespace-pre-wrap">{error}</pre>
</Modal>
</Portal>
</StyledWrapper>
) : null}
</>
);
};
export default IpcErrorModal;

View File

@@ -7,7 +7,6 @@ import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
import StatusDot from 'components/StatusDot';
import { flattenItems, isItemARequest } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
@@ -76,10 +75,6 @@ const Script = ({ collection, folder }) => {
dispatch(saveFolderRoot(collection.uid, folder.uid));
};
const items = flattenItems(folder.items || []);
const hasPreRequestScriptError = items.some((i) => isItemARequest(i) && i.preRequestScriptErrorMessage);
const hasPostResponseScriptError = items.some((i) => isItemARequest(i) && i.postResponseScriptErrorMessage);
return (
<StyledWrapper className="w-full flex flex-col h-full">
<div className="text-xs mb-4 text-muted">
@@ -90,15 +85,11 @@ const Script = ({ collection, folder }) => {
<TabsList>
<TabsTrigger value="pre-request">
Pre Request
{requestScript && requestScript.trim().length > 0 && (
<StatusDot type={hasPreRequestScriptError ? 'error' : 'default'} />
)}
{requestScript && requestScript.trim().length > 0 && <StatusDot />}
</TabsTrigger>
<TabsTrigger value="post-response">
Post Response
{responseScript && responseScript.trim().length > 0 && (
<StatusDot type={hasPostResponseScriptError ? 'error' : 'default'} />
)}
{responseScript && responseScript.trim().length > 0 && <StatusDot />}
</TabsTrigger>
</TabsList>

View File

@@ -1,62 +0,0 @@
import React from 'react';
import Modal from 'components/Modal/index';
import Portal from 'components/Portal/index';
const getOSName = () => {
const platform = window.navigator.userAgentData?.platform || '';
if (platform.startsWith('Win')) {
return 'Windows';
} else if (platform.startsWith('Mac')) {
return 'macOS';
} else if (platform.startsWith('Linux')) {
return 'Linux';
} else {
return 'your OS';
}
};
const getDownloadUrl = (os) => {
switch (os) {
case 'Windows':
return 'https://git-scm.com/download/win';
case 'macOS':
return 'https://git-scm.com/download/mac';
case 'Linux':
return 'https://git-scm.com/download/linux';
default:
return 'https://git-scm.com/download';
}
};
const GitNotFoundModal = ({ onClose }) => {
const osName = getOSName();
const downloadUrl = getDownloadUrl(osName);
return (
<Portal>
<Modal
size="sm"
title="Git Not Found"
handleCancel={onClose}
hideFooter={true}
>
<div>
<p>Git was not detected on your system. You need to install Git to proceed.</p>
<p className="mt-2">
You can download Git for <strong>{osName}</strong> here:
</p>
<p>
<span
className="text-blue-600 cursor-pointer border-b border-blue-600"
onClick={() => window.open(downloadUrl, '_blank')}
>
Download Git for {osName}
</span>
</p>
</div>
</Modal>
</Portal>
);
};
export default GitNotFoundModal;

View File

@@ -8,37 +8,7 @@ import React, { useState } from 'react';
import HelpIcon from 'components/Icons/Help';
import StyledWrapper from './StyledWrapper';
const getPlacementStyles = (placement) => {
switch (placement) {
case 'top':
return {
bottom: 'calc(100% + 8px)',
left: '50%',
transform: 'translateX(-50%)'
};
case 'bottom':
return {
top: 'calc(100% + 8px)',
left: '50%',
transform: 'translateX(-50%)'
};
case 'left':
return {
top: '50%',
right: 'calc(100% + 8px)',
transform: 'translateY(-50%)'
};
case 'right':
default:
return {
top: '50%',
left: 'calc(100% + 8px)',
transform: 'translateY(-50%)'
};
}
};
const Help = ({ children, width = 200, placement = 'right' }) => {
const Help = ({ children, width = 200 }) => {
const [showTooltip, setShowTooltip] = useState(false);
return (
@@ -54,7 +24,9 @@ const Help = ({ children, width = 200, placement = 'right' }) => {
<StyledWrapper
className="absolute z-50 rounded-md p-3"
style={{
...getPlacementStyles(placement),
top: '50%',
left: 'calc(100% + 8px)',
transform: 'translateY(-50%)',
width: `${width}px`
}}
>

View File

@@ -15,20 +15,20 @@ const StyledMarkdownBodyWrapper = styled.div`
margin: 0.67em 0;
font-weight: var(--base-text-weight-semibold, 600);
padding-bottom: 0.3em;
font-size: 2.2em;
font-size: 1.4em;
border-bottom: 1px solid var(--color-border-muted);
}
h2 {
font-weight: var(--base-text-weight-semibold, 600);
padding-bottom: 0.3em;
font-size: 1.7em;
font-size: 1.3em;
border-bottom: 1px solid var(--color-border-muted);
}
h3 {
font-weight: var(--base-text-weight-semibold, 600);
font-size: 1.45em;
font-size: 1.2em;
}
h4 {
@@ -38,12 +38,12 @@ const StyledMarkdownBodyWrapper = styled.div`
h5 {
font-weight: var(--base-text-weight-semibold, 600);
font-size: 0.975em;
font-size: 1em;
}
h6 {
font-weight: var(--base-text-weight-semibold, 600);
font-size: 0.85em;
font-size: 0.9em;
color: var(--color-fg-muted);
}

View File

@@ -154,17 +154,10 @@ class MultiLineEditor extends Component {
this.editor.setOption('readOnly', this.props.readOnly);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
const nextValue = String(this.props.value ?? '');
const currentValue = this.editor.getValue();
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
this.cachedValue = currentValue;
} else {
const cursor = this.editor.getCursor();
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
this.editor.setCursor(cursor);
}
const cursor = this.editor.getCursor();
this.cachedValue = String(this.props.value);
this.editor.setValue(String(this.props.value) || '');
this.editor.setCursor(cursor);
}
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
// If the secret flag has changed, update the editor to reflect the change

View File

@@ -47,8 +47,8 @@ const General = () => {
.test('isNumber', 'Save Delay must be a number', (value) => {
return value === undefined || !isNaN(value);
})
.test('isValidInterval', 'Save Delay must be at least 500ms', (value) => {
return value === undefined || Number(value) >= 500;
.test('isValidInterval', 'Save Delay must be at least 100ms', (value) => {
return value === undefined || Number(value) >= 100;
})
}).test('intervalRequired', 'Save Delay is required when Auto Save is enabled', (value) => {
// If autosave is enabled, interval must be provided

View File

@@ -118,7 +118,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
};
const handleReflection = async (url, isManualRefresh = false) => {
const { methods, error, fromCache } = await reflectionManagement.loadMethodsFromReflection(url, isManualRefresh);
const { methods, error } = await reflectionManagement.loadMethodsFromReflection(url, isManualRefresh);
if (error) {
toast.error(`Failed to load gRPC methods: ${error.message || 'Unknown error'}`);
@@ -139,7 +139,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
}));
}
if (!fromCache && methods && methods.length > 0) {
if (methods && methods.length > 0) {
toast.success(`Loaded ${methods.length} gRPC methods from reflection`);
}
@@ -161,7 +161,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
};
const handleProtoFileLoad = async (filePath, isManualRefresh = false) => {
const { methods, error, fromCache } = await protoFileManagement.loadMethodsFromProtoFile(filePath, isManualRefresh);
const { methods, error } = await protoFileManagement.loadMethodsFromProtoFile(filePath, isManualRefresh);
if (error) {
console.error('Failed to load gRPC methods:', error);
@@ -174,9 +174,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
setGrpcMethods(methods);
setIsReflectionMode(false);
if (!fromCache) {
toast.success(`Loaded ${methods.length} gRPC methods from proto file`);
}
toast.success(`Loaded ${methods.length} gRPC methods from proto file`);
if (methods && methods.length > 0) {
const haveSelectedMethod = selectedGrpcMethod && methods.some((method) => method.path === selectedGrpcMethod.path);

View File

@@ -156,15 +156,8 @@ export default class QueryEditor extends React.Component {
CodeMirror.signal(this.editor, 'change', this.editor);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
const nextValue = this.props.value ?? '';
const currentValue = this.editor.getValue();
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
this.cachedValue = currentValue;
} else {
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
}
this.cachedValue = this.props.value;
this.editor.setValue(this.props.value);
}
if (this.props.theme !== prevProps.theme && this.editor) {

View File

@@ -1,11 +1,9 @@
import React, { useState, useEffect, useRef } from 'react';
import get from 'lodash/get';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import { updateRequestScript, updateResponseScript } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
import StatusDot from 'components/StatusDot';
@@ -17,22 +15,27 @@ const Script = ({ item, collection }) => {
const requestScript = item.draft ? get(item, 'draft.request.script.req') : get(item, 'request.script.req');
const responseScript = item.draft ? get(item, 'draft.request.script.res') : get(item, 'request.script.res');
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const scriptPaneTab = focusedTab?.scriptPaneTab;
// Default to post-response if pre-request script is empty (only when scriptPaneTab is null/undefined)
const getDefaultTab = () => {
// Default to post-response if pre-request script is empty
const getInitialTab = () => {
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
return hasPreRequestScript ? 'pre-request' : 'post-response';
};
const activeTab = scriptPaneTab || getDefaultTab();
const [activeTab, setActiveTab] = useState(getInitialTab);
const prevItemUidRef = useRef(item.uid);
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
// Update active tab only when switching to a different item
useEffect(() => {
if (prevItemUidRef.current !== item.uid) {
prevItemUidRef.current = item.uid;
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
setActiveTab(hasPreRequestScript ? 'pre-request' : 'post-response');
}
}, [item.uid, requestScript]);
// Refresh CodeMirror when tab becomes visible
useEffect(() => {
// Small delay to ensure DOM is updated
@@ -73,25 +76,17 @@ const Script = ({ item, collection }) => {
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
const hasPostResponseScript = responseScript && responseScript.trim().length > 0;
const onScriptTabChange = (tab) => {
dispatch(updateScriptPaneTab({ uid: item.uid, scriptPaneTab: tab }));
};
return (
<div className="w-full h-full flex flex-col">
<Tabs value={activeTab} onValueChange={onScriptTabChange}>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="pre-request">
Pre Request
{hasPreRequestScript && (
<StatusDot type={item.preRequestScriptErrorMessage ? 'error' : 'default'} />
)}
{hasPreRequestScript && <StatusDot />}
</TabsTrigger>
<TabsTrigger value="post-response">
Post Response
{hasPostResponseScript && (
<StatusDot type={item.postResponseScriptErrorMessage ? 'error' : 'default'} />
)}
{hasPostResponseScript && <StatusDot />}
</TabsTrigger>
</TabsList>

View File

@@ -58,7 +58,6 @@ const Tags = ({ item, collection }) => {
handleRemoveTag={handleRemove}
tags={tags}
onSave={handleRequestSave}
collectionFormat={collection.format}
/>
</div>
);

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
import ErrorBanner from 'ui/ErrorBanner';
import Button from 'ui/Button';

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState, useCallback } from 'react';
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
import ErrorBanner from 'ui/ErrorBanner';
import Button from 'ui/Button';

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
import ErrorBanner from 'ui/ErrorBanner';
import Button from 'ui/Button';

View File

@@ -32,7 +32,7 @@ import WSRequestPane from 'components/RequestPane/WSRequestPane';
import WSResponsePane from 'components/ResponsePane/WsResponsePane';
import { useTabPaneBoundaries } from 'hooks/useTabPaneBoundaries/index';
import ResponseExample from 'components/ResponseExample';
import WorkspaceOverview from 'components/WorkspaceHome/WorkspaceOverview';
import WorkspaceHome from 'components/WorkspaceHome';
import Preferences from 'components/Preferences';
import EnvironmentSettings from 'components/Environments/EnvironmentSettings';
import GlobalEnvironmentSettings from 'components/Environments/GlobalEnvironmentSettings';
@@ -43,6 +43,9 @@ const MIN_TOP_PANE_HEIGHT = 150;
const MIN_BOTTOM_PANE_HEIGHT = 150;
const RequestTabPanel = () => {
if (typeof window == 'undefined') {
return <div></div>;
}
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
@@ -50,8 +53,6 @@ const RequestTabPanel = () => {
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const _collections = useSelector((state) => state.collections.collections);
const preferences = useSelector((state) => state.app.preferences);
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
@@ -170,10 +171,6 @@ const RequestTabPanel = () => {
}
}, [isConsoleOpen, isVerticalLayout]);
if (typeof window == 'undefined') {
return <div></div>;
}
if (!activeTabUid || !focusedTab) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
@@ -186,14 +183,6 @@ const RequestTabPanel = () => {
return <Preferences />;
}
if (focusedTab.type === 'workspaceOverview') {
return activeWorkspace ? <WorkspaceOverview workspace={activeWorkspace} /> : null;
}
if (focusedTab.type === 'workspaceEnvironments') {
return <GlobalEnvironmentSettings />;
}
if (!focusedTab.uid || !focusedTab.collectionUid) {
return <div className="pb-4 px-4">An error occurred!</div>;
}

View File

@@ -1,125 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.collection-switcher {
display: flex;
align-items: center;
gap: 4px;
}
.switcher-trigger {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border: none;
border-radius: 4px;
background: transparent;
color: ${(props) => props.theme.text};
cursor: pointer;
font-size: 15px;
font-weight: 600;
transition: background-color 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
.switcher-name {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tab-count {
font-size: 11px;
font-weight: 500;
padding: 1px 6px;
border-radius: 10px;
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
min-width: 18px;
text-align: center;
}
.chevron {
opacity: 0.6;
flex-shrink: 0;
}
}
.workspace-actions-trigger {
cursor: pointer;
opacity: 0.6;
padding: 4px;
border-radius: 4px;
transition: opacity 0.15s ease, background-color 0.15s ease;
&:hover {
opacity: 1;
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
}
.workspace-rename-container {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
}
.workspace-name-input {
font-size: 14px;
font-weight: 500;
padding: 2px 6px;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: 3px;
background: ${(props) => props.theme.input.bg};
color: ${(props) => props.theme.text};
outline: none;
min-width: 150px;
&:focus {
border-color: ${(props) => props.theme.input.focusBorder};
}
}
.inline-actions {
display: flex;
align-items: center;
gap: 2px;
}
.inline-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: 3px;
cursor: pointer;
background: transparent;
color: ${(props) => props.theme.text};
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
&.save {
color: ${(props) => props.theme.colors.text.green};
}
&.cancel {
color: ${(props) => props.theme.colors.text.danger};
}
}
.workspace-error {
font-size: 12px;
color: ${(props) => props.theme.colors.text.danger};
margin-left: 8px;
}
`;
export default StyledWrapper;

View File

@@ -1,452 +0,0 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
IconCategory,
IconBox,
IconChevronDown,
IconRun,
IconEye,
IconSettings,
IconDots,
IconEdit,
IconX,
IconCheck,
IconFolder,
IconUpload
} from '@tabler/icons';
import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
import { uuid } from 'utils/common';
import toast from 'react-hot-toast';
import Dropdown from 'components/Dropdown';
import CloseWorkspace from 'components/Sidebar/CloseWorkspace';
import EnvironmentSelector from 'components/Environments/EnvironmentSelector';
import ToolHint from 'components/ToolHint';
import JsSandboxMode from 'components/SecuritySettings/JsSandboxMode';
import ActionIcon from 'ui/ActionIcon';
import { getRevealInFolderLabel } from 'utils/common/platform';
import classNames from 'classnames';
import StyledWrapper from './StyledWrapper';
const CollectionHeader = ({ collection, isScratchCollection }) => {
const dispatch = useDispatch();
const workspaces = useSelector((state) => state.workspaces.workspaces);
const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid);
const collections = useSelector((state) => state.collections.collections);
const tabs = useSelector((state) => state.tabs.tabs);
// Get the current active workspace
const currentWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
// Workspace rename state
const [isRenamingWorkspace, setIsRenamingWorkspace] = useState(false);
const [workspaceNameInput, setWorkspaceNameInput] = useState('');
const [workspaceNameError, setWorkspaceNameError] = useState('');
const [closeWorkspaceModalOpen, setCloseWorkspaceModalOpen] = useState(false);
const switcherRef = useRef();
const workspaceActionsRef = useRef();
const workspaceNameInputRef = useRef(null);
const workspaceRenameContainerRef = useRef(null);
const onSwitcherCreate = (ref) => (switcherRef.current = ref);
const onWorkspaceActionsCreate = (ref) => (workspaceActionsRef.current = ref);
const handleCancelWorkspaceRename = useCallback(() => {
setIsRenamingWorkspace(false);
setWorkspaceNameInput('');
setWorkspaceNameError('');
}, []);
useEffect(() => {
if (!isRenamingWorkspace) return;
const handleClickOutside = (event) => {
if (workspaceRenameContainerRef.current && !workspaceRenameContainerRef.current.contains(event.target)) {
handleCancelWorkspaceRename();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isRenamingWorkspace, handleCancelWorkspaceRename]);
if (!collection) {
return null;
}
// Get mounted collections for the current workspace (excluding scratch collections)
const mountedCollections = collections.filter((c) => {
if (c.mountStatus !== 'mounted') return false;
const isScratch = workspaces.some((w) => w.scratchCollectionUid === c.uid);
if (isScratch) return false;
const workspaceCollectionPaths = currentWorkspace?.collections?.map((wc) => wc.path) || [];
return workspaceCollectionPaths.some((wcPath) => c.pathname === wcPath);
});
// Count tabs for the current collection
const tabCount = tabs.filter((t) => t.collectionUid === collection.uid).length;
// Get tab count for a given collection uid
const getTabCount = (collectionUid) => tabs.filter((t) => t.collectionUid === collectionUid).length;
// Get tab count for workspace (scratch collection)
const workspaceTabCount = currentWorkspace?.scratchCollectionUid
? getTabCount(currentWorkspace.scratchCollectionUid)
: 0;
// Display name and icon based on context
const displayName = isScratchCollection
? (currentWorkspace?.name || 'Untitled Workspace')
: (collection.name || 'Untitled Collection');
const DisplayIcon = isScratchCollection ? IconCategory : IconBox;
// Switcher handlers
const handleSwitchToWorkspace = (workspaceUid) => {
switcherRef.current?.hide();
if (workspaceUid) {
dispatch(switchWorkspace(workspaceUid));
}
};
const handleSwitchToCollection = (targetCollection) => {
switcherRef.current?.hide();
if (!targetCollection?.uid) return;
const existingTab = tabs.find((t) => t.collectionUid === targetCollection.uid);
if (existingTab) {
dispatch(focusTab({ uid: existingTab.uid }));
} else {
dispatch(
addTab({
uid: targetCollection.uid,
collectionUid: targetCollection.uid,
type: 'collection-settings'
})
);
}
};
// Collection action handlers
const handleRun = () => {
dispatch(
addTab({
uid: uuid(),
collectionUid: collection.uid,
type: 'collection-runner'
})
);
};
const viewVariables = () => {
dispatch(
addTab({
uid: uuid(),
collectionUid: collection.uid,
type: 'variables'
})
);
};
const viewCollectionSettings = () => {
dispatch(
addTab({
uid: collection.uid,
collectionUid: collection.uid,
type: 'collection-settings'
})
);
};
// Workspace action handlers (only used when isScratchCollection is true)
const handleRenameWorkspaceClick = () => {
workspaceActionsRef.current?.hide();
setIsRenamingWorkspace(true);
setWorkspaceNameInput(currentWorkspace?.name || '');
setWorkspaceNameError('');
setTimeout(() => {
workspaceNameInputRef.current?.focus();
workspaceNameInputRef.current?.select();
}, 50);
};
const handleCloseWorkspaceClick = () => {
workspaceActionsRef.current?.hide();
if (currentWorkspace?.type === 'default') {
toast.error('Cannot close the default workspace');
return;
}
setCloseWorkspaceModalOpen(true);
};
const handleShowInFolder = () => {
workspaceActionsRef.current?.hide();
const pathname = currentWorkspace?.pathname;
if (pathname) {
dispatch(showInFolder(pathname)).catch(() => {
toast.error('Error opening the folder');
});
}
};
const handleExportWorkspace = () => {
workspaceActionsRef.current?.hide();
const uid = currentWorkspace?.uid;
if (!uid) return;
dispatch(exportWorkspaceAction(uid))
.then((result) => {
if (!result?.canceled) {
toast.success('Workspace exported successfully');
}
})
.catch((error) => {
toast.error(error?.message || 'Error exporting workspace');
});
};
const validateWorkspaceName = (name) => {
const trimmed = name?.trim();
if (!trimmed) {
return 'Name is required';
}
if (trimmed.length > 255) {
return 'Must be 255 characters or less';
}
return null;
};
const handleSaveWorkspaceRename = () => {
const error = validateWorkspaceName(workspaceNameInput);
if (error) {
setWorkspaceNameError(error);
return;
}
const uid = currentWorkspace?.uid;
if (!uid) return;
dispatch(renameWorkspaceAction(uid, workspaceNameInput))
.then(() => {
toast.success('Workspace renamed!');
setIsRenamingWorkspace(false);
setWorkspaceNameInput('');
setWorkspaceNameError('');
})
.catch((err) => {
toast.error(err?.message || 'An error occurred while renaming the workspace');
setWorkspaceNameError(err?.message || 'Failed to rename workspace');
});
};
const handleWorkspaceNameChange = (e) => {
setWorkspaceNameInput(e.target.value);
if (workspaceNameError) {
setWorkspaceNameError('');
}
};
const handleWorkspaceNameKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveWorkspaceRename();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancelWorkspaceRename();
}
};
// Check if workspace actions should be shown
const showWorkspaceActions = isScratchCollection
&& currentWorkspace
&& currentWorkspace.type !== 'default'
&& !isRenamingWorkspace;
return (
<StyledWrapper>
{closeWorkspaceModalOpen && currentWorkspace?.uid && (
<CloseWorkspace
workspaceUid={currentWorkspace.uid}
onClose={() => setCloseWorkspaceModalOpen(false)}
/>
)}
<div className="flex items-center justify-between gap-2 py-2 px-4">
{/* Left side: Switcher dropdown or rename input */}
<div className="collection-switcher">
{isRenamingWorkspace ? (
<div className="workspace-rename-container" ref={workspaceRenameContainerRef}>
<DisplayIcon size={18} strokeWidth={1.5} />
<input
ref={workspaceNameInputRef}
type="text"
className="workspace-name-input"
value={workspaceNameInput}
onChange={handleWorkspaceNameChange}
onKeyDown={handleWorkspaceNameKeyDown}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveWorkspaceRename}
onMouseDown={(e) => e.preventDefault()}
title="Save"
>
<IconCheck size={14} strokeWidth={2} />
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelWorkspaceRename}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
<IconX size={14} strokeWidth={2} />
</button>
</div>
{workspaceNameError && (
<span className="workspace-error">{workspaceNameError}</span>
)}
</div>
) : (
<Dropdown
placement="bottom-start"
onCreate={onSwitcherCreate}
appendTo={() => document.body}
icon={(
<button className="switcher-trigger">
<DisplayIcon size={18} strokeWidth={1.5} />
<span className="switcher-name">{displayName}</span>
{tabCount > 0 && <span className="tab-count">{tabCount}</span>}
<IconChevronDown size={14} strokeWidth={1.5} className="chevron" />
</button>
)}
>
{/* Workspace section */}
{currentWorkspace && (
<>
<div className="label-item">Workspace</div>
<div
className={classNames('dropdown-item', {
'dropdown-item-active': isScratchCollection
})}
onClick={() => handleSwitchToWorkspace(currentWorkspace.uid)}
>
<div className="dropdown-icon">
<IconCategory size={16} strokeWidth={1.5} />
</div>
<span className="dropdown-label">
{currentWorkspace.name || 'Untitled Workspace'}
</span>
{workspaceTabCount > 0 && (
<span className="dropdown-tab-count">{workspaceTabCount}</span>
)}
</div>
</>
)}
{/* Collections section */}
{mountedCollections.length > 0 && (
<>
<div className="dropdown-separator" />
<div className="label-item">Collections</div>
{mountedCollections.map((col) => {
const colTabCount = getTabCount(col.uid);
return (
<div
key={col.uid}
className={classNames('dropdown-item', {
'dropdown-item-active': !isScratchCollection && collection.uid === col.uid
})}
onClick={() => handleSwitchToCollection(col)}
>
<div className="dropdown-icon">
<IconBox size={16} strokeWidth={1.5} />
</div>
<span className="dropdown-label">{col.name || 'Untitled Collection'}</span>
{colTabCount > 0 && (
<span className="dropdown-tab-count">{colTabCount}</span>
)}
</div>
);
})}
</>
)}
</Dropdown>
)}
{/* Workspace actions dropdown */}
{showWorkspaceActions && (
<Dropdown
placement="bottom-start"
onCreate={onWorkspaceActionsCreate}
appendTo={() => document.body}
icon={<IconDots size={18} strokeWidth={1.5} className="workspace-actions-trigger" />}
>
<div className="dropdown-item" onClick={handleRenameWorkspaceClick}>
<div className="dropdown-icon">
<IconEdit size={16} strokeWidth={1.5} />
</div>
<span>Rename</span>
</div>
<div className="dropdown-item" onClick={handleShowInFolder}>
<div className="dropdown-icon">
<IconFolder size={16} strokeWidth={1.5} />
</div>
<span>{getRevealInFolderLabel()}</span>
</div>
<div className="dropdown-item" onClick={handleExportWorkspace}>
<div className="dropdown-icon">
<IconUpload size={16} strokeWidth={1.5} />
</div>
<span>Export</span>
</div>
<div className="dropdown-item" onClick={handleCloseWorkspaceClick}>
<div className="dropdown-icon">
<IconX size={16} strokeWidth={1.5} />
</div>
<span>Close</span>
</div>
</Dropdown>
)}
</div>
{/* Right side: Actions (only for regular collections) */}
{!isScratchCollection && (
<div className="flex flex-grow gap-1 items-center justify-end">
<ToolHint text="Runner" toolhintId="RunnerToolhintId" place="bottom">
<ActionIcon onClick={handleRun} aria-label="Runner" size="sm">
<IconRun size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<ToolHint text="Variables" toolhintId="VariablesToolhintId">
<ActionIcon onClick={viewVariables} aria-label="Variables" size="sm">
<IconEye size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<ToolHint text="Collection Settings" toolhintId="CollectionSettingsToolhintId">
<ActionIcon onClick={viewCollectionSettings} aria-label="Collection Settings" size="sm">
<IconSettings size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<JsSandboxMode collection={collection} />
<span className="ml-2">
<EnvironmentSelector collection={collection} />
</span>
</div>
)}
</div>
</StyledWrapper>
);
};
export default CollectionHeader;

View File

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

View File

@@ -0,0 +1,83 @@
import React from 'react';
import { uuid } from 'utils/common';
import { IconBox, IconRun, IconEye, IconSettings } from '@tabler/icons';
import EnvironmentSelector from 'components/Environments/EnvironmentSelector';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
import ToolHint from 'components/ToolHint';
import StyledWrapper from './StyledWrapper';
import JsSandboxMode from 'components/SecuritySettings/JsSandboxMode';
import ActionIcon from 'ui/ActionIcon';
const CollectionToolBar = ({ collection }) => {
const dispatch = useDispatch();
if (!collection) {
return null;
}
const handleRun = () => {
dispatch(
addTab({
uid: uuid(),
collectionUid: collection.uid,
type: 'collection-runner'
})
);
};
const viewVariables = () => {
dispatch(
addTab({
uid: uuid(),
collectionUid: collection.uid,
type: 'variables'
})
);
};
const viewCollectionSettings = () => {
dispatch(
addTab({
uid: collection.uid,
collectionUid: collection.uid,
type: 'collection-settings'
})
);
};
return (
<StyledWrapper>
<div className="flex items-center justify-between gap-2 py-2 px-4">
<button className="flex items-center cursor-pointer hover:underline bg-transparent border-none p-0 text-inherit" onClick={viewCollectionSettings}>
<IconBox size={18} strokeWidth={1.5} />
<span className="ml-2 mr-4 font-medium">{collection?.name}</span>
</button>
<div className="flex flex-grow gap-1 items-center justify-end">
<ToolHint text="Runner" toolhintId="RunnerToolhintId" place="bottom">
<ActionIcon onClick={handleRun} aria-label="Runner" size="sm">
<IconRun size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<ToolHint text="Variables" toolhintId="VariablesToolhintId">
<ActionIcon onClick={viewVariables} aria-label="Variables" size="sm">
<IconEye size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<ToolHint text="Collection Settings" toolhintId="CollectionSettingsToolhintId">
<ActionIcon onClick={viewCollectionSettings} aria-label="Collection Settings" size="sm">
<IconSettings size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
{/* ToolHint is present within the JsSandboxMode component */}
<JsSandboxMode collection={collection} />
<span className="ml-2">
<EnvironmentSelector collection={collection} />
</span>
</div>
</div>
</StyledWrapper>
);
};
export default CollectionToolBar;

View File

@@ -1,8 +1,8 @@
import React, { useState, useRef, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
import { saveRequest, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { hasExampleChanges, findItemInCollection } from 'utils/collections';
import ExampleIcon from 'components/Icons/ExampleIcon';
import ConfirmRequestClose from '../RequestTab/ConfirmRequestClose';

View File

@@ -1,6 +1,6 @@
import React from 'react';
import GradientCloseButton from './GradientCloseButton';
import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock, IconDatabase, IconWorld, IconHome } from '@tabler/icons';
import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock, IconDatabase, IconWorld } from '@tabler/icons';
const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDraft }) => {
const getTabInfo = (type, tabName) => {
@@ -69,22 +69,6 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
</>
);
}
case 'workspaceOverview': {
return (
<>
<IconHome size={14} strokeWidth={1.5} className="special-tab-icon flex-shrink-0" />
<span className="ml-1 tab-name">Overview</span>
</>
);
}
case 'workspaceEnvironments': {
return (
<>
<IconWorld size={14} strokeWidth={1.5} className="special-tab-icon flex-shrink-0" />
<span className="ml-1 tab-name">Environments</span>
</>
);
}
}
};
@@ -96,7 +80,7 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
>
{getTabInfo(type, tabName)}
</div>
{handleCloseClick && <GradientCloseButton hasChanges={hasDraft} onClick={(e) => handleCloseClick(e)} />}
<GradientCloseButton hasChanges={hasDraft} onClick={(e) => handleCloseClick(e)} />
</>
);
};

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useState, useRef, Fragment, useMemo, useEffect } from 'react';
import get from 'lodash/get';
import { makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { deleteRequestDraft, deleteCollectionDraft, deleteFolderDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
import { clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-environments';
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
@@ -172,7 +172,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
setShowConfirmGlobalEnvironmentClose(true);
};
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'environment-settings', 'global-environment-settings', 'preferences', 'workspaceOverview', 'workspaceEnvironments'].includes(tab.type)) {
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'environment-settings', 'global-environment-settings', 'preferences'].includes(tab.type)) {
return (
<StyledWrapper
className={`flex items-center justify-between tab-container px-2 ${tab.preview ? 'italic' : ''}`}
@@ -236,7 +236,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
{showConfirmEnvironmentClose && tab.type === 'environment-settings' && (
<ConfirmCloseEnvironment
isGlobal={false}
isDotEnv={collection.environmentsDraft?.environmentUid?.startsWith('dotenv:')}
onCancel={() => setShowConfirmEnvironmentClose(false)}
onCloseWithoutSave={() => {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
@@ -245,25 +244,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
}}
onSaveAndClose={() => {
const draft = collection.environmentsDraft;
if (draft?.environmentUid?.startsWith('dotenv:')) {
const onSuccess = () => {
cleanup();
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
dispatch(closeTabs({ tabUids: [tab.uid] }));
setShowConfirmEnvironmentClose(false);
};
const onFailed = () => {
cleanup();
setShowConfirmEnvironmentClose(false);
};
const cleanup = () => {
window.removeEventListener('dotenv-save-complete', onSuccess);
window.removeEventListener('dotenv-save-failed', onFailed);
};
window.addEventListener('dotenv-save-complete', onSuccess, { once: true });
window.addEventListener('dotenv-save-failed', onFailed, { once: true });
window.dispatchEvent(new Event('dotenv-save'));
} else if (draft?.environmentUid && draft?.variables) {
if (draft?.environmentUid && draft?.variables) {
dispatch(saveEnvironment(draft.variables, draft.environmentUid, collection.uid))
.then(() => {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
@@ -282,7 +263,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
{showConfirmGlobalEnvironmentClose && tab.type === 'global-environment-settings' && (
<ConfirmCloseEnvironment
isGlobal={true}
isDotEnv={globalEnvironmentDraft?.environmentUid?.startsWith('dotenv:')}
onCancel={() => setShowConfirmGlobalEnvironmentClose(false)}
onCloseWithoutSave={() => {
dispatch(clearGlobalEnvironmentDraft());
@@ -291,25 +271,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
}}
onSaveAndClose={() => {
const draft = globalEnvironmentDraft;
if (draft?.environmentUid?.startsWith('dotenv:')) {
const onSuccess = () => {
cleanup();
dispatch(clearGlobalEnvironmentDraft());
dispatch(closeTabs({ tabUids: [tab.uid] }));
setShowConfirmGlobalEnvironmentClose(false);
};
const onFailed = () => {
cleanup();
setShowConfirmGlobalEnvironmentClose(false);
};
const cleanup = () => {
window.removeEventListener('dotenv-save-complete', onSuccess);
window.removeEventListener('dotenv-save-failed', onFailed);
};
window.addEventListener('dotenv-save-complete', onSuccess, { once: true });
window.addEventListener('dotenv-save-failed', onFailed, { once: true });
window.dispatchEvent(new Event('dotenv-save'));
} else if (draft?.environmentUid && draft?.variables) {
if (draft?.environmentUid && draft?.variables) {
dispatch(saveGlobalEnvironment({ variables: draft.variables, environmentUid: draft.environmentUid }))
.then(() => {
dispatch(clearGlobalEnvironmentDraft());
@@ -335,10 +297,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
<SpecialTab handleCloseClick={handleCloseEnvironmentSettings} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} hasDraft={hasEnvironmentDraft} />
) : tab.type === 'global-environment-settings' ? (
<SpecialTab handleCloseClick={handleCloseGlobalEnvironmentSettings} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} hasDraft={hasGlobalEnvironmentDraft} />
) : tab.type === 'workspaceOverview' ? (
<SpecialTab handleCloseClick={null} type={tab.type} />
) : tab.type === 'workspaceEnvironments' ? (
<SpecialTab handleCloseClick={null} type={tab.type} />
) : (
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} />
)}
@@ -516,42 +474,19 @@ function RequestTabMenu({ menuDropdownRef, tabLabelRef, collectionRequestTabs, t
} catch (err) { }
}
async function handleCloseMultipleTabs(tabs) {
const tabUidsToClose = [];
for (const tab of tabs) {
const item = findItemInCollection(collection, tab.uid);
if (item && hasRequestChanges(item)) {
try {
await dispatch(saveRequest(item.uid, collection.uid, true));
} catch (err) {
continue;
}
}
if (tab?.uid) {
tabUidsToClose.push(tab.uid);
}
}
if (tabUidsToClose.length > 0) {
dispatch(closeTabs({ tabUids: tabUidsToClose }));
}
}
async function handleCloseOtherTabs() {
const otherTabs = collectionRequestTabs.filter((_, index) => index !== tabIndex);
await handleCloseMultipleTabs(otherTabs);
await Promise.all(otherTabs.map((tab) => handleCloseTab(tab.uid)));
}
async function handleCloseTabsToTheLeft() {
const leftTabs = collectionRequestTabs.filter((_, index) => index < tabIndex);
await handleCloseMultipleTabs(leftTabs);
await Promise.all(leftTabs.map((tab) => handleCloseTab(tab.uid)));
}
async function handleCloseTabsToTheRight() {
const rightTabs = collectionRequestTabs.filter((_, index) => index > tabIndex);
await handleCloseMultipleTabs(rightTabs);
await Promise.all(rightTabs.map((tab) => handleCloseTab(tab.uid)));
}
function handleCloseSavedTabs() {
@@ -562,7 +497,7 @@ function RequestTabMenu({ menuDropdownRef, tabLabelRef, collectionRequestTabs, t
}
async function handleCloseAllTabs() {
await handleCloseMultipleTabs(collectionRequestTabs);
await Promise.all(collectionRequestTabs.map((tab) => handleCloseTab(tab.uid)));
}
const menuItems = useMemo(() => [

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import find from 'lodash/find';
import filter from 'lodash/filter';
import classnames from 'classnames';
@@ -6,7 +6,7 @@ import { IconChevronRight, IconChevronLeft } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { focusTab, reorderTabs } from 'providers/ReduxStore/slices/tabs';
import NewRequest from 'components/Sidebar/NewRequest';
import CollectionHeader from './CollectionHeader';
import CollectionToolBar from './CollectionToolBar';
import RequestTab from './RequestTab';
import StyledWrapper from './StyledWrapper';
import DraggableTab from './DraggableTab';
@@ -27,7 +27,6 @@ const RequestTabs = () => {
const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth);
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
const screenWidth = useSelector((state) => state.app.screenWidth);
const workspaces = useSelector((state) => state.workspaces.workspaces);
const createSetHasOverflow = useCallback((tabUid) => {
return (hasOverflow) => {
@@ -47,10 +46,6 @@ const RequestTabs = () => {
const activeCollection = find(collections, (c) => c?.uid === activeTab?.collectionUid);
const collectionRequestTabs = filter(tabs, (t) => t.collectionUid === activeTab?.collectionUid);
const isScratchCollection = useMemo(() => {
return activeCollection ? workspaces.some((w) => w.scratchCollectionUid === activeCollection.uid) : false;
}, [workspaces, activeCollection]);
useEffect(() => {
if (!activeTabUid || !activeTab) return;
@@ -115,12 +110,7 @@ const RequestTabs = () => {
)}
{collectionRequestTabs && collectionRequestTabs.length ? (
<>
{activeCollection && (
<CollectionHeader
collection={activeCollection}
isScratchCollection={isScratchCollection}
/>
)}
{activeCollection && <CollectionToolBar collection={activeCollection} />}
<div className="flex items-center gap-2 pl-2" ref={collectionTabsRef}>
<div className={classnames('scroll-chevrons', { hidden: !showChevrons })}>
<ActionIcon size="lg" onClick={leftSlide} aria-label="Left Chevron" style={{ marginBottom: '3px' }}>

View File

@@ -4,7 +4,7 @@ import { IconBookmark } from '@tabler/icons';
import { addResponseExample } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
import { uuid, formatResponse } from 'utils/common';
import { uuid } from 'utils/common';
import toast from 'react-hot-toast';
import CreateExampleModal from 'components/ResponseExample/CreateExampleModal';
import { getBodyType } from 'utils/responseBodyProcessor';
@@ -83,7 +83,7 @@ const ResponseBookmark = forwardRef(({ item, collection, responseSize, children
const contentType = contentTypeHeader?.value?.toLowerCase() || '';
const bodyType = getBodyType(contentType);
const content = formatResponse(response.data, response.dataBuffer, bodyType);
const content = response.data;
const exampleData = {
name: name,

View File

@@ -9,7 +9,7 @@ import ActionIcon from 'ui/ActionIcon/index';
const ResponseDownload = forwardRef(({ item, children }, ref) => {
const { ipcRenderer } = window;
const response = item.response || {};
const isDisabled = !response.dataBuffer || response.stream?.running;
const isDisabled = !response.dataBuffer ? true : false;
const elementRef = useRef(null);
useImperativeHandle(ref, () => ({

View File

@@ -164,7 +164,7 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems })
if (!items?.length) return;
items.forEach((item) => {
if (isItemARequest(item) && !item.partial && !item.isTransient) {
if (isItemARequest(item) && !item.partial) {
const relativePath = path.relative(collection.pathname, path.dirname(item.pathname));
const folderPath = relativePath !== '.' ? relativePath : '';

View File

@@ -1,43 +0,0 @@
import React, { useMemo, useCallback, memo } from 'react';
import { useSelector } from 'react-redux';
import { IconDatabase, IconCheck, IconLoader2 } from '@tabler/icons';
import { areItemsLoading } from 'utils/collections';
const CollectionListItem = memo(({ collectionUid, collectionPath, collectionName, isSelected, onSelect }) => {
const collection = useSelector((state) =>
state.collections.collections.find((c) => c.uid === collectionUid || c.pathname === collectionPath)
);
const { isFullyLoaded, isLoading } = useMemo(() => {
const isMounted = collection?.mountStatus === 'mounted';
const fullyLoaded = isMounted && !areItemsLoading(collection);
const loading = isSelected && !fullyLoaded;
return { isFullyLoaded: fullyLoaded, isLoading: loading };
}, [collection, isSelected]);
const handleClick = useCallback(() => {
if (!isLoading) {
onSelect();
}
}, [isLoading, onSelect]);
return (
<li
className={`collection-item ${isLoading ? 'mounting' : ''} ${isSelected ? 'selected' : ''}`}
onClick={handleClick}
>
<div className="collection-item-content">
<IconDatabase size={16} strokeWidth={1.5} />
<span className="collection-item-name">{collectionName}</span>
</div>
{isLoading && (
<IconLoader2 size={16} strokeWidth={1.5} className="animate-spin" />
)}
{isFullyLoaded && (
<IconCheck size={16} strokeWidth={1.5} className="icon-success" />
)}
</li>
);
});
export default CollectionListItem;

View File

@@ -3,7 +3,7 @@ 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/collections/actions';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import toast from 'react-hot-toast';
import Modal from 'components/Modal';
import Button from 'ui/Button';

View File

@@ -1,38 +0,0 @@
import React from 'react';
import { IconChevronRight } from '@tabler/icons';
const FolderBreadcrumbs = ({
collectionName,
breadcrumbs,
isAtRoot,
onNavigateToRoot,
onNavigateToBreadcrumb
}) => {
return (
<>
<span
className={!isAtRoot ? 'collection-name-breadcrumb' : ''}
onClick={!isAtRoot ? onNavigateToRoot : undefined}
>
{collectionName}
</span>
{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();
onNavigateToBreadcrumb(index);
}}
>
{breadcrumb.name}
</span>
</React.Fragment>
))}
{isAtRoot && <IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />}
</>
);
};
export default FolderBreadcrumbs;

View File

@@ -127,79 +127,6 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.muted};
}
.collection-list {
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: ${(props) => props.theme.border.radius.sm};
max-height: 320px;
overflow-y: auto;
background-color: ${(props) => props.theme.modal.body.bg};
padding: 8px 8px;
}
.collection-list-items {
display: flex;
flex-direction: column;
gap: 4px;
list-style: none;
padding: 0;
margin: 0;
border-radius: ${(props) => props.theme.border.radius.sm};
}
.collection-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
cursor: pointer;
transition: background-color 0.15s ease;
color: ${(props) => props.theme.text};
border-radius: ${(props) => props.theme.border.radius.sm};
user-select: none;
border: 1px solid ${(props) => props.theme.border.border1};
&:hover {
background-color: ${(props) => props.theme.plainGrid.hoverBg};
border-color: ${(props) => props.theme.colors.text.muted};
}
}
.collection-item-content {
display: flex;
align-items: center;
gap: 10px;
}
.collection-item-name {
color: ${(props) => props.theme.text};
font-weight: 500;
}
.collection-empty-state {
padding: 20px 16px;
text-align: center;
font-size: 14px;
color: ${(props) => props.theme.colors.text.muted};
line-height: 1.5;
}
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.icon-success {
color: ${(props) => props.theme.colors.success};
}
.custom-modal-footer {
display: flex;
justify-content: space-between;
@@ -236,17 +163,30 @@ const StyledWrapper = styled.div`
padding-top: 12px;
}
.new-folder-header {
.new-folder-content {
display: flex;
align-items: center;
align-items: flex-start;
gap: 8px;
margin-bottom: 4px;
}
.new-folder-header-label {
font-size: 13px;
.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.text};
color: ${(props) => props.theme.colors.text.muted};
}
.new-folder-input-row {
@@ -307,41 +247,13 @@ const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 4px;
}
.new-folder-filesystem-label {
font-size: 13px;
font-size: 12px;
font-weight: 500;
color: ${(props) => props.theme.text};
}
.filesystem-input-container {
display: flex;
align-items: center;
background: ${(props) => props.theme.requestTabPanel.url.bg};
border-radius: 4px;
padding: 8px 12px;
border: 1px solid rgba(0, 0, 0, 0.08);
margin-top: 8px;
}
.filesystem-input-icon {
flex-shrink: 0;
margin-right: 8px;
color: ${(props) => props.theme.colors.text.yellow};
}
.filesystem-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: ${(props) => props.theme.colors.text.yellow};
font-size: ${(props) => props.theme.font.size.base};
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
color: ${(props) => props.theme.colors.text.muted};
}
.new-folder-toggle-filesystem-btn {

View File

@@ -1,28 +1,21 @@
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
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, IconEdit, IconArrowBackUp } from '@tabler/icons';
import PathDisplay from 'components/PathDisplay/index';
import Help from 'components/Help';
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 CollectionListItem from './CollectionListItem';
import FolderBreadcrumbs from './FolderBreadcrumbs';
import useCollectionFolderTree from 'hooks/useCollectionFolderTree';
import { removeSaveTransientRequestModal } from 'providers/ReduxStore/slices/collections';
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
import { newFolder, closeTabs, mountCollection } from 'providers/ReduxStore/slices/collections/actions';
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 path from 'utils/common/path';
import { transformRequestToSaveToFilesystem, findCollectionByUid, findItemInCollection, areItemsLoading } from 'utils/collections';
import { transformRequestToSaveToFilesystem, findCollectionByUid, findItemInCollection } from 'utils/collections';
import { DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants';
import { itemSchema } from '@usebruno/schema';
import { uuid } from 'utils/common';
import { formatIpcError } from 'utils/common/error';
const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOpen = false, onClose }) => {
const dispatch = useDispatch();
@@ -35,27 +28,12 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
const item = itemProp;
const collection = collectionProp;
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const allCollections = useSelector((state) => state.collections.collections);
const isScratchCollection = activeWorkspace?.scratchCollectionUid === collection?.uid;
const availableCollections = useMemo(() => {
if (!isScratchCollection || !activeWorkspace) return [];
return (activeWorkspace.collections || []).map((wc) => {
const fullCollection = allCollections.find((c) => c.pathname === wc.path);
// Use stable deterministic UID based on path to avoid duplicate Redux entries
const stableUid = wc.path ? `pending-${wc.path.replace(/[^a-zA-Z0-9]/g, '-')}` : uuid();
return fullCollection || { ...wc, uid: stableUid, mountStatus: 'unmounted' };
}).filter((c) => !workspaces.some((w) => w.scratchCollectionUid === c.uid));
}, [isScratchCollection, activeWorkspace, allCollections, workspaces]);
const handleClose = () => {
if (onClose) {
onClose();
return;
}
// Remove from Redux array
dispatch(removeSaveTransientRequestModal({ itemUid: item.uid }));
};
const [requestName, setRequestName] = useState(item?.name || '');
@@ -64,28 +42,8 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
const [newFolderName, setNewFolderName] = useState('');
const [newFolderDirectoryName, setNewFolderDirectoryName] = useState('');
const [showFilesystemName, setShowFilesystemName] = useState(false);
const [isEditingFolderFilename, setIsEditingFolderFilename] = useState(false);
const [pendingFolderNavigation, setPendingFolderNavigation] = useState(null);
const newFolderInputRef = useRef(null);
const [selectedTargetCollectionPath, setSelectedTargetCollectionPath] = useState(null);
const [isSelectingCollection, setIsSelectingCollection] = useState(isScratchCollection);
const folderTreeCollectionUid = selectedTargetCollectionPath
? availableCollections.find((c) => (c.path || c.pathname) === selectedTargetCollectionPath)?.uid
: collection?.uid;
const selectedTargetCollection = selectedTargetCollectionPath
? availableCollections.find((c) => (c.path || c.pathname) === selectedTargetCollectionPath)
: null;
useEffect(() => {
const isMounted = selectedTargetCollection?.mountStatus === 'mounted';
const isFullyLoaded = isMounted && !areItemsLoading(selectedTargetCollection);
if (selectedTargetCollectionPath && isFullyLoaded) {
setIsSelectingCollection(false);
}
}, [selectedTargetCollectionPath, selectedTargetCollection]);
const {
currentFolders,
breadcrumbs,
@@ -97,27 +55,21 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
getCurrentSelectedFolder,
reset,
isAtRoot
} = useCollectionFolderTree(folderTreeCollectionUid);
} = useCollectionFolderTree(collection?.uid);
const resetForm = useCallback(() => {
setRequestName(item?.name || '');
const resetForm = () => {
setRequestName(item.name || '');
setSearchText('');
reset();
setShowNewFolderInput(false);
setNewFolderName('');
setNewFolderDirectoryName('');
setShowFilesystemName(false);
setIsEditingFolderFilename(false);
setPendingFolderNavigation(null);
setSelectedTargetCollectionPath(null);
setIsSelectingCollection(isScratchCollection);
}, [item?.name, isScratchCollection, reset]);
};
useEffect(() => {
if (isOpen && item) {
resetForm();
}
}, [isOpen, item, resetForm]);
isOpen && item && resetForm();
}, [isOpen, item]);
useEffect(() => {
if (showNewFolderInput && newFolderInputRef.current) {
@@ -125,16 +77,6 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
}
}, [showNewFolderInput]);
useEffect(() => {
if (pendingFolderNavigation) {
const newFolder = currentFolders.find((f) => f.filename === pendingFolderNavigation);
if (newFolder) {
navigateIntoFolder(newFolder.uid);
setPendingFolderNavigation(null);
}
}
}, [currentFolders, pendingFolderNavigation, navigateIntoFolder]);
const filteredFolders = useMemo(() => {
if (!searchText.trim()) {
return currentFolders;
@@ -148,41 +90,16 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
handleClose();
};
const handleSelectCollection = useCallback((selectedCollection) => {
const collectionPath = selectedCollection.path || selectedCollection.pathname;
const isMounted = selectedCollection.mountStatus === 'mounted';
const isFullyLoaded = isMounted && !areItemsLoading(selectedCollection);
setSelectedTargetCollectionPath(collectionPath);
if (isFullyLoaded) {
setIsSelectingCollection(false);
return;
}
if (!isMounted && selectedCollection.mountStatus !== 'mounting') {
dispatch(
mountCollection({
collectionUid: selectedCollection.uid || uuid(),
collectionPathname: collectionPath,
brunoConfig: selectedCollection.brunoConfig
})
);
}
}, [dispatch]);
const handleConfirm = async () => {
if (!item || !collection || !latestItem) {
return;
}
const targetCollection = selectedTargetCollection || collection;
try {
const { ipcRenderer } = window;
const selectedFolder = getCurrentSelectedFolder();
const targetDirname = selectedFolder ? selectedFolder.pathname : targetCollection.pathname;
const targetDirname = selectedFolder ? selectedFolder.pathname : collection.pathname;
const trimmedName = requestName.trim();
if (!trimmedName || trimmedName.length === 0) {
@@ -190,11 +107,6 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
return;
}
if (!validateName(trimmedName)) {
toast.error(validateNameError(trimmedName));
return;
}
const sanitizedFilename = sanitizeName(trimmedName);
const itemToSave = latestItem.draft ? { ...latestItem, ...latestItem.draft } : { ...latestItem };
@@ -204,32 +116,23 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
const transformedItem = transformRequestToSaveToFilesystem(itemToSave);
await itemSchema.validate(transformedItem);
const targetFormat = targetCollection.format || DEFAULT_COLLECTION_FORMAT;
const sourceFormat = collection.format || DEFAULT_COLLECTION_FORMAT;
const targetFilename = resolveRequestFilename(sanitizedFilename, targetFormat);
const targetPathname = path.join(targetDirname, targetFilename);
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: targetFormat,
sourceFormat
format
});
dispatch(
insertTaskIntoQueue({
uid: uuid(),
type: 'OPEN_REQUEST',
collectionUid: targetCollection.uid,
itemPathname: targetPathname,
preview: false
closeTabs({
tabUids: [item.uid]
})
);
dispatch(closeTabs({ tabUids: [item.uid] }));
dispatch({
type: 'collections/deleteItem',
payload: {
@@ -241,7 +144,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
toast.success('Request saved successfully');
handleClose();
} catch (err) {
toast.error(formatIpcError(err) || 'Failed to save request');
toast.error(err?.message || 'Failed to save request');
console.error('Error saving request:', err);
}
};
@@ -251,7 +154,6 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
setNewFolderName('');
setNewFolderDirectoryName('');
setShowFilesystemName(false);
setIsEditingFolderFilename(false);
};
const handleCancelNewFolder = () => {
@@ -259,38 +161,26 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
setNewFolderName('');
setNewFolderDirectoryName('');
setShowFilesystemName(false);
setIsEditingFolderFilename(false);
};
const handleNewFolderNameChange = (value) => {
setNewFolderName(value);
if (!isEditingFolderFilename) {
if (!showFilesystemName) {
setNewFolderDirectoryName(sanitizeName(value));
}
};
const handleDirectoryNameChange = (value) => {
setNewFolderDirectoryName(value);
};
const handleCreateNewFolder = async () => {
const trimmedFolderName = newFolderName.trim();
if (!trimmedFolderName) {
toast.error('Folder name is required');
return;
}
if (!validateName(trimmedFolderName)) {
toast.error(validateNameError(trimmedFolderName));
return;
}
const directoryName = newFolderDirectoryName.trim() || sanitizeName(trimmedFolderName);
const directoryName = newFolderDirectoryName.trim() || sanitizeName(newFolderName.trim());
const parentFolder = getCurrentParentFolder();
const targetCollectionUid = selectedTargetCollection?.uid || collection?.uid;
try {
await dispatch(newFolder(trimmedFolderName, directoryName, targetCollectionUid, parentFolder?.uid));
await dispatch(newFolder(newFolderName.trim(), directoryName, collection?.uid, parentFolder?.uid));
toast.success('New folder created!');
setPendingFolderNavigation(directoryName);
handleCancelNewFolder();
} catch (err) {
const errorMessage = err?.message || 'An error occurred while adding the folder';
@@ -303,11 +193,6 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
setSearchText('');
};
const handleBreadcrumbNavigate = useCallback((index) => {
navigateToBreadcrumb(index);
setSearchText('');
}, [navigateToBreadcrumb]);
if (!isOpen) {
return null;
}
@@ -316,7 +201,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
<StyledWrapper>
<Modal
size="md"
title={isSelectingCollection ? 'Select Collection' : 'Save Request'}
title="Save Request"
handleCancel={handleCancel}
handleConfirm={handleConfirm}
confirmText="Save"
@@ -338,253 +223,168 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
spellCheck="false"
value={requestName}
onChange={(e) => setRequestName(e.target.value)}
autoFocus={!isSelectingCollection}
autoFocus={true}
onFocus={(e) => e.target.select()}
/>
</div>
<div className="collections-section">
<div className="collections-label">
{isSelectingCollection ? 'Select a collection to save to' : 'Save to Collections'}
</div>
{isScratchCollection && (
<div className="collection-name">
<span
className={isSelectingCollection ? '' : 'collection-name-breadcrumb'}
onClick={!isSelectingCollection ? () => {
setIsSelectingCollection(true);
setSelectedTargetCollectionPath(null);
reset();
} : undefined}
>
Collections
</span>
{!isSelectingCollection && (
<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 && (
<>
<IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />
<FolderBreadcrumbs
collectionName={(selectedTargetCollection || collection).name}
breadcrumbs={breadcrumbs}
isAtRoot={isAtRoot}
onNavigateToRoot={navigateToRoot}
onNavigateToBreadcrumb={handleBreadcrumbNavigate}
/>
{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>
)}
{isSelectingCollection ? (
<div className="collection-list">
{availableCollections.length > 0 ? (
<ul className="collection-list-items">
{availableCollections.map((coll) => {
const collPath = coll.path || coll.pathname;
return (
<CollectionListItem
key={collPath}
collectionUid={coll.uid}
collectionPath={collPath}
collectionName={coll.name}
isSelected={selectedTargetCollectionPath === collPath}
onSelect={() => handleSelectCollection(coll)}
/>
);
})}
</ul>
) : (
<div className="collection-empty-state">
No collections available in workspace. Please add a collection to the workspace first.
</div>
)}
</div>
) : (
<>
{!isScratchCollection && (selectedTargetCollection || collection) && (
<div className="collection-name">
<FolderBreadcrumbs
collectionName={(selectedTargetCollection || collection).name}
breadcrumbs={breadcrumbs}
isAtRoot={isAtRoot}
onNavigateToRoot={navigateToRoot}
onNavigateToBreadcrumb={handleBreadcrumbNavigate}
/>
</div>
)}
<div className="search-container">
<SearchInput
searchText={searchText}
setSearchText={setSearchText}
placeholder="Search for folder"
autoFocus={false}
/>
</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-header">
<IconFolder size={16} strokeWidth={1.5} />
<label className="new-folder-header-label">
{showFilesystemName ? 'New Folder name (in bruno)' : 'New Folder name'}
</label>
</div>
<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();
e.stopPropagation();
handleCreateNewFolder();
} else if (e.key === 'Escape') {
e.stopPropagation();
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 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">
<div className="flex items-center justify-between">
<label className="new-folder-filesystem-label flex items-center font-medium">
Folder Name <small className="font-normal text-muted ml-1">(on filesystem)</small>
<Help width={300} placement="top">
<p>
You can choose to save the folder as a different name on your file system versus what is displayed in the app.
</p>
</Help>
</label>
{isEditingFolderFilename ? (
<IconArrowBackUp
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => setIsEditingFolderFilename(false)}
/>
) : (
<IconEdit
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => setIsEditingFolderFilename(true)}
/>
)}
</div>
{isEditingFolderFilename ? (
<div className="relative flex flex-row gap-1 items-center justify-between">
<input
type="text"
className="block textbox mt-2 w-full"
placeholder="Folder Name"
value={newFolderDirectoryName}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={(e) => setNewFolderDirectoryName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
handleCreateNewFolder();
} else if (e.key === 'Escape') {
e.stopPropagation();
handleCancelNewFolder();
}
}}
/>
</div>
) : (
<div className="relative flex flex-row gap-1 items-center justify-between">
<PathDisplay
iconType="folder"
baseName={newFolderDirectoryName}
/>
</div>
)}
<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>
)}
<button
type="button"
className="new-folder-toggle-filesystem-btn"
onClick={() => {
setShowFilesystemName(!showFilesystemName);
setNewFolderDirectoryName(sanitizeName(newFolderName));
setIsEditingFolderFilename(false);
}}
>
{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>
<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 && !isSelectingCollection && (
{!showNewFolderInput && (
<Button
type="button"
color="primary"
@@ -600,11 +400,9 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
<Button type="button" color="secondary" variant="ghost" onClick={handleCancel}>
Cancel
</Button>
{!isSelectingCollection && (
<Button type="button" color="primary" onClick={handleConfirm}>
Save
</Button>
)}
<Button type="button" color="primary" onClick={handleConfirm}>
Save
</Button>
</div>
</div>
</Modal>

View File

@@ -1,28 +0,0 @@
import styled from 'styled-components';
import { darken } from 'polished';
const StyledWrapper = styled.div`
.current-group {
background-color: ${(props) => props.theme.background.surface1};
border-radius: 4px;
padding: 0.4rem;
cursor: pointer;
border: 1px solid ${(props) => props.theme.background.surface2};
}
.current-group:hover {
background-color: ${(props) => darken(0.03, props.theme.background.surface1)};
border-color: ${(props) => darken(0.03, props.theme.background.surface2)};
}
/* Fix dropdown positioning */
[data-tippy-root] {
left: 0 !important;
}
.bruno-modal-footer {
padding-top: 0;
}
`;
export default StyledWrapper;

View File

@@ -1,887 +0,0 @@
import React, { useRef, useEffect, useState, useMemo, forwardRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { browseDirectory, importCollection } from 'providers/ReduxStore/slices/collections/actions';
import Modal from 'components/Modal';
import { isElectron } from 'utils/common/platform';
import { IconX, IconLoader2, IconCheck, IconCaretDown } from '@tabler/icons';
import InfoTip from 'components/InfoTip/index';
import Help from 'components/Help';
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import Dropdown from 'components/Dropdown';
import { postmanToBruno } from 'utils/importers/postman-collection';
import { convertInsomniaToBruno } from 'utils/importers/insomnia-collection';
import { convertOpenapiToBruno } from 'utils/importers/openapi-collection';
import { processBrunoCollection } from 'utils/importers/bruno-collection';
import { wsdlToBruno } from '@usebruno/converters';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import get from 'lodash/get';
const STATUS = {
LOADING: 'loading',
SUCCESS: 'success',
ERROR: 'error'
};
const IMPORT_TYPE = {
BULK: 'bulk',
MULTIPLE: 'multiple'
};
const groupingOptions = [
{ value: 'tags', label: 'Tags', description: 'Group requests by OpenAPI tags', testId: 'grouping-option-tags' },
{ value: 'path', label: 'Paths', description: 'Group requests by URL path structure', testId: 'grouping-option-path' }
];
// Extract collection name from raw data
const getCollectionName = (format, rawData) => {
if (!rawData) return 'Collection';
switch (format) {
case 'openapi':
return rawData.info?.title || 'OpenAPI Collection';
case 'postman':
return rawData.info?.name || rawData.collection?.info?.name || 'Postman Collection';
case 'insomnia':
// For Insomnia v4 format, name is in the workspace resource
if (rawData.resources && Array.isArray(rawData.resources)) {
const workspace = rawData.resources.find((r) => r._type === 'workspace');
if (workspace?.name) {
return workspace.name;
}
}
// Fallback to root name property
return rawData.name || 'Insomnia Collection';
case 'bruno':
return rawData.name || 'Bruno Collection';
case 'wsdl':
return 'WSDL Collection';
default:
return 'Collection';
}
};
// Convert raw data to Bruno collection format
const convertCollection = async (format, rawData, groupingType) => {
let collection;
switch (format) {
case 'openapi':
collection = convertOpenapiToBruno(rawData, { groupBy: groupingType });
break;
case 'wsdl':
collection = await wsdlToBruno(rawData);
break;
case 'postman':
collection = await postmanToBruno(rawData);
break;
case 'insomnia':
collection = convertInsomniaToBruno(rawData);
break;
case 'bruno':
collection = await processBrunoCollection(rawData);
break;
default:
throw new Error('Unknown collection format');
}
return collection;
};
export function normalizeName(name) {
if (typeof name !== 'string') {
return '';
}
return name.trim().toLowerCase();
}
/**
* Generate a unique name by adding "copy" suffix if the name already exists.
* @param {string} baseName - The original name
* @param {function} checkExists - Function that returns true if name exists
* @returns {string} - Unique name with "copy" suffix if needed
*/
export function generateUniqueName(baseName, checkExists) {
const normalizedBase = normalizeName(baseName);
if (!checkExists(normalizedBase)) {
return baseName;
}
let counter = 1;
let uniqueName = `${baseName} copy`;
while (checkExists(normalizeName(uniqueName))) {
counter++;
uniqueName = `${baseName} copy ${counter}`;
}
return uniqueName;
}
export const BulkImportCollectionLocation = ({
onClose,
handleSubmit,
importData
}) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const preferences = useSelector((state) => state.app.preferences);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const isDefaultWorkspace = !activeWorkspace || activeWorkspace.type === 'default';
const defaultLocation = isDefaultWorkspace
? get(preferences, 'general.defaultCollectionLocation', '')
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
const [status, setStatus] = useState({});
const [errorMessages, setErrorMessages] = useState({});
const [importStarted, setImportStarted] = useState(false);
const [environmentStatus, setEnvironmentStatus] = useState({});
const [showErrorModal, setShowErrorModal] = useState(false);
const [selectedError, setSelectedError] = useState(null);
const [applyToGlobal, setApplyToGlobal] = useState(true);
const [applyToCollection, setApplyToCollection] = useState(false);
const [groupingType, setGroupingType] = useState('tags');
const [collectionFormat, setCollectionFormat] = useState('bru');
const [renamedCollectionNames, setRenamedCollectionNames] = useState({});
const [renamedEnvironmentNames, setRenamedEnvironmentNames] = useState({});
// Extract data based on import type
const importType = importData?.type;
const isBulkImport = importType === IMPORT_TYPE.BULK;
const isMultipleImport = importType === IMPORT_TYPE.MULTIPLE;
// For bulk import (ZIP files)
const importedCollectionFromBulk = isBulkImport ? importData.collection : [];
const importedEnvironmentFromBulk = isBulkImport ? (importData.environment || []) : [];
// For multiple files import
const filesData = isMultipleImport ? importData.filesData : [];
const hasOpenApiSpec = filesData.some((f) => f.type === 'openapi');
// Create unified collection structure for display
const importedCollection = isMultipleImport
? filesData.map((fileData, index) => ({
uid: `file-${index}`,
name: getCollectionName(fileData.type, fileData.data),
_fileData: fileData
}))
: importedCollectionFromBulk;
const importedEnvironment = isBulkImport ? importedEnvironmentFromBulk : [];
const globalEnvironments = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
const existingCollections = useSelector((state) => state?.collections?.collections || []);
// Initialize selected items based on import type
const [selectedCollections, setSelectedCollections] = useState(importedCollection.map((col) => col.uid));
const [selectedEnvironments, setSelectedEnvironments] = useState(isBulkImport ? importedEnvironmentFromBulk.map((env) => env.uid) : []);
const allCollectionsSelected = selectedCollections.length === importedCollection.length;
const allEnvironmentsSelected = selectedEnvironments.length === importedEnvironment.length;
// Sort collections to show selected items first, then unselected items
// This helps users see their selections at the top of the list
const sortedCollections = useMemo(() => {
const arr = [...importedCollection];
arr.sort((a, b) => {
const aSelected = selectedCollections.includes(a.uid);
const bSelected = selectedCollections.includes(b.uid);
// Convert boolean to number: true = 1, false = 0
// bSelected - aSelected means: selected items (1) come before unselected (0)
return Number(bSelected) - Number(aSelected);
});
return arr;
}, [importedCollection, selectedCollections]);
// Sort environments to show selected items first, then unselected items
// This helps users see their selections at the top of the list
const sortedEnvironments = useMemo(() => {
const arr = [...importedEnvironment];
arr.sort((a, b) => {
const aSelected = selectedEnvironments.includes(a.uid);
const bSelected = selectedEnvironments.includes(b.uid);
// selected (true) should come before unselected (false)
return Number(bSelected) - Number(aSelected);
});
return arr;
}, [importedEnvironment, selectedEnvironments]);
const importStatus = useMemo(() => {
const selectedSet = new Set(selectedCollections);
const totalSelected = selectedCollections.length;
const failedCount = Object.entries(status).reduce((acc, [uid, s]) => {
return selectedSet.has(uid) && s === STATUS.ERROR ? acc + 1 : acc;
}, 0);
return {
totalSelected,
failedCount
};
}, [status, selectedCollections]);
// Handlers
const handleCollectionToggle = (uid) => {
setSelectedCollections((prev) =>
prev.includes(uid) ? prev.filter((id) => id !== uid) : [...prev, uid]
);
};
const handleEnvironmentToggle = (uid) => {
setSelectedEnvironments((prev) =>
prev.includes(uid) ? prev.filter((id) => id !== uid) : [...prev, uid]
);
};
const handleSelectAllCollections = (e) => {
setSelectedCollections(e.target.checked ? importedCollection.map((col) => col.uid) : []);
};
const handleSelectAllEnvironments = (e) => {
setSelectedEnvironments(
e.target.checked ? importedEnvironment.map((env) => env.uid) : []
);
};
const onDropdownCreate = (ref) => {
dropdownTippyRef.current = ref;
};
const GroupingDropdownIcon = forwardRef((props, ref) => {
const selectedOption = groupingOptions.find((option) => option.value === groupingType);
return (
<div ref={ref} className="flex items-center justify-between w-full current-group" data-testid="grouping-dropdown">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{selectedOption.label}</div>
</div>
<IconCaretDown size={16} className="text-gray-400 ml-[0.25rem]" fill="currentColor" />
</div>
);
});
const formik = useFormik({
enableReinitialize: true,
initialValues: {
collectionLocation: defaultLocation
},
validationSchema: Yup.object({
collectionLocation: Yup.string()
.min(1, 'must be at least 1 character')
.max(500, 'must be 500 characters or less')
.required('Location is required')
}),
onSubmit: async (values) => {
let filteredCollections = [];
const selectedItems = importedCollection.filter((col) => selectedCollections.includes(col.uid));
if (isMultipleImport) {
// Convert selected files to collections at submit time
for (const item of selectedItems) {
try {
const collection = await convertCollection(item._fileData.type, item._fileData.data, groupingType);
if (collection) {
// Preserve the synthetic UID so status tracking, rename tracking,
// and UI rendering all use the same key
collection.uid = item.uid;
filteredCollections.push(collection);
}
} catch (err) {
console.warn(`Failed to convert file ${item._fileData.file.name}:`, err);
}
}
} else if (isBulkImport) {
// For bulk import, use selected collections directly
filteredCollections = selectedItems;
}
const initialStatus = {};
filteredCollections.forEach((col) => {
initialStatus[col.uid] = STATUS.LOADING;
});
setStatus(initialStatus);
setErrorMessages({});
const filteredEnvironments = importedEnvironment.filter((env) =>
selectedEnvironments.includes(env.uid)
);
// Handle duplicate collection names by renaming new ones to a unique "{originalName} N" suffix
const existingCollectionNames = new Set(existingCollections.map((col) => normalizeName(col.name)));
const usedNames = new Set();
const renamedNames = {};
filteredCollections.forEach((collection) => {
const originalName = collection.name;
let finalName = originalName;
let index = 0;
while (existingCollectionNames.has(normalizeName(finalName)) || usedNames.has(normalizeName(finalName))) {
finalName = `${originalName} ${index + 1}`;
index++;
}
collection.name = finalName;
usedNames.add(normalizeName(finalName));
// Store renamed name for summary display
if (finalName !== originalName) {
renamedNames[collection.uid] = finalName;
}
});
setRenamedCollectionNames(renamedNames);
// Process all selected environments and rename duplicates
// Don't use getUniqueEnvironments as it filters out duplicates - we want to rename them instead
const collectionRenamedEnvNames = {};
const globalRenamedEnvNames = {};
if (applyToCollection) {
// add selected environments to each selected collection
// Rename duplicates with "copy" suffix instead of filtering them out
filteredCollections.forEach((collection) => {
const existingNamesSet = new Set((collection.environments || []).map((e) => normalizeName(e?.name)));
const usedNamesInBatch = new Set();
const envsForCollection = filteredEnvironments.map((env) => {
const originalName = env.name;
const normalizedOriginalName = normalizeName(originalName);
// Check if name exists in collection or was already used in this batch
const checkExists = (name) => existingNamesSet.has(name) || usedNamesInBatch.has(name);
const finalName = generateUniqueName(originalName, checkExists);
// Track renamed name for summary display
if (finalName !== originalName) {
collectionRenamedEnvNames[env.uid] = finalName;
}
usedNamesInBatch.add(normalizeName(finalName));
existingNamesSet.add(normalizeName(finalName));
return { ...env, name: finalName };
});
collection.environments = envsForCollection;
});
// Mark all collection environments as success (they're processed with the collection import)
const envStatusUpdate = {};
filteredEnvironments.forEach((env) => {
envStatusUpdate[env.uid] = STATUS.SUCCESS;
});
setEnvironmentStatus((prev) => ({ ...prev, ...envStatusUpdate }));
if (Object.keys(collectionRenamedEnvNames).length > 0) {
setRenamedEnvironmentNames((prev) => ({ ...prev, ...collectionRenamedEnvNames }));
}
}
if (applyToGlobal && filteredEnvironments.length > 0) {
// Pre-compute unique names for all environments to avoid race conditions
const existingGlobalNames = new Set((globalEnvironments || []).map((env) => normalizeName(env?.name)));
const usedNamesInBatch = new Set();
const envsToImport = [];
filteredEnvironments.forEach((environment) => {
const checkExists = (name) => existingGlobalNames.has(name) || usedNamesInBatch.has(name);
const uniqueName = generateUniqueName(environment.name, checkExists);
if (uniqueName !== environment.name) {
globalRenamedEnvNames[environment.uid] = uniqueName;
}
usedNamesInBatch.add(normalizeName(uniqueName));
envsToImport.push({ ...environment, name: uniqueName });
});
if (Object.keys(globalRenamedEnvNames).length > 0) {
setRenamedEnvironmentNames((prev) => ({ ...prev, ...globalRenamedEnvNames }));
}
envsToImport.forEach((envToImport) => {
const originalUid = envToImport.uid;
setEnvironmentStatus((prev) => ({ ...prev, [originalUid]: STATUS.LOADING }));
dispatch(addGlobalEnvironment(envToImport))
.then(() => setEnvironmentStatus((prev) => ({ ...prev, [originalUid]: STATUS.SUCCESS })))
.catch((error) => {
setEnvironmentStatus((prev) => ({ ...prev, [originalUid]: STATUS.ERROR }));
setErrorMessages((prev) => ({ ...prev, [originalUid]: error.message || 'Failed to add environment' }));
});
});
}
setImportStarted(true);
if (filteredCollections.length > 1 || isBulkImport || isMultipleImport) {
dispatch(importCollection(filteredCollections, values.collectionLocation, { format: collectionFormat }))
.catch((err) => {
console.error('Failed to import collections', err);
filteredCollections.forEach((collection) => {
setStatus((prev) => ({ ...prev, [collection.uid]: STATUS.ERROR }));
setErrorMessages((prev) => ({ ...prev, [collection.uid]: err.message || 'Failed to import collection' }));
});
});
} else {
handleSubmit(filteredCollections[0], values.collectionLocation, { format: collectionFormat });
}
}
});
const browse = () => {
dispatch(browseDirectory())
.then((dirPath) => {
if (typeof dirPath === 'string' && dirPath.length > 0) {
formik.setFieldValue('collectionLocation', dirPath);
}
})
.catch((error) => {
formik.setFieldValue('collectionLocation', '');
console.error(error);
});
};
useEffect(() => {
if (!isElectron()) {
return () => {};
}
const { ipcRenderer } = window;
const handleImportStatus = (collectionId, status, errorMessage = '') => {
setStatus((prev) => ({ ...prev, [collectionId]: status }));
if (status === STATUS.ERROR) {
setErrorMessages((prev) => ({
...prev,
[collectionId]: errorMessage
}));
}
};
const importingCollectionStarted = ipcRenderer.on(
'main:collection-import-started',
(collectionId) => {
handleImportStatus(collectionId, STATUS.LOADING);
}
);
const importingCollectionCompleted = ipcRenderer.on(
'main:collection-import-ended',
(collectionId) => {
handleImportStatus(collectionId, STATUS.SUCCESS);
}
);
const importingCollectionFailed = ipcRenderer.on(
'main:collection-import-failed',
(collectionId, { message }) => {
handleImportStatus(collectionId, STATUS.ERROR, message);
}
);
const allCollectionsImportCompleted = ipcRenderer.on(
'main:all-collections-import-ended',
(report) => {
toast.success(report?.message);
}
);
return () => {
importingCollectionStarted();
importingCollectionCompleted();
importingCollectionFailed();
allCollectionsImportCompleted();
};
}, []);
const onSubmit = () => {
if (importStarted) {
onClose();
} else {
formik.handleSubmit();
}
};
const handleErrorClick = (error, uid) => {
setSelectedError({ message: error, uid });
setShowErrorModal(true);
};
const ErrorModal = ({ error, onClose }) => (
<Modal
size="sm"
title="Error Details"
handleConfirm={onClose}
handleCancel={onClose}
showCancelButton={false}
disableCloseOnOutsideClick={true}
hideFooter={true}
>
<div className="p-4">
<pre className="whitespace-pre-wrap text-red-600 text-sm">{error}</pre>
</div>
</Modal>
);
return (
<StyledWrapper>
<Modal
size="md"
title="Bulk Import"
confirmText={importStarted ? 'Close' : 'Import'}
confirmDisabled={Boolean(!selectedCollections?.length)}
handleConfirm={onSubmit}
handleCancel={onClose}
showConfirm={true}
disableCloseOnOutsideClick={true}
disableEscapeKey={false}
hideCancel={importStarted}
>
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
<div className="flex flex-col">
{importStarted ? (
<>
<div className="mb-6">
<div className="flex items-center justify-between relative mb-5 w-full">
<div className="font-semibold">Location</div>
<div className="text-sm border border-slate-600 rounded px-3 py-1.5 ml-4 flex-1">
{formik.values.collectionLocation
|| 'No location selected'}
</div>
</div>
<div className="flex items-center justify-between mb-2">
<div className="font-semibold">
Importing Collections ({importStatus.totalSelected})
</div>
{importStatus.failedCount > 0 && importStatus.totalSelected > 0 && (
<div className="text-sm text-red-500">
({importStatus.failedCount}/{importStatus.totalSelected} failed)
</div>
)}
</div>
<div className="max-h-[180px] overflow-y-scroll border border-slate-600 rounded-md py-2 scrollbar-visible">
{sortedCollections
.filter((collection) =>
selectedCollections.includes(collection.uid)
)
.map((collection) => (
<div
key={collection.uid}
className="flex items-center px-4 py-1.5 text-sm font-normal justify-between"
>
<div className="flex items-center flex-1">
<div className="flex items-center mr-2">
{status[collection.uid] === STATUS.LOADING && (
<IconLoader2
className="animate-spin text-blue-500"
size={16}
strokeWidth={1.5}
/>
)}
{status[collection.uid] === STATUS.SUCCESS && (
<div className="flex items-center text-green-500">
<IconCheck size={16} strokeWidth={1.5} />
</div>
)}
{status[collection.uid] === STATUS.ERROR && (
<div className="flex items-center">
<IconX
className="text-red-500"
size={16}
strokeWidth={1.5}
/>
</div>
)}
</div>
<span>{renamedCollectionNames[collection.uid] || collection.name}</span>
</div>
{status[collection.uid] === STATUS.ERROR && (
<button
onClick={() =>
handleErrorClick(
errorMessages[collection.uid],
collection.uid
)}
className="text-red-500 text-sm hover:underline"
>
See error
</button>
)}
</div>
))}
</div>
</div>
{selectedEnvironments.length > 0 && (
<div className="mb-6">
<div className="font-semibold mb-2">
Importing Environments ({selectedEnvironments.length})
</div>
<div className="max-h-[180px] overflow-y-scroll border border-slate-600 rounded-md py-2 scrollbar-visible">
{sortedEnvironments
.filter((env) => selectedEnvironments.includes(env.uid))
.map((env) => (
<div
key={env.uid}
className="flex items-center px-4 py-1.5 text-sm font-normal justify-between"
>
<div className="flex items-center flex-1">
<div className="flex items-center mr-2">
{!environmentStatus[env.uid] || environmentStatus[env.uid] === STATUS.LOADING ? (
<IconLoader2
className="animate-spin text-blue-500"
size={16}
strokeWidth={1.5}
/>
) : environmentStatus[env.uid] === STATUS.SUCCESS ? (
<div className="flex items-center text-green-500">
<IconCheck size={16} strokeWidth={1.5} />
</div>
) : environmentStatus[env.uid] === STATUS.ERROR ? (
<div className="flex items-center">
<IconX
className="text-red-500"
size={16}
strokeWidth={1.5}
/>
</div>
) : null}
</div>
<span>{renamedEnvironmentNames[env.uid] || env.name}</span>
</div>
{environmentStatus[env.uid] === STATUS.ERROR && (
<button
onClick={() =>
handleErrorClick(
errorMessages[env.uid],
env.uid
)}
className="text-red-500 text-sm hover:underline"
>
See error
</button>
)}
</div>
))}
</div>
</div>
)}
</>
) : (
<>
<div className="mb-6">
<div className="font-semibold mb-2 flex justify-between items-center">
<span>Collections ({importedCollection.length})</span>
<label className="flex items-center text-sm font-normal select-none cursor-pointer">
<input
type="checkbox"
checked={allCollectionsSelected}
onChange={handleSelectAllCollections}
className="mr-2"
/>
Select All
</label>
</div>
<div className="max-h-[180px] overflow-y-scroll border border-slate-600 rounded-md py-2">
{importedCollection.length === 0 && (
<div className="px-4 py-2 text-gray-400 italic">
No collections found
</div>
)}
{sortedCollections.map((collection) => (
<label
key={collection.uid}
className="flex items-center px-4 py-1.5 text-sm font-normal select-none cursor-pointer justify-between"
>
<div className="flex items-center flex-1">
<input
type="checkbox"
checked={selectedCollections.includes(collection.uid)}
onChange={() => handleCollectionToggle(collection.uid)}
className="mr-3"
/>
<span>{collection.name}</span>
</div>
</label>
))}
</div>
</div>
{importType === 'bulk' && (
<>
<div className="mb-4">
<div className="font-semibold mb-2 flex justify-between items-center">
<span>Environments ({importedEnvironment.length})</span>
<label className="flex items-center text-sm font-normal select-none cursor-pointer">
<input
type="checkbox"
checked={allEnvironmentsSelected}
onChange={handleSelectAllEnvironments}
className="mr-2"
/>
Select All
</label>
</div>
<div className="max-h-[180px] overflow-y-scroll border border-slate-600 rounded-md py-2 scrollbar-visible">
{importedEnvironment.length === 0 && (
<div className="px-4 py-2 text-gray-400 italic">
No environments found
</div>
)}
{sortedEnvironments.map((env) => (
<label
key={env.uid}
className="flex items-center px-4 py-1.5 text-sm font-normal select-none cursor-pointer"
>
<input
type="checkbox"
checked={selectedEnvironments.includes(env.uid)}
onChange={() => handleEnvironmentToggle(env.uid)}
className="mr-3"
/>
<span>{env.name}</span>
</label>
))}
</div>
</div>
<div className="mb-6">
<div className="font-semibold mb-2">
Environment Assignment
</div>
<div className="flex gap-8 mt-2 ml-2">
<label className="flex items-center">
<input
type="checkbox"
checked={applyToGlobal}
onChange={(e) => setApplyToGlobal(e.target.checked)}
className="mr-2"
/>
<span className="ml-2">
Global Environment
<InfoTip
content="Environments will be imported and stored as global, accessible across collections."
infotipId="apply-to-global-infotip"
/>
</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={applyToCollection}
onChange={(e) =>
setApplyToCollection(e.target.checked)}
className="mr-2"
/>
<span className="ml-2">
Duplicate Across Collections
<InfoTip
content="Each imported collection will receive its own copy of the environments."
infotipId="apply-to-each-infotip"
/>
</span>
</label>
</div>
</div>
</>
)}
<div className="flex items-start flex-col relative">
<div className="font-semibold mb-2">Location</div>
<input
id="collection-location"
type="text"
placeholder="Select a location to save the collection"
name="collectionLocation"
className="block textbox w-full cursor-pointer"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.collectionLocation || ''}
onClick={browse}
onChange={(e) => {
formik.setFieldValue('collectionLocation', e.target.value);
}}
/>
{formik.touched.collectionLocation && formik.errors.collectionLocation ? (
<div className="text-red-500 mt-1">
{formik.errors.collectionLocation}
</div>
) : null}
<div className="mt-1">
<span className="text-link cursor-pointer hover:underline" onClick={browse}>
Browse
</span>
</div>
</div>
<div className="mt-4">
<label htmlFor="format" className="flex items-center font-semibold">
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={collectionFormat}
onChange={(e) => setCollectionFormat(e.target.value)}
>
<option value="yml">OpenCollection (YAML)</option>
<option value="bru">BRU Format (.bru)</option>
</select>
</div>
{isMultipleImport && hasOpenApiSpec && (
<div>
<div className="flex gap-4 items-center">
<div>
<label htmlFor="groupingType" className="block font-semibold">
Folder arrangement
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1 mb-2">
Select whether to create folders according to the spec's paths or tags.
</p>
</div>
<div className="relative">
<Dropdown onCreate={onDropdownCreate} icon={<GroupingDropdownIcon />} placement="bottom-start">
{groupingOptions.map((option) => (
<div
key={option.value}
className="dropdown-item"
data-testid={option.testId}
onClick={() => {
dropdownTippyRef?.current?.hide();
setGroupingType(option.value);
}}
>
{option.label}
</div>
))}
</Dropdown>
</div>
</div>
</div>
)}
</>
)}
</div>
</form>
</Modal>
{showErrorModal && (
<ErrorModal
error={selectedError?.message}
onClose={() => setShowErrorModal(false)}
/>
)}
</StyledWrapper>
);
};
export default BulkImportCollectionLocation;

View File

@@ -1,30 +0,0 @@
import { normalizeName, generateUniqueName } from './index';
describe('BulkImportCollectionLocation helpers', () => {
describe('normalizeName', () => {
it('should trim and lowercase names', () => {
expect(normalizeName(' Beta ')).toBe('beta');
expect(normalizeName('TEST')).toBe('test');
expect(normalizeName(null)).toBe('');
});
});
describe('generateUniqueName', () => {
it('should return original name if no conflict', () => {
const checkExists = () => false;
expect(generateUniqueName('Beta', checkExists)).toBe('Beta');
});
it('should add "copy" suffix on first conflict', () => {
const existing = new Set(['beta']);
const checkExists = (name) => existing.has(name);
expect(generateUniqueName('Beta', checkExists)).toBe('Beta copy');
});
it('should increment copy number on multiple conflicts', () => {
const existing = new Set(['beta', 'beta copy']);
const checkExists = (name) => existing.has(name);
expect(generateUniqueName('Beta', checkExists)).toBe('Beta copy 2');
});
});
});

View File

@@ -1,18 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.info-box {
background-color: ${(props) => props.theme.background.mantle};
color: ${(props) => props.theme.text};
border: 1px solid ${(props) => props.theme.border.border2};
padding: 10px;
border-radius: 5px;
margin-top: 5px;
width: 400px;
white-space: pre-wrap;
max-height: 150px;
overflow-y: auto;
}
`;
export default StyledWrapper;

View File

@@ -1,372 +0,0 @@
import React, { useRef, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import {
browseDirectory,
cloneGitRepository,
openMultipleCollections,
scanForBrunoFiles
} from 'providers/ReduxStore/slices/collections/actions';
import { removeGitOperationProgress } from 'providers/ReduxStore/slices/app';
import Modal from 'components/Modal';
import * as path from 'path';
import Portal from 'components/Portal';
import { IconRefresh, IconCheck, IconAlertCircle, IconBrandGit } from '@tabler/icons';
import { uuid } from 'utils/common/index';
import StyledWrapper from './StyledWrapper';
import { getRepoNameFromUrl } from 'utils/git';
import GitNotFoundModal from 'components/Git/GitNotFoundModal/index';
import get from 'lodash/get';
const CloneGitRepository = ({ onClose, onFinish, collectionRepositoryUrl = null }) => {
const [collectionPaths, setCollectionPaths] = useState([]);
const [selectedCollectionPaths, setSelectedCollectionPaths] = useState([]);
const [processUid, setProcessUid] = useState(uuid());
const [steps, setSteps] = useState([]);
const [view, setView] = useState('form');
const progressData = useSelector((state) => state.app.gitOperationProgress[processUid]);
const { gitVersion } = useSelector((state) => state.app);
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const preferences = useSelector((state) => state.app.preferences);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const isDefaultWorkspace = !activeWorkspace || activeWorkspace.type === 'default';
const defaultLocation = isDefaultWorkspace
? get(preferences, 'general.defaultCollectionLocation', '')
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
const inputRef = useRef();
const dispatch = useDispatch();
useEffect(() => {
if (progressData) {
setSteps((prev) =>
prev.map((step) =>
step.step === 'clone' && !step?.completed
? { ...step, title: 'Cloning repository', completed: false, info: progressData.progressData }
: step
)
);
}
}, [progressData]);
useEffect(() => {
if (inputRef?.current) {
inputRef.current.focus();
}
}, []);
const cloneInProgress = () => {
setSteps((prev) => [
...prev,
{
step: 'clone',
title: 'Cloning repository',
completed: false
}
]);
};
const cloneFinished = () => {
setSteps((prev) =>
prev.map((step) =>
step.step === 'clone'
? { ...step, title: 'Cloning successful', completed: true, info: '' }
: step
)
);
};
const cloneError = () => {
setSteps((prev) =>
prev.map((step) =>
step.step === 'clone'
? { ...step, title: 'Cloning failed', completed: true, error: true }
: step
)
);
};
const scanInProgress = () => {
setSteps((prev) => [
...prev,
{
step: 'scan',
title: 'Scanning for Bruno files',
completed: false
}
]);
};
const scanFinished = () => {
setSteps((prev) =>
prev.map((step) =>
step.step === 'scan' ? { ...step, title: 'Scan successful', completed: true, info: '' } : step
)
);
};
const formik = useFormik({
enableReinitialize: true,
initialValues: {
repositoryUrl: collectionRepositoryUrl || '',
collectionLocation: defaultLocation
},
validationSchema: Yup.object({
repositoryUrl: Yup.string().required('Repository URL is required'),
collectionLocation: Yup.string().min(1, 'Location is required').required('Location is required')
}),
onSubmit: async (values) => {
try {
setView('progress');
cloneInProgress();
const { repositoryUrl, collectionLocation } = values;
const repoName = getRepoNameFromUrl(repositoryUrl);
const targetPath = path.join(collectionLocation, repoName);
await dispatch(cloneGitRepository({ url: values.repositoryUrl, path: targetPath, processUid }));
cloneFinished();
dispatch(removeGitOperationProgress(processUid));
scanInProgress();
const foundCollectionPaths = await dispatch(scanForBrunoFiles(targetPath));
scanFinished();
setCollectionPaths(foundCollectionPaths);
} catch (err) {
cloneError();
dispatch(removeGitOperationProgress(processUid));
console.error(err);
}
}
});
const browse = () => {
dispatch(browseDirectory())
.then((dirPath) => {
if (typeof dirPath === 'string') {
formik.setFieldValue('collectionLocation', dirPath);
}
})
.catch((error) => {
formik.setFieldValue('collectionLocation', '');
console.error(error);
});
};
const handleCollectionSelect = (collection) => {
setSelectedCollectionPaths((prevSelected) =>
prevSelected.includes(collection)
? prevSelected.filter((c) => c !== collection)
: [...prevSelected, collection]
);
};
const getRelativePath = (fullPath, pathname) => {
let relativePath = path.relative(fullPath, pathname);
const { dir, name } = path.parse(relativePath);
return path.join(dir, name);
};
const isScanCompleted = () => steps.some((step) => step.step === 'scan' && step.completed);
const isConfirmDisabled = () => isScanCompleted() && collectionPaths?.length > 0 && selectedCollectionPaths?.length === 0;
const isFooterHidden = () => steps.some((step) => !step.completed);
const isError = () => steps.some((step) => step.error);
const handleConfirm = () => {
const buttonText = getConfirmText();
switch (buttonText) {
case 'Clone':
formik.handleSubmit();
break;
case 'Close':
onClose();
break;
case 'Open':
if (collectionPaths.length > 0 && selectedCollectionPaths.length > 0) {
dispatch(openMultipleCollections(selectedCollectionPaths));
onClose();
onFinish();
}
break;
default:
break;
}
};
const getConfirmText = () =>
!steps.length
? 'Clone'
: steps.some((step) => !step.completed || step.error || (isScanCompleted() && !collectionPaths?.length))
? 'Close'
: 'Open';
const handleBackButtonClick = () => {
setView('form');
setSteps([]);
setSelectedCollectionPaths([]);
};
if (!gitVersion) {
return <GitNotFoundModal onClose={onClose} />;
}
return (
<Portal id="clone-repository-portal">
<Modal
size="md"
title="Clone Git Repository"
confirmText={getConfirmText()}
handleConfirm={handleConfirm}
handleCancel={onClose}
confirmDisabled={isConfirmDisabled()}
hideFooter={isFooterHidden()}
hideCancel={isError() || (isScanCompleted() && !collectionPaths?.length)}
showBackButton={isError()}
handleBack={handleBackButtonClick}
>
<StyledWrapper>
{view === 'form' && (
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
<div>
{collectionRepositoryUrl
? (
<div className="flex items-start">
<div className="flex-shrink-0 p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<IconBrandGit className="w-6 h-6 text-purple-500" stroke={1.5} />
</div>
<div className="ml-4">
<div className="font-semibold text-sm">{getRepoNameFromUrl(collectionRepositoryUrl)}</div>
<div className="mt-1 text-xs text-muted font-mono">
{collectionRepositoryUrl}
</div>
</div>
</div>
)
: (
<>
<label htmlFor="repository-url" className="flex items-center font-semibold">
Git Repository URL
</label>
<input
id="repository-url"
type="text"
name="repositoryUrl"
ref={inputRef}
className="block textbox mt-2 w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.repositoryUrl || ''}
/>
</>
)}
{formik.touched.repositoryUrl && formik.errors.repositoryUrl && (
<div className="text-red-500">{formik.errors.repositoryUrl}</div>
)}
<label htmlFor="collection-location" className="block font-semibold mt-3">
Location
</label>
<input
id="collection-location"
type="text"
name="collectionLocation"
readOnly
className="block textbox mt-2 w-full cursor-pointer"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.collectionLocation || ''}
onClick={browse}
/>
{formik.touched.collectionLocation && formik.errors.collectionLocation && (
<div className="text-red-500">{formik.errors.collectionLocation}</div>
)}
<div className="mt-1">
<span className="text-link cursor-pointer hover:underline" onClick={browse}>
Browse
</span>
</div>
</div>
</form>
)}
{view === 'progress' && (
<>
{steps.length > 0 && (
<div className="mt-4">
<ul>
{steps.map((step, index) => (
<li key={index} className="flex-col items-center space-x-2 mt-1">
<div className="flex">
{step.error ? (
<IconAlertCircle className="text-red-500" size={18} strokeWidth={1.5} />
) : (
<>
{step.completed ? (
<IconCheck className="text-green-500" size={18} strokeWidth={1.5} />
) : (
<IconRefresh className="text-yellow-500 animate-spin" size={18} strokeWidth={1.5} />
)}
</>
)}
<span className="ml-2">{step.title}</span>
</div>
{step.info && (
<div className="w-full mt-2">
<pre className="info-box ml-4">{step.info}</pre>
</div>
)}
</li>
))}
</ul>
</div>
)}
{isScanCompleted() && (
<div className="mt-4 mb-4">
{collectionPaths.length === 0 && (
<div className="flex">
<IconAlertCircle className="text-yellow-500" size={18} strokeWidth={1.5} />
<h3 className="text-sm ml-2">No bruno collections found in this repository.</h3>
</div>
)}
{collectionPaths.length > 0 && (
<>
<h3 className="text-sm mb-2">
{collectionPaths.length} bruno collections found. Please select the collections to open:
</h3>
<ul>
{collectionPaths.map((collection) => (
<li key={collection} className="mb-2">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={selectedCollectionPaths.includes(collection)}
onChange={() => handleCollectionSelect(collection)}
className="form-checkbox"
/>
<span>{getRelativePath(formik.values.collectionLocation, collection)}</span>
</label>
</li>
))}
</ul>
</>
)}
</div>
)}
</>
)}
</StyledWrapper>
</Modal>
</Portal>
);
};
export default CloneGitRepository;

View File

@@ -2,7 +2,8 @@ import React from 'react';
import Modal from 'components/Modal';
import { isItemAFolder } from 'utils/tabs';
import { useDispatch } from 'react-redux';
import { deleteItem, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { deleteItem } from 'providers/ReduxStore/slices/collections/actions';
import { recursivelyGetAllItemUids } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';

View File

@@ -3,7 +3,8 @@ import Modal from 'components/Modal';
import Portal from 'components/Portal';
import { useDispatch } from 'react-redux';
import { deleteResponseExample } from 'providers/ReduxStore/slices/collections';
import { saveRequest, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
const DeleteResponseExampleModal = ({ onClose, example, item, collection }) => {
const dispatch = useDispatch();

View File

@@ -4,11 +4,12 @@ import * as Yup from 'yup';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { isItemAFolder } from 'utils/tabs';
import { renameItem, saveRequest, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { renameItem, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import path from 'utils/common/path';
import { IconArrowBackUp, IconEdit, IconCaretDown } from '@tabler/icons';
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import Help from 'components/Help';
import PathDisplay from 'components/PathDisplay';
import Portal from 'components/Portal';

View File

@@ -39,7 +39,6 @@ import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from '
import { getDefaultRequestPaneTab } from 'utils/collections';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import { getKeyBindingsForActionAllOS } from 'providers/Hotkeys/keyMappings';
import NetworkError from 'components/ResponsePane/NetworkError/index';
import CollectionItemInfo from './CollectionItemInfo/index';
import CollectionItemIcon from './CollectionItemIcon';
@@ -562,14 +561,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
const isMac = navigator.userAgent?.includes('Mac') || navigator.platform?.startsWith('Mac');
const isModifierPressed = isMac ? e.metaKey : e.ctrlKey;
const [macRenameKey, winRenameKey] = getKeyBindingsForActionAllOS('renameItem');
const renameKey = isMac ? macRenameKey : winRenameKey;
if (e.key.toLowerCase() === renameKey) {
e.preventDefault();
e.stopPropagation();
setRenameItemModalOpen(true);
} else if (isModifierPressed && e.key.toLowerCase() === 'c') {
if (isModifierPressed && e.key.toLowerCase() === 'c') {
e.preventDefault();
e.stopPropagation();
handleCopyItem();

View File

@@ -1,12 +1,12 @@
import React, { useState, useMemo } from 'react';
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import Collection from './Collection';
import CreateCollection from '../CreateCollection';
import StyledWrapper from './StyledWrapper';
import CreateOrOpenCollection from './CreateOrOpenCollection';
import CollectionSearch from './CollectionSearch/index';
import { useMemo } from 'react';
import { normalizePath } from 'utils/common/path';
import { isScratchCollection } from 'utils/collections';
const Collections = ({ showSearch }) => {
const [searchText, setSearchText] = useState('');
@@ -18,14 +18,10 @@ const Collections = ({ showSearch }) => {
const workspaceCollections = useMemo(() => {
if (!activeWorkspace) return [];
return collections.filter((c) => {
if (isScratchCollection(c, workspaces)) {
return false;
}
return activeWorkspace.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname));
});
}, [activeWorkspace, collections, workspaces]);
return collections.filter((c) =>
activeWorkspace.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname))
);
}, [activeWorkspace, collections]);
if (!workspaceCollections || !workspaceCollections.length) {
return (

View File

@@ -1,276 +0,0 @@
import React, { useState, useRef } from 'react';
import { IconFileImport } from '@tabler/icons';
import { toastError } from 'utils/common/error';
import jsyaml from 'js-yaml';
import { isPostmanCollection } from 'utils/importers/postman-collection';
import { isInsomniaCollection } from 'utils/importers/insomnia-collection';
import { isOpenApiSpec } from 'utils/importers/openapi-collection';
import { isWSDLCollection } from 'utils/importers/wsdl-collection';
import { isBrunoCollection } from 'utils/importers/bruno-collection';
import { isOpenCollection } from 'utils/importers/opencollection';
import { useTheme } from 'providers/Theme';
const convertFileToObject = async (file) => {
const text = await file.text();
// Handle WSDL files - return as plain text
if (file.name.endsWith('.wsdl') || file.type === 'text/xml' || file.type === 'application/xml') {
return text;
}
try {
if (file.type === 'application/json' || file.name.endsWith('.json')) {
return JSON.parse(text);
}
const parsed = jsyaml.load(text);
if (typeof parsed !== 'object' || parsed === null) {
throw new Error();
}
return parsed;
} catch {
throw new Error('Failed to parse the file ensure it is valid JSON or YAML');
}
};
const FileTab = ({
setIsLoading,
handleSubmit,
setErrorMessage
}) => {
const [dragActive, setDragActive] = useState(false);
const fileInputRef = useRef(null);
const { theme } = useTheme();
const acceptedFileTypes = [
'.json',
'.yaml',
'.yml',
'.wsdl',
'.zip',
'application/json',
'application/yaml',
'application/x-yaml',
'application/zip',
'application/x-zip-compressed',
'text/xml',
'application/xml'
];
const handleDrag = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy';
}
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
setDragActive(false);
}
};
const processZipFile = async (zipFile) => {
setIsLoading(true);
try {
const filePath = window.ipcRenderer.getFilePath(zipFile);
const isBrunoZip = await window.ipcRenderer.invoke('renderer:is-bruno-collection-zip', filePath);
if (isBrunoZip) {
const collectionName = zipFile.name.replace(/\.zip$/i, '');
await handleSubmit({ rawData: { zipFilePath: filePath, collectionName }, type: 'bruno-zip' });
return;
}
toastError(new Error('The ZIP file is not a valid Bruno collection'));
} catch (err) {
toastError(err, 'Import ZIP file failed');
} finally {
setIsLoading(false);
}
};
const handleMultipleFiles = async (fileArray) => {
setIsLoading(true);
try {
const filesData = [];
// Parse all files
for (const file of fileArray) {
try {
const data = await convertFileToObject(file);
// Determine type for each file
let type = null;
if (isOpenApiSpec(data)) {
type = 'openapi';
} else if (isWSDLCollection(data)) {
type = 'wsdl';
} else if (isPostmanCollection(data)) {
type = 'postman';
} else if (isInsomniaCollection(data)) {
type = 'insomnia';
} else if (isOpenCollection(data)) {
type = 'opencollection';
} else if (isBrunoCollection(data)) {
type = 'bruno';
}
if (type) {
filesData.push({ file, data, type });
}
} catch (err) {
console.warn(`Failed to process file ${file.name}:`, err);
}
}
if (filesData.length > 0) {
// Pass raw filesData to be processed in BulkImportCollectionLocation
handleSubmit({ filesData, type: 'multiple' });
} else {
throw new Error('No valid collections found in the selected files');
}
} catch (err) {
toastError(err, 'Import multiple files failed');
} finally {
setIsLoading(false);
}
};
const processFile = async (file) => {
setIsLoading(true);
try {
const data = await convertFileToObject(file);
if (!data) {
throw new Error('Failed to parse file content');
}
let type = null;
if (isOpenApiSpec(data)) {
type = 'openapi';
} else if (isWSDLCollection(data)) {
type = 'wsdl';
} else if (isPostmanCollection(data)) {
type = 'postman';
} else if (isInsomniaCollection(data)) {
type = 'insomnia';
} else if (isOpenCollection(data)) {
type = 'opencollection';
} else if (isBrunoCollection(data)) {
type = 'bruno';
} else {
throw new Error('Unsupported collection format');
}
await handleSubmit({ rawData: data, type });
} catch (err) {
toastError(err, 'Import collection failed');
} finally {
setIsLoading(false);
}
};
const processFiles = async (files) => {
setErrorMessage('');
const fileArray = Array.from(files);
const zipFiles = fileArray.filter((file) => file.name.endsWith('.zip'));
// If both ZIP and non-ZIP files are selected, show error
if (zipFiles.length && (fileArray.length - zipFiles.length > 0)) {
setErrorMessage('Cannot mix ZIP files with other file types. Please select either a single ZIP file OR collection files (JSON/YAML)');
return;
}
if (zipFiles.length > 1) {
setErrorMessage('Multiple ZIP files selected. Please select only one ZIP file at a time for import.');
return;
}
if (zipFiles.length) {
await processZipFile(zipFiles[0]);
return;
}
if (fileArray.length > 1) {
// Process multiple non-ZIP files normally
await handleMultipleFiles(fileArray);
} else if (fileArray.length === 1) {
await processFile(fileArray[0]);
}
};
const handleDrop = async (e) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
await processFiles(e.dataTransfer.files);
}
};
const handleBrowseFiles = () => {
setErrorMessage('');
fileInputRef.current.click();
};
const handleFileInputChange = async (e) => {
if (e.target.files && e.target.files.length > 0) {
await processFiles(e.target.files);
e.target.value = '';
}
};
return (
<div className="mb-4">
<div
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
className={`
border-2 border-dashed rounded-lg p-6 transition-colors duration-200
${dragActive
? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700'
}
`}
>
<div className="flex flex-col items-center justify-center">
<IconFileImport
size={28}
className="text-gray-400 dark:text-gray-500 mb-3"
/>
<input
ref={fileInputRef}
type="file"
className="hidden"
multiple
onChange={handleFileInputChange}
accept={acceptedFileTypes.join(',')}
/>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-2">
Drop file(s) to import or{' '}
<button
className="underline cursor-pointer"
onClick={handleBrowseFiles}
style={{ color: theme.textLink }}
>
choose file(s)
</button>
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 text-center">
Supports Bruno, OpenCollection, Postman, Insomnia, OpenAPI v3, WSDL, and ZIP formats
</p>
</div>
</div>
</div>
);
};
export default FileTab;

View File

@@ -1,54 +0,0 @@
import React, { useState } from 'react';
import { isGitRepositoryUrl } from 'utils/git';
import toast from 'react-hot-toast';
import Button from 'ui/Button';
const GitHubTab = ({
handleSubmit,
setErrorMessage
}) => {
const [urlInput, setUrlInput] = useState('');
const handleGitRepositoryImport = (url) => {
if (!isGitRepositoryUrl(url)) {
setErrorMessage('Please enter a valid git repository URL');
return;
}
handleSubmit({ repositoryUrl: url, type: 'git-repository' });
};
const handleFormSubmit = (e) => {
e.preventDefault();
if (urlInput.trim()) {
handleGitRepositoryImport(urlInput.trim());
}
};
return (
<form onSubmit={handleFormSubmit}>
<div className="flex gap-2">
<input
id="gitUrlInput"
data-testid="git-url-input"
type="text"
value={urlInput}
autoFocus
onChange={(e) => setUrlInput(e.target.value)}
placeholder="Enter Git repository URL"
className="flex-1 px-3 py-1 textbox"
/>
<Button
type="submit"
id="clone-git-button"
disabled={!urlInput.trim()}
variant="filled"
color="primary"
style={{ height: '100%' }}
>
Clone
</Button>
</div>
</form>
);
};
export default GitHubTab;

View File

@@ -1,30 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.tabs {
.tab {
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
&:focus,
&:active,
&:focus-within,
&:focus-visible,
&:target {
outline: none !important;
box-shadow: none !important;
}
&.active {
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}
}
}
`;
export default StyledWrapper;

View File

@@ -1,62 +0,0 @@
import React, { useState } from 'react';
import { fetchAndValidateApiSpecFromUrl } from 'utils/importers/common';
import { isValidUrl } from 'utils/url/index';
import Button from 'ui/Button';
const UrlTab = ({
setIsLoading,
handleSubmit,
setErrorMessage
}) => {
const [urlInput, setUrlInput] = useState('');
const handleUrlImport = async (event) => {
event.preventDefault();
if (!urlInput.trim() || !isValidUrl(urlInput.trim())) {
setErrorMessage('Please enter a valid URL');
return;
}
setIsLoading(true);
try {
const { data, specType } = await fetchAndValidateApiSpecFromUrl({ url: urlInput.trim() });
// Pass raw data for all types
handleSubmit({ rawData: data, type: specType });
} catch (err) {
console.error(err);
setErrorMessage('URL import failed. Please check the URL and try again.');
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleUrlImport}>
<div className="flex gap-2">
<input
id="urlInput"
data-testid="url-input"
type="text"
value={urlInput}
autoFocus
onChange={(e) => {
setUrlInput(e.target.value);
setErrorMessage('');
}}
placeholder="Enter URL (OpenAPI/Swagger, Postman, or Insomnia specification)"
className="flex-1 px-3 py-1 textbox"
/>
<Button
type="submit"
id="import-url-button"
disabled={!urlInput.trim()}
variant="filled"
color="primary"
style={{ height: '100%' }}
>
Import
</Button>
</div>
</form>
);
};
export default UrlTab;

View File

@@ -1,120 +1,176 @@
import React, { useState } from 'react';
import { IconFileImport, IconBrandGit, IconUnlink, IconX } from '@tabler/icons';
import React, { useState, useEffect, useRef } from 'react';
import { IconFileImport } from '@tabler/icons';
import { toastError } from 'utils/common/error';
import Modal from 'components/Modal';
import classnames from 'classnames';
import StyledWrapper from './StyledWrapper';
import FileTab from './FileTab';
import GitHubTab from './GitHubTab';
import UrlTab from './UrlTab';
import jsyaml from 'js-yaml';
import { isPostmanCollection } from 'utils/importers/postman-collection';
import { isInsomniaCollection } from 'utils/importers/insomnia-collection';
import { isOpenApiSpec } from 'utils/importers/openapi-collection';
import { isWSDLCollection } from 'utils/importers/wsdl-collection';
import { isBrunoCollection } from 'utils/importers/bruno-collection';
import { isOpenCollection } from 'utils/importers/opencollection';
import FullscreenLoader from './FullscreenLoader/index';
import { useTheme } from 'providers/Theme';
const IMPORT_TABS = {
FILE: 'file',
GITHUB: 'github',
URL: 'url'
const convertFileToObject = async (file) => {
const text = await file.text();
// Handle WSDL files - return as plain text
if (file.name.endsWith('.wsdl') || file.type === 'text/xml' || file.type === 'application/xml') {
return text;
}
try {
if (file.type === 'application/json' || file.name.endsWith('.json')) {
return JSON.parse(text);
}
const parsed = jsyaml.load(text);
if (typeof parsed !== 'object' || parsed === null) {
throw new Error();
}
return parsed;
} catch {
throw new Error('Failed to parse the file ensure it is valid JSON or YAML');
}
};
const ImportCollection = ({ onClose, handleSubmit }) => {
const { theme } = useTheme();
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [tab, setTab] = useState(IMPORT_TABS.FILE);
const [dragActive, setDragActive] = useState(false);
const fileInputRef = useRef(null);
const handleTabSelect = (value) => () => {
setTab(value);
setErrorMessage('');
const handleDrag = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy';
}
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
setDragActive(false);
}
};
const getTabClassname = (tabName) => {
return classnames(`flex tab items-center py-2 px-4 ${tabName}`, {
active: tabName === tab
});
const processFile = async (file) => {
setIsLoading(true);
try {
const data = await convertFileToObject(file);
if (!data) {
throw new Error('Failed to parse file content');
}
let type = null;
if (isOpenApiSpec(data)) {
type = 'openapi';
} else if (isWSDLCollection(data)) {
type = 'wsdl';
} else if (isPostmanCollection(data)) {
type = 'postman';
} else if (isInsomniaCollection(data)) {
type = 'insomnia';
} else if (isOpenCollection(data)) {
type = 'opencollection';
} else if (isBrunoCollection(data)) {
type = 'bruno';
} else {
throw new Error('Unsupported collection format');
}
handleSubmit({ rawData: data, type });
} catch (err) {
toastError(err, 'Import collection failed');
} finally {
setIsLoading(false);
}
};
const handleDrop = async (e) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
await processFile(e.dataTransfer.files[0]);
}
};
const handleBrowseFiles = () => {
fileInputRef.current.click();
};
const handleFileInputChange = async (e) => {
if (e.target.files && e.target.files[0]) {
await processFile(e.target.files[0]);
}
};
if (isLoading) {
return <FullscreenLoader isLoading={isLoading} />;
}
const acceptedFileTypes = [
'.json',
'.yaml',
'.yml',
'.wsdl',
'application/json',
'application/yaml',
'application/x-yaml',
'text/xml',
'application/xml'
];
return (
<Modal size="md" title="Import Collection" hideFooter={true} handleCancel={onClose} dataTestId="import-collection-modal">
<StyledWrapper className="flex flex-col h-full w-[600px] max-w-[600px]">
<div className="flex w-full mb-6">
<div className="flex justify-start w-full tabs">
<div
className={getTabClassname(IMPORT_TABS.FILE)}
onClick={handleTabSelect(IMPORT_TABS.FILE)}
data-testid="file-tab"
>
<IconFileImport size={18} strokeWidth={1.5} className="mr-2" />
File
</div>
<div
className={getTabClassname(IMPORT_TABS.GITHUB)}
onClick={handleTabSelect(IMPORT_TABS.GITHUB)}
data-testid="github-tab"
>
<IconBrandGit size={18} strokeWidth={1.5} className="mr-2" />
Git Repository
</div>
<div
className={getTabClassname(IMPORT_TABS.URL)}
onClick={handleTabSelect(IMPORT_TABS.URL)}
data-testid="url-tab"
>
<IconUnlink size={18} strokeWidth={1.5} className="mr-2" />
URL
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose} dataTestId="import-collection-modal">
<div className="flex flex-col">
<div className="mb-4">
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-2">Import from file</h3>
<div
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
className={`
border-2 border-dashed rounded-lg p-6 transition-colors duration-200
${dragActive ? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/20' : 'border-gray-200 dark:border-gray-700'}
`}
>
<div className="flex flex-col items-center justify-center">
<IconFileImport
size={28}
className="text-gray-400 dark:text-gray-500 mb-3"
/>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleFileInputChange}
accept={acceptedFileTypes.join(',')}
/>
<p className="text-gray-600 dark:text-gray-300 mb-2">
Drop file to import or{' '}
<button
className="underline cursor-pointer"
onClick={handleBrowseFiles}
style={{ color: theme.textLink }}
>
choose a file
</button>
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Supports Bruno, OpenCollection, Postman, Insomnia, OpenAPI v3, and WSDL formats
</p>
</div>
</div>
</div>
{errorMessage && (
<div
className="mb-4 p-2 border rounded-md"
style={{
backgroundColor: theme.status.danger.background,
borderColor: theme.status.danger.border
}}
>
<div className="flex gap-2">
<div
className="text-xs flex-1"
style={{ color: theme.status.danger.text }}
>
{errorMessage}
</div>
<div
className="close-button flex items-center cursor-pointer"
onClick={() => setErrorMessage('')}
style={{ color: theme.status.danger.text }}
>
<IconX size={16} strokeWidth={1.5} />
</div>
</div>
</div>
)}
{tab === IMPORT_TABS.FILE && (
<FileTab
setIsLoading={setIsLoading}
handleSubmit={handleSubmit}
setErrorMessage={setErrorMessage}
/>
)}
{tab === IMPORT_TABS.GITHUB && (
<GitHubTab
handleSubmit={handleSubmit}
setErrorMessage={setErrorMessage}
/>
)}
{tab === IMPORT_TABS.URL && (
<UrlTab
setIsLoading={setIsLoading}
handleSubmit={handleSubmit}
setErrorMessage={setErrorMessage}
/>
)}
</StyledWrapper>
</div>
</Modal>
);
};

View File

@@ -43,21 +43,19 @@ const getCollectionName = (format, rawData) => {
return rawData.info?.name || 'OpenCollection';
case 'wsdl':
return 'WSDL Collection';
case 'bruno-zip':
return rawData.collectionName || 'Bruno Collection';
default:
return 'Collection';
}
};
// Convert raw data to Bruno collection format
const convertCollection = async (format, rawData, groupingType, collectionFormat) => {
const convertCollection = async (format, rawData, groupingType) => {
try {
let collection;
switch (format) {
case 'openapi':
collection = convertOpenapiToBruno(rawData, { groupBy: groupingType, collectionFormat });
collection = convertOpenapiToBruno(rawData, { groupBy: groupingType });
break;
case 'wsdl':
collection = await wsdlToBruno(rawData);
@@ -74,10 +72,6 @@ const convertCollection = async (format, rawData, groupingType, collectionFormat
case 'opencollection':
collection = await processOpenCollection(rawData);
break;
case 'bruno-zip':
// ZIP doesn't need conversion
collection = rawData;
break;
default:
throw new Error('Unknown collection format');
}
@@ -102,7 +96,6 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
const [collectionFormat, setCollectionFormat] = useState(DEFAULT_COLLECTION_FORMAT);
const dropdownTippyRef = useRef();
const isOpenApi = format === 'openapi';
const isZipImport = format === 'bruno-zip';
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const preferences = useSelector((state) => state.app.preferences);
@@ -127,7 +120,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
.required('Location is required')
}),
onSubmit: async (values) => {
const convertedCollection = await convertCollection(format, rawData, groupingType, collectionFormat);
const convertedCollection = await convertCollection(format, rawData, groupingType);
handleSubmit(convertedCollection, values.collectionLocation, { format: collectionFormat });
}
});
@@ -166,19 +159,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
}
}, [inputRef]);
const onSubmit = async () => {
if (isZipImport) {
const errors = await formik.validateForm();
if (Object.keys(errors).length > 0) {
formik.setTouched({ collectionLocation: true });
return;
}
const collectionLocation = formik.values.collectionLocation;
handleSubmit(rawData, collectionLocation, { format: collectionFormat, isZipImport: true });
} else {
formik.handleSubmit();
}
};
const onSubmit = () => formik.handleSubmit();
return (
<StyledWrapper>
@@ -231,32 +212,30 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
</span>
</div>
{!isZipImport && (
<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={collectionFormat}
onChange={(e) => setCollectionFormat(e.target.value)}
>
<option value="yml">OpenCollection (YAML)</option>
<option value="bru">BRU Format (.bru)</option>
</select>
</div>
)}
<div className="mt-4">
<label htmlFor="format" className="flex items-center font-medium">
File Format
<Help width="300">
<p>Choose the file format for storing requests in this collection.</p>
<p className="mt-2">
<strong>OpenCollection (YAML):</strong> Industry-standard YAML format (.yml files)
</p>
<p className="mt-1">
<strong>BRU:</strong> Bruno's native file format (.bru files)
</p>
</Help>
</label>
<select
id="format"
name="format"
className="block textbox mt-2 w-full"
value={collectionFormat}
onChange={(e) => setCollectionFormat(e.target.value)}
>
<option value="yml">OpenCollection (YAML)</option>
<option value="bru">BRU Format (.bru)</option>
</select>
</div>
</div>
{isOpenApi && (

View File

@@ -15,17 +15,14 @@ import {
IconTerminal2
} from '@tabler/icons';
import { importCollection, openCollection, importCollectionFromZip } from 'providers/ReduxStore/slices/collections/actions';
import { importCollection, openCollection } from 'providers/ReduxStore/slices/collections/actions';
import { sortCollections } from 'providers/ReduxStore/slices/collections/index';
import { normalizePath } from 'utils/common/path';
import { isScratchCollection } from 'utils/collections';
import MenuDropdown from 'ui/MenuDropdown';
import ActionIcon from 'ui/ActionIcon';
import ImportCollection from 'components/Sidebar/ImportCollection';
import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation';
import BulkImportCollectionLocation from 'components/Sidebar/BulkImportCollectionLocation';
import CloneGitRepository from 'components/Sidebar/CloneGitRespository';
import RemoveCollectionsModal from 'components/Sidebar/Collections/RemoveCollectionsModal/index';
import CreateCollection from 'components/Sidebar/CreateCollection';
import Collections from 'components/Sidebar/Collections';
@@ -47,50 +44,33 @@ const CollectionsSection = () => {
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
const [showCloneGitModal, setShowCloneGitModal] = useState(false);
const [gitRepositoryUrl, setGitRepositoryUrl] = useState(null);
const workspaceCollections = useMemo(() => {
if (!activeWorkspace) return [];
return collections.filter((c) =>
activeWorkspace.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname))
);
}, [activeWorkspace, collections]);
return collections.filter((c) => {
if (isScratchCollection(c, workspaces)) {
return false;
}
return activeWorkspace.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname));
});
}, [activeWorkspace, collections, workspaces]);
const handleImportCollection = ({ rawData, type, repositoryUrl, ...rest }) => {
const handleImportCollection = ({ rawData, type }) => {
setImportCollectionModalOpen(false);
if (type === 'git-repository') {
setGitRepositoryUrl(repositoryUrl);
setShowCloneGitModal(true);
return;
}
setImportData({ rawData, type, ...rest });
setImportData({ rawData, type });
setImportCollectionLocationModalOpen(true);
};
const handleImportCollectionLocation = (convertedCollection, collectionLocation, options = {}) => {
const importAction = options.isZipImport
? importCollectionFromZip(convertedCollection.zipFilePath, collectionLocation)
: importCollection(convertedCollection, collectionLocation, options);
dispatch(importAction)
dispatch(importCollection(convertedCollection, collectionLocation, options))
.then(() => {
setImportCollectionLocationModalOpen(false);
setImportData(null);
toast.success('Collection imported successfully');
})
.catch((err) => {
console.error(err);
toast.error('An error occurred while importing the collection');
});
};
const handleCloseGitModal = () => {
setShowCloneGitModal(false);
setGitRepositoryUrl(null);
};
const handleToggleSearch = () => {
setShowSearch((prev) => !prev);
};
@@ -261,7 +241,7 @@ const CollectionsSection = () => {
handleSubmit={handleImportCollection}
/>
)}
{importCollectionLocationModalOpen && importData && (importData.type !== 'multiple' && importData.type !== 'bulk') && (
{importCollectionLocationModalOpen && importData && (
<ImportCollectionLocation
rawData={importData.rawData}
format={importData.type}
@@ -269,20 +249,6 @@ const CollectionsSection = () => {
handleSubmit={handleImportCollectionLocation}
/>
)}
{importCollectionLocationModalOpen && importData && (importData.type === 'multiple' || importData.type === 'bulk') && (
<BulkImportCollectionLocation
importData={importData}
onClose={() => setImportCollectionLocationModalOpen(false)}
handleSubmit={handleImportCollectionLocation}
/>
)}
{showCloneGitModal && (
<CloneGitRepository
onClose={handleCloseGitModal}
onFinish={handleCloseGitModal}
collectionRepositoryUrl={gitRepositoryUrl}
/>
)}
<SidebarSection
id="collections"
title="Collections"

View File

@@ -169,21 +169,14 @@ class SingleLineEditor extends Component {
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
const nextValue = String(this.props.value ?? '');
const currentValue = this.editor.getValue();
if (this.editor.hasFocus?.() && currentValue !== nextValue && nextValue !== '') {
this.cachedValue = currentValue;
} else {
const cursor = this.editor.getCursor();
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
this.editor.setCursor(cursor);
const cursor = this.editor.getCursor();
this.cachedValue = String(this.props.value);
this.editor.setValue(String(this.props.value ?? ''));
this.editor.setCursor(cursor);
// Update newline markers after value change
if (this.props.showNewlineArrow) {
this._updateNewlineMarkers();
}
// Update newline markers after value change
if (this.props.showNewlineArrow) {
this._updateNewlineMarkers();
}
}
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {

View File

@@ -10,6 +10,7 @@ import Notifications from 'components/Notifications';
import Portal from 'components/Portal';
import ThemeDropdown from './ThemeDropdown';
import { openConsole } from 'providers/ReduxStore/slices/logs';
import { setActiveWorkspaceTab } from 'providers/ReduxStore/slices/workspaceTabs';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { useApp } from 'providers/App';
import StyledWrapper from './StyledWrapper';
@@ -17,7 +18,6 @@ import StyledWrapper from './StyledWrapper';
const StatusBar = () => {
const dispatch = useDispatch();
const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid);
const workspaces = useSelector((state) => state.workspaces.workspaces);
const showHomePage = useSelector((state) => state.app.showHomePage);
const showManageWorkspacePage = useSelector((state) => state.app.showManageWorkspacePage);
const showApiSpecPage = useSelector((state) => state.app.showApiSpecPage);
@@ -28,8 +28,6 @@ const StatusBar = () => {
const [cookiesOpen, setCookiesOpen] = useState(false);
const { version } = useApp();
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const errorCount = logs.filter((log) => log.type === 'error').length;
const handleConsoleClick = () => {
@@ -37,15 +35,19 @@ const StatusBar = () => {
};
const handlePreferencesClick = () => {
const collectionUid = activeTab?.collectionUid || activeWorkspace?.scratchCollectionUid;
dispatch(
addTab({
type: 'preferences',
uid: collectionUid ? `${collectionUid}-preferences` : 'preferences',
collectionUid: collectionUid
})
);
if (showHomePage || showManageWorkspacePage || showApiSpecPage || !activeTabUid) {
if (activeWorkspaceUid) {
dispatch(setActiveWorkspaceTab({ workspaceUid: activeWorkspaceUid, type: 'preferences' }));
}
} else {
dispatch(
addTab({
type: 'preferences',
uid: activeTab?.collectionUid ? `${activeTab.collectionUid}-preferences` : 'preferences',
collectionUid: activeTab?.collectionUid
})
);
}
};
const openGlobalSearch = () => {

View File

@@ -4,10 +4,9 @@ import StyledWrapper from './StyledWrapper';
import SingleLineEditor from 'components/SingleLineEditor/index';
import { useTheme } from 'providers/Theme/index';
const TagList = ({ tagsHintList = [], handleAddTag, tags, handleRemoveTag, onSave, handleValidation, collectionFormat }) => {
const TagList = ({ tagsHintList = [], handleAddTag, tags, handleRemoveTag, onSave, handleValidation }) => {
const { displayedTheme } = useTheme();
const isBruFormat = collectionFormat === 'bru';
const tagNameRegex = isBruFormat ? /^[\w-]+$/ : /^[\w-][\w\s-]*[\w-]$|^[\w-]+$/;
const tagNameRegex = /^[\w-]+$/;
const [text, setText] = useState('');
const [error, setError] = useState('');
@@ -17,14 +16,8 @@ const TagList = ({ tagsHintList = [], handleAddTag, tags, handleRemoveTag, onSav
};
const handleKeyDown = (e) => {
if (!text.trim()) {
return;
}
if (!tagNameRegex.test(text)) {
setError(isBruFormat
? 'Tags in BRU format must only contain alpha-numeric characters, "-", "_".'
: 'Tags must only contain alpha-numeric characters, spaces, "-", "_"'
);
setError('Tags must only contain alpha-numeric characters, "-", "_"');
return;
}
if (tags.includes(text)) {
@@ -35,6 +28,7 @@ const TagList = ({ tagsHintList = [], handleAddTag, tags, handleRemoveTag, onSav
const error = handleValidation(text);
if (error) {
setError(error);
setText('');
return;
}
}

View File

@@ -99,8 +99,6 @@ const VariablesEditor = ({ collection }) => {
<div className="mt-8 muted text-xs">
Note: As of today, runtime variables can only be set via the API - <span className="font-medium">getVar()</span>{' '}
and <span className="font-medium">setVar()</span>. <br />
You can use the <span className="font-medium">var</span> variable with the
<span className="font-medium">{'{{var}}'}</span> syntax.<br />
</div>
</StyledWrapper>
);

View File

@@ -0,0 +1,110 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.workspace-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
position: relative;
}
.workspace-title {
display: flex;
align-items: center;
gap: 8px;
height: 24px;
font-size: 15px;
font-weight: 600;
color: ${(props) => props.theme.text};
}
.workspace-rename-container {
height: 24px;
display: flex;
align-items: center;
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
gap: 6px;
border-radius: 4px;
}
.workspace-name-input {
padding: 0 8px;
font-size: 14px;
font-weight: 600;
border-radius: 4px;
background: transparent;
color: ${(props) => props.theme.text};
outline: none;
min-width: 180px;
&:focus {
outline: none;
}
}
.inline-actions {
display: flex;
gap: 2px;
}
.inline-action-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
&.save {
color: ${(props) => props.theme.colors.text.green};
&:hover {
background-color: ${(props) => rgba(props.theme.colors.text.green, 0.1)};
}
}
&.cancel {
color: ${(props) => props.theme.colors.text.danger};
&:hover {
background-color: ${(props) => rgba(props.theme.colors.text.danger, 0.1)};
}
}
}
.workspace-error {
position: absolute;
top: 80%;
left: 40px;
z-index: 10;
margin-top: 4px;
padding: 4px 8px;
font-size: 11px;
color: ${(props) => props.theme.colors.text.danger};
background: ${(props) => props.theme.bg};
border: 1px solid ${(props) => props.theme.colors.text.danger};
border-radius: 4px;
white-space: nowrap;
}
.workspace-menu-dropdown {
min-width: 140px;
}
.tab-content {
flex: 1;
overflow: hidden;
}
`;
export default StyledWrapper;

View File

@@ -134,7 +134,13 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
};
const handleColorChange = (color) => {
dispatch(updateGlobalEnvironmentColor(environment.uid, color));
dispatch(updateGlobalEnvironmentColor(environment.uid, color))
.then(() => {
toast.success('Environment color updated!');
})
.catch(() => {
toast.error('An error occurred while updating the environment color');
});
};
return (

View File

@@ -13,7 +13,7 @@ import DotEnvFileDetails from 'components/Environments/DotEnvFileDetails';
import ColorBadge from 'components/ColorBadge';
import { isEqual } from 'lodash';
import { useDispatch, useSelector } from 'react-redux';
import { addGlobalEnvironment, renameGlobalEnvironment, selectGlobalEnvironment, setGlobalEnvironmentDraft, clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-environments';
import { addGlobalEnvironment, renameGlobalEnvironment, selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import {
saveWorkspaceDotEnvVariables,
saveWorkspaceDotEnvRaw,
@@ -72,22 +72,9 @@ const EnvironmentList = ({
const envUids = environments ? environments.map((env) => env.uid) : [];
const prevEnvUids = usePrevious(envUids);
const handleDotEnvModifiedChange = useCallback((modified) => {
setIsDotEnvModified(modified);
if (modified) {
dispatch(setGlobalEnvironmentDraft({
environmentUid: `dotenv:${selectedDotEnvFile}`,
variables: []
}));
} else {
dispatch(clearGlobalEnvironmentDraft());
}
}, [dispatch, selectedDotEnvFile]);
useEffect(() => {
if (dotEnvFiles.length === 0) {
setSelectedDotEnvFile(null);
handleDotEnvModifiedChange(false);
return;
}
@@ -435,7 +422,7 @@ const EnvironmentList = ({
dispatch(deleteWorkspaceDotEnvFile(workspace.uid, filename))
.then(() => {
toast.success(`${filename} file deleted!`);
handleDotEnvModifiedChange(false);
setIsDotEnvModified(false);
if (selectedDotEnvFile === filename) {
const remainingFiles = dotEnvFiles.filter((f) => f.filename !== filename);
if (remainingFiles.length > 0) {
@@ -478,7 +465,7 @@ const EnvironmentList = ({
onSave={handleSaveDotEnv}
onSaveRaw={handleSaveDotEnvRaw}
isModified={isDotEnvModified}
setIsModified={handleDotEnvModifiedChange}
setIsModified={setIsDotEnvModified}
dotEnvExists={selectedDotEnvData?.exists}
viewMode={dotEnvViewMode}
/>

View File

@@ -1,9 +1,8 @@
import React, { useState, useMemo, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { IconBox, IconTrash, IconEdit, IconShare, IconDots, IconX, IconFolder } from '@tabler/icons';
import { IconBox, IconTrash, IconEdit, IconShare, IconDots, IconX } from '@tabler/icons';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { mountCollection, showInFolder } from 'providers/ReduxStore/slices/collections/actions';
import { getRevealInFolderLabel } from 'utils/common/platform';
import { mountCollection } from 'providers/ReduxStore/slices/collections/actions';
import { normalizePath } from 'utils/common/path';
import toast from 'react-hot-toast';
import RenameCollection from 'components/Sidebar/Collections/Collection/RenameCollection';
@@ -29,14 +28,7 @@ const CollectionsList = ({ workspace }) => {
return [];
}
const filteredCollections = workspace.collections.filter((wc) => {
if (workspace.scratchTempDirectory) {
return normalizePath(wc.path) !== normalizePath(workspace.scratchTempDirectory);
}
return true;
});
return filteredCollections.map((wc) => {
return workspace.collections.map((wc) => {
const loadedCollection = collections.find(
(c) => normalizePath(c.pathname) === normalizePath(wc.path)
);
@@ -72,7 +64,7 @@ const CollectionsList = ({ workspace }) => {
}
};
});
}, [workspace.collections, workspace.scratchTempDirectory, collections]);
}, [workspace.collections, collections]);
const handleOpenCollectionClick = (collection, event) => {
if (event.target.closest('.collection-menu')) {
@@ -154,14 +146,6 @@ const CollectionsList = ({ workspace }) => {
setDeleteCollectionModalOpen(true);
};
const handleShowInFolder = (collection) => {
dropdownRefs.current[collection.uid]?.hide();
dispatch(showInFolder(collection.pathname)).catch((error) => {
console.error('Error opening the folder', error);
toast.error('Error opening the folder');
});
};
return (
<StyledWrapper>
{renameCollectionModalOpen && selectedCollectionUid && (
@@ -210,7 +194,9 @@ const CollectionsList = ({ workspace }) => {
<div className="empty-state">
<IconBox size={32} strokeWidth={1.5} className="empty-icon" />
<h3 className="empty-title">No collections yet</h3>
<p className="empty-description">Create your first collection or open an existing one to get started.</p>
<p className="empty-description">
Create your first collection or open an existing one to get started.
</p>
</div>
) : (
workspaceCollections.map((collection, index) => (
@@ -256,16 +242,6 @@ const CollectionsList = ({ workspace }) => {
<IconShare size={16} strokeWidth={1.5} />
<span>Share</span>
</div>
<div
className="dropdown-item"
onClick={(e) => {
e.stopPropagation();
handleShowInFolder(collection);
}}
>
<IconFolder size={16} strokeWidth={1.5} />
<span>{getRevealInFolderLabel()}</span>
</div>
<div
className="dropdown-item"
onClick={(e) => {

View File

@@ -1,13 +1,11 @@
import React, { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { IconPlus, IconFolder, IconDownload } from '@tabler/icons';
import { importCollection, openCollection, importCollectionFromZip } from 'providers/ReduxStore/slices/collections/actions';
import { importCollection, openCollection } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import CreateCollection from 'components/Sidebar/CreateCollection';
import ImportCollection from 'components/Sidebar/ImportCollection';
import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation';
import BulkImportCollectionLocation from 'components/Sidebar/BulkImportCollectionLocation';
import CloneGitRepository from 'components/Sidebar/CloneGitRespository';
import Button from 'ui/Button';
import CollectionsList from './CollectionsList';
import WorkspaceDocs from '../WorkspaceDocs';
@@ -21,8 +19,6 @@ const WorkspaceOverview = ({ workspace }) => {
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
const [importData, setImportData] = useState(null);
const [showCloneGitModal, setShowCloneGitModal] = useState(false);
const [gitRepositoryUrl, setGitRepositoryUrl] = useState(null);
const workspaceCollectionsCount = workspace?.collections?.length || 0;
@@ -55,36 +51,25 @@ const WorkspaceOverview = ({ workspace }) => {
setImportCollectionModalOpen(true);
};
const handleImportCollectionSubmit = ({ rawData, type, repositoryUrl, ...rest }) => {
const handleImportCollectionSubmit = ({ rawData, type }) => {
setImportCollectionModalOpen(false);
if (type === 'git-repository') {
setGitRepositoryUrl(repositoryUrl);
setShowCloneGitModal(true);
return;
}
setImportData({ rawData, type, ...rest });
setImportData({ rawData, type });
setImportCollectionLocationModalOpen(true);
};
const handleImportCollectionLocation = (convertedCollection, collectionLocation, options = {}) => {
const importAction = options.isZipImport
? importCollectionFromZip(convertedCollection.zipFilePath, collectionLocation)
: importCollection(convertedCollection, collectionLocation, options);
dispatch(importAction)
dispatch(importCollection(convertedCollection, collectionLocation, options))
.then(() => {
setImportCollectionLocationModalOpen(false);
setImportData(null);
toast.success('Collection imported successfully');
})
.catch((err) => {
console.error(err);
toast.error(err.message);
});
};
const handleCloseGitModal = () => {
setShowCloneGitModal(false);
setGitRepositoryUrl(null);
};
return (
<StyledWrapper>
{createCollectionModalOpen && (
@@ -98,7 +83,7 @@ const WorkspaceOverview = ({ workspace }) => {
/>
)}
{importCollectionLocationModalOpen && importData && (importData.type !== 'multiple' && importData.type !== 'bulk') && (
{importCollectionLocationModalOpen && importData && (
<ImportCollectionLocation
rawData={importData.rawData}
format={importData.type}
@@ -106,20 +91,6 @@ const WorkspaceOverview = ({ workspace }) => {
handleSubmit={handleImportCollectionLocation}
/>
)}
{importCollectionLocationModalOpen && importData && (importData.type === 'multiple' || importData.type === 'bulk') && (
<BulkImportCollectionLocation
importData={importData}
onClose={() => setImportCollectionLocationModalOpen(false)}
handleSubmit={handleImportCollectionLocation}
/>
)}
{showCloneGitModal && (
<CloneGitRepository
onClose={handleCloseGitModal}
onFinish={handleCloseGitModal}
collectionRepositoryUrl={gitRepositoryUrl}
/>
)}
<div className="overview-layout">
<div className="overview-main">

View File

@@ -0,0 +1,262 @@
import React, { useEffect, useState, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { IconCategory, IconDots, IconEdit, IconX, IconCheck, IconFolder, IconUpload } from '@tabler/icons';
import { renameWorkspaceAction, exportWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import CloseWorkspace from 'components/Sidebar/CloseWorkspace';
import WorkspaceOverview from './WorkspaceOverview';
import WorkspaceEnvironments from './WorkspaceEnvironments';
import Preferences from 'components/Preferences';
import WorkspaceTabs from 'components/WorkspaceTabs';
import StyledWrapper from './StyledWrapper';
import Dropdown from 'components/Dropdown';
import { getRevealInFolderLabel } from 'utils/common/platform';
import { getWorkspaceDisplayName } from 'components/AppTitleBar';
import classNames from 'classnames';
const WorkspaceHome = () => {
const dispatch = useDispatch();
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const workspaceTabs = useSelector((state) => state.workspaceTabs);
const activeTabUid = workspaceTabs.activeTabUid;
const activeTab = workspaceTabs.tabs.find((t) => t.uid === activeTabUid);
const [isRenamingWorkspace, setIsRenamingWorkspace] = useState(false);
const [workspaceNameInput, setWorkspaceNameInput] = useState('');
const [workspaceNameError, setWorkspaceNameError] = useState('');
const [closeWorkspaceModalOpen, setCloseWorkspaceModalOpen] = useState(false);
const workspaceNameInputRef = useRef(null);
const workspaceRenameContainerRef = useRef(null);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
useEffect(() => {
if (!isRenamingWorkspace) return;
const handleClickOutside = (event) => {
if (workspaceRenameContainerRef.current && !workspaceRenameContainerRef.current.contains(event.target)) {
handleCancelWorkspaceRename();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isRenamingWorkspace]);
if (!activeWorkspace) {
return null;
}
const handleRenameWorkspaceClick = () => {
dropdownTippyRef.current?.hide();
setIsRenamingWorkspace(true);
setWorkspaceNameInput(activeWorkspace.name);
setWorkspaceNameError('');
setTimeout(() => {
workspaceNameInputRef.current?.focus();
workspaceNameInputRef.current?.select();
}, 50);
};
const handleCloseWorkspaceClick = () => {
dropdownTippyRef.current?.hide();
if (activeWorkspace.type === 'default') {
toast.error('Cannot close the default workspace');
return;
}
setCloseWorkspaceModalOpen(true);
};
const handleShowInFolder = () => {
dropdownTippyRef.current?.hide();
if (activeWorkspace.pathname) {
dispatch(showInFolder(activeWorkspace.pathname)).catch((error) => {
toast.error('Error opening the folder');
});
}
};
const handleExportWorkspace = () => {
dropdownTippyRef.current?.hide();
dispatch(exportWorkspaceAction(activeWorkspace.uid))
.then((result) => {
if (!result.canceled) {
toast.success('Workspace exported successfully');
}
})
.catch((error) => {
toast.error(error?.message || 'Error exporting workspace');
});
};
const validateWorkspaceName = (name) => {
if (!name || name.trim() === '') {
return 'Name is required';
}
if (name.length < 1) {
return 'Must be at least 1 character';
}
if (name.length > 255) {
return 'Must be 255 characters or less';
}
return null;
};
const handleSaveWorkspaceRename = () => {
const error = validateWorkspaceName(workspaceNameInput);
if (error) {
setWorkspaceNameError(error);
return;
}
dispatch(renameWorkspaceAction(activeWorkspace.uid, workspaceNameInput))
.then(() => {
toast.success('Workspace renamed!');
setIsRenamingWorkspace(false);
setWorkspaceNameInput('');
setWorkspaceNameError('');
})
.catch((err) => {
toast.error(err?.message || 'An error occurred while renaming the workspace');
setWorkspaceNameError(err?.message || 'Failed to rename workspace');
});
};
const handleCancelWorkspaceRename = () => {
setIsRenamingWorkspace(false);
setWorkspaceNameInput('');
setWorkspaceNameError('');
};
const handleWorkspaceNameChange = (e) => {
setWorkspaceNameInput(e.target.value);
if (workspaceNameError) {
setWorkspaceNameError('');
}
};
const handleWorkspaceNameKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveWorkspaceRename();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancelWorkspaceRename();
}
};
const renderTabContent = () => {
if (!activeTab) return null;
switch (activeTab.type) {
case 'overview':
return <WorkspaceOverview workspace={activeWorkspace} />;
case 'environments':
return <WorkspaceEnvironments workspace={activeWorkspace} />;
case 'preferences':
return <Preferences />;
default:
return null;
}
};
return (
<StyledWrapper className="h-full">
<div className="h-full flex flex-row">
{closeWorkspaceModalOpen && (
<CloseWorkspace
workspaceUid={activeWorkspace.uid}
onClose={() => setCloseWorkspaceModalOpen(false)}
/>
)}
<div className="main-content">
<div className="workspace-header">
<div className="workspace-title">
<IconCategory size={20} strokeWidth={1.5} />
{isRenamingWorkspace ? (
<div className="workspace-rename-container" ref={workspaceRenameContainerRef}>
<input
ref={workspaceNameInputRef}
type="text"
className="workspace-name-input"
value={workspaceNameInput}
onChange={handleWorkspaceNameChange}
onKeyDown={handleWorkspaceNameKeyDown}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveWorkspaceRename}
onMouseDown={(e) => e.preventDefault()}
title="Save"
>
<IconCheck size={14} strokeWidth={2} />
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelWorkspaceRename}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
<IconX size={14} strokeWidth={2} />
</button>
</div>
</div>
) : (
<span className={classNames('workspace-name', { 'italic text-muted': !activeWorkspace?.name })}>{getWorkspaceDisplayName(activeWorkspace.name)}</span>
)}
</div>
{!isRenamingWorkspace && activeWorkspace.type !== 'default' && (
<Dropdown
style="new"
placement="bottom-end"
onCreate={onDropdownCreate}
icon={<IconDots size={18} strokeWidth={1.5} className="cursor-pointer" />}
>
<div className="workspace-menu-dropdown">
<div className="dropdown-item" onClick={handleRenameWorkspaceClick}>
<IconEdit size={16} strokeWidth={1.5} />
<span>Rename</span>
</div>
<div className="dropdown-item" onClick={handleShowInFolder}>
<IconFolder size={16} strokeWidth={1.5} />
<span>{getRevealInFolderLabel()}</span>
</div>
<div className="dropdown-item" onClick={handleExportWorkspace}>
<IconUpload size={16} strokeWidth={1.5} />
<span>Export</span>
</div>
<div className="dropdown-item" onClick={handleCloseWorkspaceClick}>
<IconX size={16} strokeWidth={1.5} />
<span>Close</span>
</div>
</div>
</Dropdown>
)}
{workspaceNameError && isRenamingWorkspace && (
<div className="workspace-error">{workspaceNameError}</div>
)}
</div>
<WorkspaceTabs workspaceUid={activeWorkspace.uid} />
<div className="tab-content">{renderTabContent()}</div>
</div>
</div>
</StyledWrapper>
);
};
export default WorkspaceHome;

View File

@@ -0,0 +1,197 @@
import styled from 'styled-components';
const Wrapper = styled.div`
position: relative;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: ${(props) => props.theme.requestTabs.bottomBorder};
z-index: 0;
}
.tabs-scroll-container {
overflow-x: auto;
overflow-y: clip;
padding-bottom: 10px;
margin-bottom: -10px;
&::-webkit-scrollbar {
display: none;
}
scrollbar-width: none;
ul {
margin-bottom: 0;
overflow: visible;
}
}
ul {
padding: 0 3px;
margin: 0;
display: flex;
align-items: flex-end;
position: relative;
&::-webkit-scrollbar {
display: none;
}
scrollbar-width: none;
li {
display: inline-flex;
max-width: 180px;
min-width: 80px;
list-style: none;
cursor: pointer;
font-size: 0.8125rem;
position: relative;
margin-right: 3px;
color: ${(props) => props.theme.requestTabs.color};
background: transparent;
border: 1px solid transparent;
padding: 6px 0;
flex-shrink: 0;
margin-bottom: 3px;
.tab-container {
width: 100%;
position: relative;
overflow: hidden;
}
&:not(.active) {
background: ${(props) => props.theme.requestTabs.bg};
border-color: transparent;
border-radius: ${(props) => props.theme.border.radius.base};
}
&:nth-last-child(1) {
margin-right: 4px;
}
&.has-overflow:not(:hover) .tab-name {
mask-image: linear-gradient(
to right,
${(props) => props.theme.requestTabs.color} 0%,
${(props) => props.theme.requestTabs.color} calc(100% - 12px),
transparent 100%
);
-webkit-mask-image: linear-gradient(
to right,
${(props) => props.theme.requestTabs.color} 0%,
${(props) => props.theme.requestTabs.color} calc(100% - 12px),
transparent 100%
);
}
&.has-overflow:hover .tab-name {
mask-image: linear-gradient(
to right,
${(props) => props.theme.requestTabs.color} 0%,
${(props) => props.theme.requestTabs.color} calc(100% - 8px),
transparent 100%
);
-webkit-mask-image: linear-gradient(
to right,
${(props) => props.theme.requestTabs.color} 0%,
${(props) => props.theme.requestTabs.color} calc(100% - 8px),
transparent 100%
);
}
&.active {
background: ${(props) => props.theme.bg || '#ffffff'};
border: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
border-bottom-color: ${(props) => props.theme.bg || '#ffffff'};
border-radius: 8px 8px 0 0;
z-index: 2;
margin-bottom: -2px;
padding-bottom: 12px;
&::before {
content: '';
position: absolute;
bottom: 1px;
left: -8px;
width: 8px;
height: 8px;
background: transparent;
border-bottom-right-radius: 6px;
box-shadow: 3px 3px 0 0 ${(props) => props.theme.bg || '#ffffff'};
border-right: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
border-bottom: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
}
&::after {
content: '';
position: absolute;
bottom: 1px;
right: -8px;
width: 8px;
height: 8px;
background: transparent;
border-bottom-left-radius: 6px;
box-shadow: -3px 3px 0 0 ${(props) => props.theme.bg || '#ffffff'};
border-left: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
border-bottom: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
}
}
&.permanent-tab {
.close-icon {
display: none;
}
}
&.short-tab {
width: 32px;
min-width: 32px;
max-width: 32px;
padding: 5px 0;
display: inline-flex;
justify-content: center;
align-items: center;
color: ${(props) => props.theme.text};
background-color: transparent;
border: 1px solid transparent;
border-radius: ${(props) => props.theme.border.radius.base};
flex-shrink: 0;
> div {
padding: 3px;
display: flex;
align-items: center;
justify-content: center;
border-radius: ${(props) => props.theme.border.radius.sm};
transition: background-color 0.12s ease, color 0.12s ease;
}
svg {
height: 20px;
width: 20px;
}
&:hover {
> div {
background-color: ${(props) => props.theme.background.surface0};
color: ${(props) => props.theme.text};
}
}
}
}
}
&.has-chevrons ul {
padding-left: 0;
}
`;
export default Wrapper;

View File

@@ -0,0 +1,61 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
position: relative;
width: 100%;
height: 100%;
.tab-label {
overflow: hidden;
align-items: center;
position: relative;
flex: 1;
min-width: 0;
}
.tab-icon {
flex-shrink: 0;
display: flex;
align-items: center;
margin-right: 6px;
color: ${(props) => props.theme.requestTabs.color};
}
.tab-name {
position: relative;
overflow: hidden;
white-space: nowrap;
font-size: 0.8125rem;
padding-right: 2px;
}
.close-icon {
margin-left: 6px;
padding: 2px;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.15s, background-color 0.15s;
&:hover {
background-color: ${(props) => props.theme.requestTabs.closeIconHoverBg || 'rgba(0, 0, 0, 0.1)'};
}
svg {
width: 14px;
height: 14px;
}
}
&:hover .close-icon {
opacity: 1;
}
&.permanent .close-icon {
display: none;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { IconX, IconHome, IconWorld, IconSettings } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { closeWorkspaceTab } from 'providers/ReduxStore/slices/workspaceTabs';
import StyledWrapper from './StyledWrapper';
const TAB_ICONS = {
overview: IconHome,
environments: IconWorld,
preferences: IconSettings
};
const WorkspaceTab = ({ tab, isActive }) => {
const dispatch = useDispatch();
const handleCloseClick = (event) => {
event.stopPropagation();
event.preventDefault();
dispatch(closeWorkspaceTab({ uid: tab.uid }));
};
const TabIcon = TAB_ICONS[tab.type];
return (
<StyledWrapper className={`flex items-center justify-between tab-container px-2 ${tab.permanent ? 'permanent' : ''}`}>
<div className="flex items-center tab-label">
{TabIcon && (
<span className="tab-icon">
<TabIcon size={14} strokeWidth={1.5} />
</span>
)}
<span className="tab-name" title={tab.label}>
{tab.label}
</span>
</div>
{!tab.permanent && (
<div className="close-icon" onClick={handleCloseClick}>
<IconX size={14} strokeWidth={1.5} />
</div>
)}
</StyledWrapper>
);
};
export default WorkspaceTab;

View File

@@ -0,0 +1,158 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import filter from 'lodash/filter';
import classnames from 'classnames';
import { IconChevronRight, IconChevronLeft } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { focusWorkspaceTab, initializeWorkspaceTabs } from 'providers/ReduxStore/slices/workspaceTabs';
import WorkspaceTab from './WorkspaceTab';
import StyledWrapper from './StyledWrapper';
const PERMANENT_TABS = [
{ type: 'overview', label: 'Overview' },
{ type: 'environments', label: 'Global Environments' }
];
const WorkspaceTabs = ({ workspaceUid }) => {
const dispatch = useDispatch();
const tabsRef = useRef();
const scrollContainerRef = useRef();
const [tabOverflowStates, setTabOverflowStates] = useState({});
const [showChevrons, setShowChevrons] = useState(false);
const tabs = useSelector((state) => state.workspaceTabs.tabs);
const activeTabUid = useSelector((state) => state.workspaceTabs.activeTabUid);
const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth);
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
const screenWidth = useSelector((state) => state.app.screenWidth);
// Initialize permanent tabs for this workspace
useEffect(() => {
if (workspaceUid) {
dispatch(initializeWorkspaceTabs({
workspaceUid,
permanentTabs: PERMANENT_TABS
}));
}
}, [workspaceUid, dispatch]);
const createSetHasOverflow = useCallback((tabUid) => {
return (hasOverflow) => {
setTabOverflowStates((prev) => {
if (prev[tabUid] === hasOverflow) {
return prev;
}
return {
...prev,
[tabUid]: hasOverflow
};
});
};
}, []);
// Filter tabs for this workspace
const workspaceTabs = filter(tabs, (t) => t.workspaceUid === workspaceUid);
useEffect(() => {
if (!activeTabUid) return;
const checkOverflow = () => {
if (tabsRef.current && scrollContainerRef.current) {
const hasOverflow = tabsRef.current.scrollWidth > scrollContainerRef.current.clientWidth + 1;
setShowChevrons(hasOverflow);
}
};
checkOverflow();
const resizeObserver = new ResizeObserver(checkOverflow);
if (scrollContainerRef.current) {
resizeObserver.observe(scrollContainerRef.current);
}
return () => resizeObserver.disconnect();
}, [activeTabUid, workspaceTabs.length, screenWidth, leftSidebarWidth, sidebarCollapsed]);
const getTabClassname = (tab, index) => {
return classnames('request-tab select-none', {
'active': tab.uid === activeTabUid,
'permanent-tab': tab.permanent,
'last-tab': workspaceTabs && workspaceTabs.length && index === workspaceTabs.length - 1,
'has-overflow': tabOverflowStates[tab.uid]
});
};
const handleClick = (tab) => {
dispatch(focusWorkspaceTab({ uid: tab.uid }));
};
if (!workspaceUid || workspaceTabs.length === 0) {
return null;
}
const effectiveSidebarWidth = sidebarCollapsed ? 0 : leftSidebarWidth;
const maxTablistWidth = screenWidth - effectiveSidebarWidth - 150;
const leftSlide = () => {
scrollContainerRef.current?.scrollBy({
left: -120,
behavior: 'smooth'
});
};
const rightSlide = () => {
scrollContainerRef.current?.scrollBy({
left: 120,
behavior: 'smooth'
});
};
const getRootClassname = () => {
return classnames({
'has-chevrons': showChevrons
});
};
return (
<StyledWrapper className={getRootClassname()}>
<div className="flex items-center pl-2">
<ul role="tablist">
{showChevrons ? (
<li className="select-none short-tab" onClick={leftSlide}>
<div className="flex items-center">
<IconChevronLeft size={18} strokeWidth={1.5} />
</div>
</li>
) : null}
</ul>
<div className="tabs-scroll-container" style={{ maxWidth: maxTablistWidth }} ref={scrollContainerRef}>
<ul role="tablist" ref={tabsRef}>
{workspaceTabs.map((tab, index) => (
<li
key={tab.uid}
className={getTabClassname(tab, index)}
onClick={() => handleClick(tab)}
>
<WorkspaceTab
tab={tab}
isActive={tab.uid === activeTabUid}
hasOverflow={tabOverflowStates[tab.uid]}
setHasOverflow={createSetHasOverflow(tab.uid)}
/>
</li>
))}
</ul>
</div>
<ul role="tablist">
{showChevrons ? (
<li className="select-none short-tab" onClick={rightSlide}>
<div className="flex items-center">
<IconChevronRight size={18} strokeWidth={1.5} />
</div>
</li>
) : null}
</ul>
</div>
</StyledWrapper>
);
};
export default WorkspaceTabs;

View File

@@ -395,12 +395,11 @@ const GlobalStyle = createGlobalStyle`
font-size: ${(props) => props.theme.font.size.base};
font-family: Inter, sans-serif;
font-weight: 400;
overflow-wrap: break-word;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.25rem;
color: ${(props) => props.theme.dropdown.color};
min-height: 1.75rem;
max-width: 17.1875rem;
max-width: 13.1875rem;
}
/* Value Editor (CodeMirror) */

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useState, useMemo, useCallback } from 'react';
import { isItemAFolder } from 'utils/collections';
import { sortByNameThenSequence } from 'utils/common/index';
import filter from 'lodash/filter';
@@ -63,7 +63,6 @@ const useCollectionFolderTree = (collectionUid) => {
const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid));
const [currentFolderPath, setCurrentFolderPath] = useState([]);
const [selectedFolderUid, setSelectedFolderUid] = useState(null);
const tree = useMemo(() => {
if (!collection || !collection.items) {
return {};
@@ -144,10 +143,6 @@ const useCollectionFolderTree = (collectionUid) => {
setSelectedFolderUid(null);
}, []);
useEffect(() => {
reset();
}, [collectionUid, reset]);
return {
currentFolders,
breadcrumbs,

View File

@@ -50,7 +50,7 @@ export default function useProtoFileManagement(collection) {
const cachedMethods = protofileCache[absolutePath];
if (cachedMethods && !isLoadingMethods && !isManualRefresh) {
return { methods: cachedMethods, error: null, fromCache: true };
return { methods: cachedMethods, error: null };
}
setIsLoadingMethods(true);
@@ -67,7 +67,7 @@ export default function useProtoFileManagement(collection) {
[absolutePath]: methods
}));
return { methods, error: null, fromCache: false };
return { methods, error: null };
} catch (err) {
console.error('Error loading gRPC methods:', err);
return { methods: [], error: err };

View File

@@ -27,7 +27,7 @@ export default function useReflectionManagement(item, collectionUid) {
const cachedMethods = reflectionCache[url];
if (!isManualRefresh && cachedMethods && !isLoadingMethods) {
return { methods: cachedMethods, error: null, fromCache: true };
return { methods: cachedMethods, error: null };
}
setIsLoadingMethods(true);
@@ -44,7 +44,7 @@ export default function useReflectionManagement(item, collectionUid) {
[url]: methods
}));
return { methods, error: null, fromCache: false };
return { methods, error: null };
} catch (error) {
console.error('Error loading gRPC methods:', error);
return { methods: [], error };

View File

@@ -1,5 +1,6 @@
import React, { useState, useRef, useEffect } from 'react';
import classnames from 'classnames';
import WorkspaceHome from 'components/WorkspaceHome';
import ManageWorkspace from 'components/ManageWorkspace';
import RequestTabs from 'components/RequestTabs';
import RequestTabPanel from 'components/RequestTabPanel';
@@ -76,6 +77,7 @@ export default function Main() {
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const activeApiSpecUid = useSelector((state) => state.apiSpec.activeApiSpecUid);
const isDragging = useSelector((state) => state.app.isDragging);
const showHomePage = useSelector((state) => state.app.showHomePage);
const showApiSpecPage = useSelector((state) => state.app.showApiSpecPage);
const showManageWorkspacePage = useSelector((state) => state.app.showManageWorkspacePage);
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
@@ -142,6 +144,8 @@ export default function Main() {
<ApiSpecPanel key={activeApiSpecUid} />
) : showManageWorkspacePage ? (
<ManageWorkspace />
) : showHomePage || !activeTabUid ? (
<WorkspaceHome />
) : (
<>
<RequestTabs />

View File

@@ -62,6 +62,7 @@ const SaveRequestsModal = ({ onClose }) => {
const requests = filter(items, (item) => isItemARequest(item) && hasRequestChanges(item));
each(requests, (draft) => {
requestDrafts.push({
type: 'request',
...draft,
collectionUid: collectionUid
});
@@ -115,7 +116,7 @@ const SaveRequestsModal = ({ onClose }) => {
// Separate drafts by type
const collectionDrafts = allDrafts.filter((d) => d.type === 'collection');
const folderDrafts = allDrafts.filter((d) => d.type === 'folder');
const requestDrafts = allDrafts.filter((d) => isItemARequest(d));
const requestDrafts = allDrafts.filter((d) => d.type === 'request');
const collectionEnvironmentDrafts = allDrafts.filter((d) => d.type === 'collection-environment');
const globalEnvironmentDrafts = allDrafts.filter((d) => d.type === 'global-environment');
@@ -218,7 +219,7 @@ const SaveRequestsModal = ({ onClose }) => {
</Button>
</div>
<div className="flex gap-2">
<Button color="secondary" variant="ghost" onClick={onClose}>
<Button size="sm" color="secondary" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button onClick={closeWithSave}>

View File

@@ -1,12 +1,14 @@
import { useEffect } from 'react';
import {
updateCookies,
updatePreferences,
setGitVersion
updatePreferences
} from 'providers/ReduxStore/slices/app';
import {
addTab
} from 'providers/ReduxStore/slices/tabs';
import {
setActiveWorkspaceTab
} from 'providers/ReduxStore/slices/workspaceTabs';
import {
brunoConfigUpdateEvent,
collectionAddDirectoryEvent,
@@ -26,16 +28,13 @@ import {
setDotEnvVariables
} from 'providers/ReduxStore/slices/collections';
import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUiStateSnapshot, mergeAndPersistEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import {
workspaceOpenedEvent,
workspaceConfigUpdatedEvent
} from 'providers/ReduxStore/slices/workspaces/actions';
import { workspaceOpenedEvent, workspaceConfigUpdatedEvent } from 'providers/ReduxStore/slices/workspaces/actions';
import { workspaceDotEnvUpdateEvent, setWorkspaceDotEnvVariables } from 'providers/ReduxStore/slices/workspaces';
import toast from 'react-hot-toast';
import { useDispatch, useStore } from 'react-redux';
import { isElectron } from 'utils/common/platform';
import { globalEnvironmentsUpdateEvent, updateGlobalEnvironments } from 'providers/ReduxStore/slices/global-environments';
import { collectionAddOauth2CredentialsByUrl, collectionClearOauth2CredentialsByCredentialsId, updateCollectionLoadingState } from 'providers/ReduxStore/slices/collections/index';
import { collectionAddOauth2CredentialsByUrl, updateCollectionLoadingState } from 'providers/ReduxStore/slices/collections/index';
import { addLog } from 'providers/ReduxStore/slices/logs';
import { updateSystemResources } from 'providers/ReduxStore/slices/performance';
import { apiSpecAddFileEvent, apiSpecChangeFileEvent } from 'providers/ReduxStore/slices/apiSpec';
@@ -275,21 +274,24 @@ const useIpcEvents = () => {
const removeShowPreferencesListener = ipcRenderer.on('main:open-preferences', () => {
const state = store.getState();
const activeWorkspaceUid = state.workspaces?.activeWorkspaceUid;
const workspaces = state.workspaces?.workspaces;
const { showHomePage, showManageWorkspacePage, showApiSpecPage } = state.app;
const tabs = state.tabs?.tabs;
const activeTabUid = state.tabs?.activeTabUid;
const activeTab = tabs?.find((t) => t.uid === activeTabUid);
const activeWorkspace = workspaces?.find((w) => w.uid === activeWorkspaceUid);
const collectionUid = activeTab?.collectionUid || activeWorkspace?.scratchCollectionUid;
dispatch(
addTab({
type: 'preferences',
uid: collectionUid ? `${collectionUid}-preferences` : 'preferences',
collectionUid
})
);
if (showHomePage || showManageWorkspacePage || showApiSpecPage || !activeTabUid) {
if (activeWorkspaceUid) {
dispatch(setActiveWorkspaceTab({ workspaceUid: activeWorkspaceUid, type: 'preferences' }));
}
} else {
dispatch(
addTab({
type: 'preferences',
uid: activeTab?.collectionUid ? `${activeTab.collectionUid}-preferences` : 'preferences',
collectionUid: activeTab?.collectionUid
})
);
}
});
const removePreferencesUpdatesListener = ipcRenderer.on('main:load-preferences', (val) => {
@@ -318,10 +320,6 @@ const useIpcEvents = () => {
dispatch(collectionAddOauth2CredentialsByUrl(payload));
});
const removeCollectionOauth2CredentialsClearListener = ipcRenderer.on('main:credentials-clear', (val) => {
dispatch(collectionClearOauth2CredentialsByCredentialsId(val));
});
const removeHttpStreamNewDataListener = ipcRenderer.on('main:http-stream-new-data', (val) => {
dispatch(streamDataReceived(val));
});
@@ -334,10 +332,6 @@ const useIpcEvents = () => {
dispatch(updateCollectionLoadingState(val));
});
const gitVersionListener = ipcRenderer.on('main:git-version', (val) => {
dispatch(setGitVersion(val));
});
return () => {
removeCollectionTreeUpdateListener();
removeApiSpecTreeUpdateListener();
@@ -364,13 +358,11 @@ const useIpcEvents = () => {
removeGlobalEnvironmentsUpdatesListener();
removeSnapshotHydrationListener();
removeCollectionOauth2CredentialsUpdatesListener();
removeCollectionOauth2CredentialsClearListener();
removeHttpStreamNewDataListener();
removeHttpStreamEndListener();
removeCollectionLoadingStateListener();
removePersistentEnvVariablesUpdateListener();
removeSystemResourcesListener();
gitVersionListener();
};
}, [isElectron]);
};

View File

@@ -11,11 +11,11 @@ import {
saveRequest,
saveCollectionRoot,
saveFolderRoot,
saveCollectionSettings,
closeTabs
saveCollectionSettings
} from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
import { addTab, reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
import { addTab, closeTabs, reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
import { closeWorkspaceTab } from 'providers/ReduxStore/slices/workspaceTabs';
import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { getKeyBindingsForActionAllOS } from './keyMappings';
@@ -26,6 +26,8 @@ export const HotkeysProvider = (props) => {
const tabs = useSelector((state) => state.tabs.tabs);
const collections = useSelector((state) => state.collections.collections);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const showHomePage = useSelector((state) => state.app.showHomePage);
const activeWorkspaceTabUid = useSelector((state) => state.workspaceTabs.activeTabUid);
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
const [showGlobalSearchModal, setShowGlobalSearchModal] = useState(false);
@@ -172,7 +174,9 @@ export const HotkeysProvider = (props) => {
// close tab hotkey
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('closeTab')], (e) => {
if (activeTabUid) {
if (showHomePage && activeWorkspaceTabUid) {
dispatch(closeWorkspaceTab({ uid: activeWorkspaceTabUid }));
} else if (activeTabUid) {
dispatch(
closeTabs({
tabUids: [activeTabUid]
@@ -186,7 +190,7 @@ export const HotkeysProvider = (props) => {
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('closeTab')]);
};
}, [activeTabUid]);
}, [activeTabUid, showHomePage, activeWorkspaceTabUid]);
// Switch to the previous tab
useEffect(() => {

View File

@@ -35,8 +35,7 @@ const KeyMapping = {
collapseSidebar: { mac: 'command+\\', windows: 'ctrl+\\', name: 'Collapse Sidebar' },
zoomIn: { mac: 'command+=', windows: 'ctrl+=', name: 'Zoom In' },
zoomOut: { mac: 'command+-', windows: 'ctrl+-', name: 'Zoom Out' },
resetZoom: { mac: 'command+0', windows: 'ctrl+0', name: 'Reset Zoom' },
renameItem: { mac: 'enter', windows: 'f2', name: 'Rename Collection Item' }
resetZoom: { mac: 'command+0', windows: 'ctrl+0', name: 'Reset Zoom' }
};
/**

View File

@@ -4,6 +4,7 @@ import debugMiddleware from './middlewares/debug/middleware';
import appReducer from './slices/app';
import collectionsReducer from './slices/collections';
import tabsReducer from './slices/tabs';
import workspaceTabsReducer from './slices/workspaceTabs';
import notificationsReducer from './slices/notifications';
import globalEnvironmentsReducer from './slices/global-environments';
import logsReducer from './slices/logs';
@@ -27,6 +28,7 @@ export const store = configureStore({
app: appReducer,
collections: collectionsReducer,
tabs: tabsReducer,
workspaceTabs: workspaceTabsReducer,
notifications: notificationsReducer,
globalEnvironments: globalEnvironmentsReducer,
logs: logsReducer,

View File

@@ -3,9 +3,9 @@ import each from 'lodash/each';
import filter from 'lodash/filter';
import { createListenerMiddleware } from '@reduxjs/toolkit';
import { removeTaskFromQueue } from 'providers/ReduxStore/slices/app';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { addTab, closeTabs, closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs';
import { collectionAddFileEvent, collectionChangeFileEvent } from 'providers/ReduxStore/slices/collections';
import { findCollectionByUid, findItemInCollectionByPathname, getDefaultRequestPaneTab, findItemInCollectionByItemUid } from 'utils/collections/index';
import { findCollectionByUid, findItemInCollectionByPathname, getDefaultRequestPaneTab, findItemInCollectionByItemUid, findItemInCollection, flattenItems } from 'utils/collections/index';
import { taskTypes } from './utils';
const taskMiddleware = createListenerMiddleware();
@@ -29,13 +29,14 @@ taskMiddleware.startListening({
const collection = findCollectionByUid(state.collections.collections, collectionUid);
if (collection && collection.mountStatus === 'mounted' && !collection.isLoading) {
const item = findItemInCollectionByPathname(collection, task.itemPathname);
const isTransient = item?.isTransient ?? false;
if (item) {
listenerApi.dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
requestPaneTab: getDefaultRequestPaneTab(item),
preview: task?.preview ?? true
preview: !isTransient
})
);
}
@@ -92,4 +93,39 @@ taskMiddleware.startListening({
}
});
/*
* When tabs are closed, check if any of them are transient requests.
* If so, delete the temporary files from the filesystem.
* Note: If a transient request was saved (moved to permanent location),
* the file will already be deleted, which is expected behavior.
*/
taskMiddleware.startListening({
actionCreator: closeTabs,
effect: (action, listenerApi) => {
const state = listenerApi.getState();
const tabUids = action.payload.tabUids || [];
const { ipcRenderer } = window;
each(tabUids, (tabUid) => {
const collections = state.collections.collections;
for (const collection of collections) {
const item = findItemInCollection(collection, tabUid);
const isTransient = item?.isTransient ?? false;
if (item && isTransient) {
ipcRenderer
.invoke('renderer:delete-item', item.pathname, item.type, collection.pathname)
.then(() => {})
.catch((err) => {
if (err.message && !err.message.includes('does not exist')) {
console.error(`Failed to delete transient request file: ${item.pathname}`, err);
}
});
break;
}
}
});
}
});
export default taskMiddleware;

View File

@@ -1,7 +1,7 @@
import { createSlice } from '@reduxjs/toolkit';
import filter from 'lodash/filter';
import brunoClipboard from 'utils/bruno-clipboard';
import { addTab, focusTab } from './tabs';
import { addTab, focusTab, closeTabs } from './tabs';
const initialState = {
isDragging: false,
@@ -48,8 +48,6 @@ const initialState = {
},
cookies: [],
taskQueue: [],
gitOperationProgress: {},
gitVersion: null,
clipboard: {
hasCopiedItems: false // Whether clipboard has Bruno data (for UI)
},
@@ -125,19 +123,6 @@ export const appSlice = createSlice({
toggleSidebarCollapse: (state) => {
state.sidebarCollapsed = !state.sidebarCollapsed;
},
updateGitOperationProgress: (state, action) => {
const { uid, data } = action.payload;
if (!state.gitOperationProgress[uid]) {
state.gitOperationProgress[uid] = { progressData: [] };
}
state.gitOperationProgress[uid].progressData.push(data);
},
removeGitOperationProgress: (state, action) => {
delete state.gitOperationProgress[action.payload];
},
setGitVersion: (state, action) => {
state.gitVersion = action.payload;
},
setClipboard: (state, action) => {
// Update clipboard UI state
state.clipboard.hasCopiedItems = action.payload.hasCopiedItems;
@@ -179,9 +164,6 @@ export const {
updateSystemProxyVariables,
updateGenerateCode,
toggleSidebarCollapse,
updateGitOperationProgress,
removeGitOperationProgress,
setGitVersion,
setClipboard
} = appSlice.actions;

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