mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 01:18:32 +00:00
Compare commits
59 Commits
dependabot
...
fix/flaky-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ef8867635 | ||
|
|
1d126dcb65 | ||
|
|
0c3b828b09 | ||
|
|
e000e377d1 | ||
|
|
4e1123bd2d | ||
|
|
ac33c909ef | ||
|
|
53e158c6d1 | ||
|
|
3e581675cd | ||
|
|
e03cf9a519 | ||
|
|
91467f699c | ||
|
|
3871ca9edd | ||
|
|
2517fe078f | ||
|
|
7f047a4412 | ||
|
|
d30ab4d984 | ||
|
|
836c2b9ace | ||
|
|
e1827080dd | ||
|
|
ff87eb23ee | ||
|
|
7460078fd6 | ||
|
|
e4b6f7a28b | ||
|
|
bac51191ee | ||
|
|
6f4489a8f3 | ||
|
|
2d8c767b90 | ||
|
|
ccac391848 | ||
|
|
bff4da336a | ||
|
|
4c779da2d3 | ||
|
|
5d0a15121c | ||
|
|
215c9f9e8a | ||
|
|
828cb19048 | ||
|
|
a86f0e492f | ||
|
|
7d25d13436 | ||
|
|
00a59840fb | ||
|
|
ffa3509e8e | ||
|
|
82d93ec840 | ||
|
|
9127be8498 | ||
|
|
1d1c3d83ec | ||
|
|
aa2d7a120f | ||
|
|
20eb7b7277 | ||
|
|
37fbdec983 | ||
|
|
3b0370643a | ||
|
|
e3bf8f29b8 | ||
|
|
edee75e372 | ||
|
|
786326ae80 | ||
|
|
814663acb9 | ||
|
|
1c5e1c5fcf | ||
|
|
3c0d9ccd4c | ||
|
|
f07c93d613 | ||
|
|
319422c20f | ||
|
|
78240d9232 | ||
|
|
1443fb0f4e | ||
|
|
e6dd582a02 | ||
|
|
29e5ab95fe | ||
|
|
79ce71c040 | ||
|
|
15c2373fb0 | ||
|
|
27da99b817 | ||
|
|
ce01c69395 | ||
|
|
cdc3cb3bdf | ||
|
|
4de470525d | ||
|
|
798db041fa | ||
|
|
5672745b76 |
3
.github/workflows/flaky-test-detector.yml
vendored
3
.github/workflows/flaky-test-detector.yml
vendored
@@ -9,6 +9,7 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
checks: write
|
||||
|
||||
jobs:
|
||||
@@ -72,7 +73,7 @@ jobs:
|
||||
|
||||
- name: Post PR comment
|
||||
if: hashFiles('pr-comment.md') != ''
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -49,6 +49,7 @@ bruno.iml
|
||||
.idea
|
||||
.vscode
|
||||
.cursor
|
||||
.claude
|
||||
|
||||
# Playwright
|
||||
/blob-report/
|
||||
|
||||
1183
package-lock.json
generated
1183
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -100,6 +100,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1"
|
||||
"ajv": "^8.17.1",
|
||||
"git-url-parse": "^14.1.0"
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@
|
||||
"shell-quote": "^1.8.3",
|
||||
"strip-json-comments": "^5.0.1",
|
||||
"styled-components": "^5.3.3",
|
||||
"swagger-ui-react": "5.17.12",
|
||||
"swagger-ui-react": "^5.31.0",
|
||||
"system": "^2.0.1",
|
||||
"url": "^0.11.3",
|
||||
"xml-formatter": "^3.5.0",
|
||||
@@ -130,4 +130,4 @@
|
||||
"form-data": "4.0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { savePreferences, showHomePage, showManageWorkspacePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import { savePreferences, 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';
|
||||
@@ -129,7 +130,10 @@ const AppTitleBar = () => {
|
||||
});
|
||||
|
||||
const handleHomeClick = () => {
|
||||
dispatch(showHomePage());
|
||||
const scratchCollectionUid = activeWorkspace?.scratchCollectionUid;
|
||||
if (scratchCollectionUid) {
|
||||
dispatch(focusTab({ uid: `${scratchCollectionUid}-overview` }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleWorkspaceSwitch = (workspaceUid) => {
|
||||
|
||||
@@ -233,10 +233,17 @@ 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) {
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = this.props.value;
|
||||
this.editor.setValue(this.props.value);
|
||||
this.editor.setCursor(cursor);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.editor) {
|
||||
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
|
||||
@@ -73,6 +74,10 @@ 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">
|
||||
@@ -83,11 +88,15 @@ const Script = ({ collection }) => {
|
||||
<TabsList>
|
||||
<TabsTrigger value="pre-request">
|
||||
Pre Request
|
||||
{requestScript && requestScript.trim().length > 0 && <StatusDot />}
|
||||
{requestScript && requestScript.trim().length > 0 && (
|
||||
<StatusDot type={hasPreRequestScriptError ? 'error' : 'default'} />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="post-response">
|
||||
Post Response
|
||||
{responseScript && responseScript.trim().length > 0 && <StatusDot />}
|
||||
{responseScript && responseScript.trim().length > 0 && (
|
||||
<StatusDot type={hasPostResponseScriptError ? 'error' : 'default'} />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
|
||||
const ColorBadge = ({ color, size = 10, showEmptyBorder = true }) => {
|
||||
const ColorBadge = ({ color, size = 10 }) => {
|
||||
const sizeValue = typeof size === 'string' ? size : `${size}px`;
|
||||
const { theme } = useTheme();
|
||||
|
||||
const showBorder = !color && showEmptyBorder;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
width: sizeValue,
|
||||
height: sizeValue,
|
||||
backgroundColor: color || 'transparent',
|
||||
border: showBorder ? '1px solid' : 'none',
|
||||
borderColor: showBorder ? theme.background.surface1 : 'transparent'
|
||||
backgroundColor: color || 'transparent'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -134,15 +134,15 @@ const ColorPicker = ({ color, onChange, icon }) => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-2 pt-2">
|
||||
<div className="flex items-center gap-2 mt-2 pt-0.5">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full flex-shrink-0 cursor-pointer"
|
||||
className="w-5 h-5 rounded-full flex-shrink-0 cursor-pointer"
|
||||
style={{ backgroundColor: customColor }}
|
||||
onClick={() => handleColorSelect(customColor)}
|
||||
title="Custom color"
|
||||
/>
|
||||
<ColorRangePicker
|
||||
className="flex-1"
|
||||
className="flex-1 flex"
|
||||
value={sliderPosition}
|
||||
onChange={handleSliderChange}
|
||||
onMouseUp={handleSliderEnd}
|
||||
|
||||
@@ -4,6 +4,7 @@ const StyledWrapper = styled.div`
|
||||
.hue-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
|
||||
@@ -2,14 +2,14 @@ import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const ColorRangePicker = ({ selectedColor, className, value, onChange, colorRange, ...props }) => {
|
||||
return (
|
||||
<StyledWrapper color={selectedColor}>
|
||||
<StyledWrapper color={selectedColor} className={className}>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className={`hue-slider ${className}`}
|
||||
className="hue-slider"
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${colorRange.join(',')})`
|
||||
}}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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',
|
||||
@@ -57,7 +58,7 @@ const CreateTransientRequest = ({ collectionUid }) => {
|
||||
|
||||
const collection = useMemo(() => {
|
||||
return collections?.find((c) => c.uid === collectionUid);
|
||||
}, [collections]);
|
||||
}, [collections, collectionUid]);
|
||||
|
||||
const collectionPresets = useMemo(() => {
|
||||
return get(collection, collection?.draft?.brunoConfig ? 'draft.brunoConfig.presets' : 'brunoConfig.presets', {
|
||||
@@ -103,7 +104,7 @@ const CreateTransientRequest = ({ collectionUid }) => {
|
||||
itemUid: null,
|
||||
isTransient: true
|
||||
})
|
||||
).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
|
||||
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
|
||||
}, [dispatch, collection, collectionPresets.requestUrl]);
|
||||
|
||||
const handleCreateGraphQLRequest = useCallback(() => {
|
||||
@@ -130,7 +131,7 @@ const CreateTransientRequest = ({ collectionUid }) => {
|
||||
}
|
||||
}
|
||||
})
|
||||
).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
|
||||
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
|
||||
}, [dispatch, collection, collectionPresets.requestUrl]);
|
||||
|
||||
const handleCreateWebSocketRequest = useCallback(() => {
|
||||
@@ -149,7 +150,7 @@ const CreateTransientRequest = ({ collectionUid }) => {
|
||||
itemUid: null,
|
||||
isTransient: true
|
||||
})
|
||||
).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
|
||||
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
|
||||
}, [dispatch, collection, collectionPresets.requestUrl]);
|
||||
|
||||
const handleCreateGrpcRequest = useCallback(() => {
|
||||
@@ -167,7 +168,7 @@ const CreateTransientRequest = ({ collectionUid }) => {
|
||||
itemUid: null,
|
||||
isTransient: true
|
||||
})
|
||||
).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
|
||||
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
|
||||
}, [dispatch, collection, collectionPresets.requestUrl]);
|
||||
|
||||
const handleItemClick = (type) => {
|
||||
|
||||
@@ -64,6 +64,89 @@ 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();
|
||||
|
||||
@@ -71,18 +154,30 @@ 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={arg}
|
||||
src={transformedArg}
|
||||
theme={displayedTheme === 'light' ? 'rjv-default' : 'monokai'}
|
||||
iconStyle="triangle"
|
||||
indentWidth={2}
|
||||
collapsed={1}
|
||||
collapsed={shouldCollapse}
|
||||
displayDataTypes={false}
|
||||
displayObjectSize={false}
|
||||
enableClipboard={false}
|
||||
name={false}
|
||||
name={displayName}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: '${(props) => props.theme.font.size.sm}',
|
||||
|
||||
@@ -85,6 +85,17 @@ 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};
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ const StyledWrapper = styled.div`
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
line-height: 1;
|
||||
text-overflow: clip;
|
||||
|
||||
input[type='checkbox'] {
|
||||
vertical-align: baseline;
|
||||
@@ -138,6 +139,9 @@ 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'] {
|
||||
|
||||
@@ -13,6 +13,7 @@ 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;
|
||||
@@ -92,6 +93,7 @@ const EnvironmentVariablesTable = ({
|
||||
}, []);
|
||||
|
||||
const prevEnvUidRef = useRef(null);
|
||||
const prevEnvVariablesRef = useRef(environment.variables);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
let _collection = collection ? cloneDeep(collection) : {};
|
||||
@@ -167,11 +169,13 @@ 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) && hasDraftForThisEnv && draft?.variables) {
|
||||
if ((isMount || envChanged || variablesReloaded) && hasDraftForThisEnv && draft?.variables) {
|
||||
formik.setValues([
|
||||
...draft.variables,
|
||||
{
|
||||
@@ -184,16 +188,16 @@ const EnvironmentVariablesTable = ({
|
||||
}
|
||||
]);
|
||||
}
|
||||
}, [environment.uid, hasDraftForThisEnv, draft?.variables]);
|
||||
}, [environment.uid, environment.variables, hasDraftForThisEnv, draft?.variables]);
|
||||
|
||||
const savedValuesJson = useMemo(() => {
|
||||
return JSON.stringify(environment.variables || []);
|
||||
return JSON.stringify((environment.variables || []).map(stripEnvVarUid));
|
||||
}, [environment.variables]);
|
||||
|
||||
// Sync modified state
|
||||
useEffect(() => {
|
||||
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
const currentValuesJson = JSON.stringify(currentValues);
|
||||
const currentValuesJson = JSON.stringify(currentValues.map(stripEnvVarUid));
|
||||
const hasActualChanges = currentValuesJson !== savedValuesJson;
|
||||
setIsModified(hasActualChanges);
|
||||
}, [formik.values, savedValuesJson, setIsModified]);
|
||||
@@ -202,11 +206,11 @@ const EnvironmentVariablesTable = ({
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
const currentValuesJson = JSON.stringify(currentValues);
|
||||
const currentValuesJson = JSON.stringify(currentValues.map(stripEnvVarUid));
|
||||
const hasActualChanges = currentValuesJson !== savedValuesJson;
|
||||
|
||||
const existingDraftVariables = hasDraftForThisEnv ? draft?.variables : null;
|
||||
const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables) : null;
|
||||
const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables.map(stripEnvVarUid)) : null;
|
||||
|
||||
if (hasActualChanges) {
|
||||
if (currentValuesJson !== existingDraftJson) {
|
||||
@@ -318,7 +322,8 @@ const EnvironmentVariablesTable = ({
|
||||
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
const savedValues = environment.variables || [];
|
||||
|
||||
const hasChanges = JSON.stringify(variablesToSave) !== JSON.stringify(savedValues);
|
||||
// Compare without UIDs since they can be different but the actual data is the same
|
||||
const hasChanges = JSON.stringify(variablesToSave.map(stripEnvVarUid)) !== JSON.stringify(savedValues.map(stripEnvVarUid));
|
||||
if (!hasChanges) {
|
||||
toast.error('No changes to save');
|
||||
return;
|
||||
@@ -441,8 +446,8 @@ const EnvironmentVariablesTable = ({
|
||||
</tr>
|
||||
)}
|
||||
fixedItemHeight={35}
|
||||
computeItemKey={(index, item) => item.variable.uid}
|
||||
itemContent={(index, { variable, index: actualIndex }) => {
|
||||
computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`}
|
||||
itemContent={(virtualIndex, { variable, index: actualIndex }) => {
|
||||
const isLastRow = actualIndex === formik.values.length - 1;
|
||||
const isEmptyRow = !variable.name || variable.name.trim() === '';
|
||||
const isLastEmptyRow = isLastRow && isEmptyRow;
|
||||
@@ -472,7 +477,7 @@ const EnvironmentVariablesTable = ({
|
||||
id={`${actualIndex}.name`}
|
||||
name={`${actualIndex}.name`}
|
||||
value={variable.name}
|
||||
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Value' : ''}
|
||||
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
|
||||
onChange={(e) => handleNameChange(actualIndex, e)}
|
||||
onBlur={() => handleNameBlur(actualIndex)}
|
||||
onKeyDown={(e) => handleNameKeyDown(actualIndex, e)}
|
||||
|
||||
@@ -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 })
|
||||
: importEnvironment({ name: environment.name, variables: environment.variables, collectionUid: collection?.uid });
|
||||
? addGlobalEnvironment({ name: environment.name, variables: environment.variables, color: environment.color })
|
||||
: importEnvironment({ name: environment.name, variables: environment.variables, color: environment.color, collectionUid: collection?.uid });
|
||||
|
||||
await dispatch(action);
|
||||
importedCount++;
|
||||
|
||||
@@ -4,7 +4,14 @@ import Modal from 'components/Modal';
|
||||
import Portal from 'components/Portal';
|
||||
import Button from 'ui/Button';
|
||||
|
||||
const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose, isGlobal }) => {
|
||||
const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose, isGlobal, isDotEnv }) => {
|
||||
let settingsLabel = 'collection environment settings';
|
||||
if (isDotEnv) {
|
||||
settingsLabel = '.env file';
|
||||
} else if (isGlobal) {
|
||||
settingsLabel = 'global environment settings';
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal
|
||||
@@ -21,7 +28,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 {isGlobal ? 'global' : 'collection'} environment settings.
|
||||
You have unsaved changes in {settingsLabel}.
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-6">
|
||||
|
||||
@@ -217,10 +217,12 @@ 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);
|
||||
@@ -240,10 +242,12 @@ 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);
|
||||
|
||||
@@ -39,7 +39,7 @@ const EnvironmentListContent = ({
|
||||
data-tooltip-content={env.name}
|
||||
data-tooltip-hidden={env.name?.length < 90}
|
||||
>
|
||||
<ColorBadge color={env.color} size={8} showEmptyBorder={false} />
|
||||
<ColorBadge color={env.color} size={8} />
|
||||
<span className="max-w-100% truncate no-wrap">{env.name}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -135,13 +135,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
};
|
||||
|
||||
const handleColorChange = (color) => {
|
||||
dispatch(updateEnvironmentColor(environment.uid, color, collection.uid))
|
||||
.then(() => {
|
||||
toast.success('Environment color updated!');
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('An error occurred while updating the environment color');
|
||||
});
|
||||
dispatch(updateEnvironmentColor(environment.uid, color, collection.uid));
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -22,6 +22,7 @@ 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';
|
||||
@@ -72,11 +73,24 @@ 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');
|
||||
setIsDotEnvModified(false);
|
||||
handleDotEnvModifiedChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -424,7 +438,7 @@ const EnvironmentList = ({
|
||||
dispatch(deleteDotEnvFile(collection.uid, filename))
|
||||
.then(() => {
|
||||
toast.success(`${filename} file deleted!`);
|
||||
setIsDotEnvModified(false);
|
||||
handleDotEnvModifiedChange(false);
|
||||
if (selectedDotEnvFile === filename) {
|
||||
const remainingFiles = dotEnvFiles.filter((f) => f.filename !== filename);
|
||||
if (remainingFiles.length > 0) {
|
||||
@@ -467,7 +481,7 @@ const EnvironmentList = ({
|
||||
onSave={handleSaveDotEnv}
|
||||
onSaveRaw={handleSaveDotEnvRaw}
|
||||
isModified={isDotEnvModified}
|
||||
setIsModified={setIsDotEnvModified}
|
||||
setIsModified={handleDotEnvModifiedChange}
|
||||
dotEnvExists={selectedDotEnvData?.exists}
|
||||
viewMode={dotEnvViewMode}
|
||||
collection={collection}
|
||||
|
||||
@@ -34,23 +34,36 @@ 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));
|
||||
return JSON.parse(JSON.stringify(arg, replacer));
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
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;
|
||||
@@ -0,0 +1,34 @@
|
||||
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;
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
|
||||
@@ -75,6 +76,10 @@ 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">
|
||||
@@ -85,11 +90,15 @@ const Script = ({ collection, folder }) => {
|
||||
<TabsList>
|
||||
<TabsTrigger value="pre-request">
|
||||
Pre Request
|
||||
{requestScript && requestScript.trim().length > 0 && <StatusDot />}
|
||||
{requestScript && requestScript.trim().length > 0 && (
|
||||
<StatusDot type={hasPreRequestScriptError ? 'error' : 'default'} />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="post-response">
|
||||
Post Response
|
||||
{responseScript && responseScript.trim().length > 0 && <StatusDot />}
|
||||
{responseScript && responseScript.trim().length > 0 && (
|
||||
<StatusDot type={hasPostResponseScriptError ? 'error' : 'default'} />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import Modal from 'components/Modal/index';
|
||||
import Portal from 'components/Portal/index';
|
||||
|
||||
const getOSName = () => {
|
||||
const platform = window.navigator.userAgentData?.platform || '';
|
||||
if (platform.startsWith('Win')) {
|
||||
return 'Windows';
|
||||
} else if (platform.startsWith('Mac')) {
|
||||
return 'macOS';
|
||||
} else if (platform.startsWith('Linux')) {
|
||||
return 'Linux';
|
||||
} else {
|
||||
return 'your OS';
|
||||
}
|
||||
};
|
||||
|
||||
const getDownloadUrl = (os) => {
|
||||
switch (os) {
|
||||
case 'Windows':
|
||||
return 'https://git-scm.com/download/win';
|
||||
case 'macOS':
|
||||
return 'https://git-scm.com/download/mac';
|
||||
case 'Linux':
|
||||
return 'https://git-scm.com/download/linux';
|
||||
default:
|
||||
return 'https://git-scm.com/download';
|
||||
}
|
||||
};
|
||||
|
||||
const GitNotFoundModal = ({ onClose }) => {
|
||||
const osName = getOSName();
|
||||
const downloadUrl = getDownloadUrl(osName);
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Git Not Found"
|
||||
handleCancel={onClose}
|
||||
hideFooter={true}
|
||||
>
|
||||
<div>
|
||||
<p>Git was not detected on your system. You need to install Git to proceed.</p>
|
||||
<p className="mt-2">
|
||||
You can download Git for <strong>{osName}</strong> here:
|
||||
</p>
|
||||
<p>
|
||||
<span
|
||||
className="text-blue-600 cursor-pointer border-b border-blue-600"
|
||||
onClick={() => window.open(downloadUrl, '_blank')}
|
||||
>
|
||||
Download Git for {osName}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default GitNotFoundModal;
|
||||
@@ -8,7 +8,37 @@ import React, { useState } from 'react';
|
||||
import HelpIcon from 'components/Icons/Help';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const Help = ({ children, width = 200 }) => {
|
||||
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 [showTooltip, setShowTooltip] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -24,9 +54,7 @@ const Help = ({ children, width = 200 }) => {
|
||||
<StyledWrapper
|
||||
className="absolute z-50 rounded-md p-3"
|
||||
style={{
|
||||
top: '50%',
|
||||
left: 'calc(100% + 8px)',
|
||||
transform: 'translateY(-50%)',
|
||||
...getPlacementStyles(placement),
|
||||
width: `${width}px`
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -154,10 +154,17 @@ class MultiLineEditor extends Component {
|
||||
this.editor.setOption('readOnly', this.props.readOnly);
|
||||
}
|
||||
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = String(this.props.value);
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.editor.setCursor(cursor);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
|
||||
// If the secret flag has changed, update the editor to reflect the change
|
||||
|
||||
@@ -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 100ms', (value) => {
|
||||
return value === undefined || Number(value) >= 100;
|
||||
.test('isValidInterval', 'Save Delay must be at least 500ms', (value) => {
|
||||
return value === undefined || Number(value) >= 500;
|
||||
})
|
||||
}).test('intervalRequired', 'Save Delay is required when Auto Save is enabled', (value) => {
|
||||
// If autosave is enabled, interval must be provided
|
||||
|
||||
@@ -118,7 +118,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
|
||||
};
|
||||
|
||||
const handleReflection = async (url, isManualRefresh = false) => {
|
||||
const { methods, error } = await reflectionManagement.loadMethodsFromReflection(url, isManualRefresh);
|
||||
const { methods, error, fromCache } = 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 (methods && methods.length > 0) {
|
||||
if (!fromCache && 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 } = await protoFileManagement.loadMethodsFromProtoFile(filePath, isManualRefresh);
|
||||
const { methods, error, fromCache } = await protoFileManagement.loadMethodsFromProtoFile(filePath, isManualRefresh);
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to load gRPC methods:', error);
|
||||
@@ -174,7 +174,9 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
|
||||
setGrpcMethods(methods);
|
||||
setIsReflectionMode(false);
|
||||
|
||||
toast.success(`Loaded ${methods.length} gRPC methods from proto file`);
|
||||
if (!fromCache) {
|
||||
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);
|
||||
|
||||
@@ -156,8 +156,15 @@ 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) {
|
||||
this.cachedValue = this.props.value;
|
||||
this.editor.setValue(this.props.value);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.props.theme !== prevProps.theme && this.editor) {
|
||||
|
||||
@@ -82,11 +82,15 @@ const Script = ({ item, collection }) => {
|
||||
<TabsList>
|
||||
<TabsTrigger value="pre-request">
|
||||
Pre Request
|
||||
{hasPreRequestScript && <StatusDot />}
|
||||
{hasPreRequestScript && (
|
||||
<StatusDot type={item.preRequestScriptErrorMessage ? 'error' : 'default'} />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="post-response">
|
||||
Post Response
|
||||
{hasPostResponseScript && <StatusDot />}
|
||||
{hasPostResponseScript && (
|
||||
<StatusDot type={item.postResponseScriptErrorMessage ? 'error' : 'default'} />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ const Tags = ({ item, collection }) => {
|
||||
handleRemoveTag={handleRemove}
|
||||
tags={tags}
|
||||
onSave={handleRequestSave}
|
||||
collectionFormat={collection.format}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import ErrorBanner from 'ui/ErrorBanner';
|
||||
import Button from 'ui/Button';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import ErrorBanner from 'ui/ErrorBanner';
|
||||
import Button from 'ui/Button';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import ErrorBanner from 'ui/ErrorBanner';
|
||||
import Button from 'ui/Button';
|
||||
|
||||
@@ -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 WorkspaceHome from 'components/WorkspaceHome';
|
||||
import WorkspaceOverview from 'components/WorkspaceHome/WorkspaceOverview';
|
||||
import Preferences from 'components/Preferences';
|
||||
import EnvironmentSettings from 'components/Environments/EnvironmentSettings';
|
||||
import GlobalEnvironmentSettings from 'components/Environments/GlobalEnvironmentSettings';
|
||||
@@ -43,9 +43,6 @@ 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);
|
||||
@@ -53,6 +50,8 @@ 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);
|
||||
|
||||
@@ -171,6 +170,10 @@ 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>;
|
||||
}
|
||||
@@ -183,6 +186,14 @@ 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>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
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;
|
||||
@@ -0,0 +1,452 @@
|
||||
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;
|
||||
@@ -1,5 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div``;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -1,83 +0,0 @@
|
||||
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;
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useRef, useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import { makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { saveRequest, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { hasExampleChanges, findItemInCollection } from 'utils/collections';
|
||||
import ExampleIcon from 'components/Icons/ExampleIcon';
|
||||
import ConfirmRequestClose from '../RequestTab/ConfirmRequestClose';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import GradientCloseButton from './GradientCloseButton';
|
||||
import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock, IconDatabase, IconWorld } from '@tabler/icons';
|
||||
import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock, IconDatabase, IconWorld, IconHome } from '@tabler/icons';
|
||||
|
||||
const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDraft }) => {
|
||||
const getTabInfo = (type, tabName) => {
|
||||
@@ -69,6 +69,22 @@ 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -80,7 +96,7 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
|
||||
>
|
||||
{getTabInfo(type, tabName)}
|
||||
</div>
|
||||
<GradientCloseButton hasChanges={hasDraft} onClick={(e) => handleCloseClick(e)} />
|
||||
{handleCloseClick && <GradientCloseButton hasChanges={hasDraft} onClick={(e) => handleCloseClick(e)} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useState, useRef, Fragment, useMemo, useEffect } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment, closeTabs } 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'].includes(tab.type)) {
|
||||
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'environment-settings', 'global-environment-settings', 'preferences', 'workspaceOverview', 'workspaceEnvironments'].includes(tab.type)) {
|
||||
return (
|
||||
<StyledWrapper
|
||||
className={`flex items-center justify-between tab-container px-2 ${tab.preview ? 'italic' : ''}`}
|
||||
@@ -236,6 +236,7 @@ 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 }));
|
||||
@@ -244,7 +245,25 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
}}
|
||||
onSaveAndClose={() => {
|
||||
const draft = collection.environmentsDraft;
|
||||
if (draft?.environmentUid && draft?.variables) {
|
||||
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) {
|
||||
dispatch(saveEnvironment(draft.variables, draft.environmentUid, collection.uid))
|
||||
.then(() => {
|
||||
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
|
||||
@@ -263,6 +282,7 @@ 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());
|
||||
@@ -271,7 +291,25 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
}}
|
||||
onSaveAndClose={() => {
|
||||
const draft = globalEnvironmentDraft;
|
||||
if (draft?.environmentUid && draft?.variables) {
|
||||
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) {
|
||||
dispatch(saveGlobalEnvironment({ variables: draft.variables, environmentUid: draft.environmentUid }))
|
||||
.then(() => {
|
||||
dispatch(clearGlobalEnvironmentDraft());
|
||||
@@ -297,6 +335,10 @@ 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} />
|
||||
)}
|
||||
@@ -474,19 +516,42 @@ 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 Promise.all(otherTabs.map((tab) => handleCloseTab(tab.uid)));
|
||||
await handleCloseMultipleTabs(otherTabs);
|
||||
}
|
||||
|
||||
async function handleCloseTabsToTheLeft() {
|
||||
const leftTabs = collectionRequestTabs.filter((_, index) => index < tabIndex);
|
||||
await Promise.all(leftTabs.map((tab) => handleCloseTab(tab.uid)));
|
||||
await handleCloseMultipleTabs(leftTabs);
|
||||
}
|
||||
|
||||
async function handleCloseTabsToTheRight() {
|
||||
const rightTabs = collectionRequestTabs.filter((_, index) => index > tabIndex);
|
||||
await Promise.all(rightTabs.map((tab) => handleCloseTab(tab.uid)));
|
||||
await handleCloseMultipleTabs(rightTabs);
|
||||
}
|
||||
|
||||
function handleCloseSavedTabs() {
|
||||
@@ -497,7 +562,7 @@ function RequestTabMenu({ menuDropdownRef, tabLabelRef, collectionRequestTabs, t
|
||||
}
|
||||
|
||||
async function handleCloseAllTabs() {
|
||||
await Promise.all(collectionRequestTabs.map((tab) => handleCloseTab(tab.uid)));
|
||||
await handleCloseMultipleTabs(collectionRequestTabs);
|
||||
}
|
||||
|
||||
const menuItems = useMemo(() => [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } 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 CollectionToolBar from './CollectionToolBar';
|
||||
import CollectionHeader from './CollectionHeader';
|
||||
import RequestTab from './RequestTab';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import DraggableTab from './DraggableTab';
|
||||
@@ -27,6 +27,7 @@ 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) => {
|
||||
@@ -46,6 +47,10 @@ 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;
|
||||
|
||||
@@ -110,7 +115,12 @@ const RequestTabs = () => {
|
||||
)}
|
||||
{collectionRequestTabs && collectionRequestTabs.length ? (
|
||||
<>
|
||||
{activeCollection && <CollectionToolBar collection={activeCollection} />}
|
||||
{activeCollection && (
|
||||
<CollectionHeader
|
||||
collection={activeCollection}
|
||||
isScratchCollection={isScratchCollection}
|
||||
/>
|
||||
)}
|
||||
<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' }}>
|
||||
|
||||
@@ -164,7 +164,7 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems })
|
||||
if (!items?.length) return;
|
||||
|
||||
items.forEach((item) => {
|
||||
if (isItemARequest(item) && !item.partial) {
|
||||
if (isItemARequest(item) && !item.partial && !item.isTransient) {
|
||||
const relativePath = path.relative(collection.pathname, path.dirname(item.pathname));
|
||||
const folderPath = relativePath !== '.' ? relativePath : '';
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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;
|
||||
@@ -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/tabs';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import toast from 'react-hot-toast';
|
||||
import Modal from 'components/Modal';
|
||||
import Button from 'ui/Button';
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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;
|
||||
@@ -127,6 +127,79 @@ 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;
|
||||
@@ -163,30 +236,17 @@ const StyledWrapper = styled.div`
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.new-folder-content {
|
||||
.new-folder-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.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;
|
||||
.new-folder-header-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.new-folder-input-row {
|
||||
@@ -247,13 +307,41 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.new-folder-filesystem-label {
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
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};
|
||||
}
|
||||
}
|
||||
|
||||
.new-folder-toggle-filesystem-btn {
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import Modal from 'components/Modal';
|
||||
import SearchInput from 'components/SearchInput';
|
||||
import Button from 'ui/Button';
|
||||
import { IconFolder, IconChevronRight, IconCheck, IconX, IconEye, IconEyeOff } from '@tabler/icons';
|
||||
import { IconFolder, IconChevronRight, IconCheck, IconX, IconEye, IconEyeOff, IconEdit, IconArrowBackUp } from '@tabler/icons';
|
||||
import PathDisplay from 'components/PathDisplay/index';
|
||||
import Help from 'components/Help';
|
||||
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, deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
|
||||
import { newFolder } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
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 { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
|
||||
import { resolveRequestFilename } from 'utils/common/platform';
|
||||
import { transformRequestToSaveToFilesystem, findCollectionByUid, findItemInCollection } from 'utils/collections';
|
||||
import path from 'utils/common/path';
|
||||
import { transformRequestToSaveToFilesystem, findCollectionByUid, findItemInCollection, areItemsLoading } 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();
|
||||
@@ -28,12 +35,27 @@ 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 || '');
|
||||
@@ -42,8 +64,28 @@ 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,
|
||||
@@ -55,21 +97,27 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
getCurrentSelectedFolder,
|
||||
reset,
|
||||
isAtRoot
|
||||
} = useCollectionFolderTree(collection?.uid);
|
||||
} = useCollectionFolderTree(folderTreeCollectionUid);
|
||||
|
||||
const resetForm = () => {
|
||||
setRequestName(item.name || '');
|
||||
const resetForm = useCallback(() => {
|
||||
setRequestName(item?.name || '');
|
||||
setSearchText('');
|
||||
reset();
|
||||
setShowNewFolderInput(false);
|
||||
setNewFolderName('');
|
||||
setNewFolderDirectoryName('');
|
||||
setShowFilesystemName(false);
|
||||
};
|
||||
setIsEditingFolderFilename(false);
|
||||
setPendingFolderNavigation(null);
|
||||
setSelectedTargetCollectionPath(null);
|
||||
setIsSelectingCollection(isScratchCollection);
|
||||
}, [item?.name, isScratchCollection, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
isOpen && item && resetForm();
|
||||
}, [isOpen, item]);
|
||||
if (isOpen && item) {
|
||||
resetForm();
|
||||
}
|
||||
}, [isOpen, item, resetForm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showNewFolderInput && newFolderInputRef.current) {
|
||||
@@ -77,6 +125,16 @@ 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;
|
||||
@@ -90,16 +148,41 @@ 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 : collection.pathname;
|
||||
const targetDirname = selectedFolder ? selectedFolder.pathname : targetCollection.pathname;
|
||||
|
||||
const trimmedName = requestName.trim();
|
||||
if (!trimmedName || trimmedName.length === 0) {
|
||||
@@ -107,6 +190,11 @@ 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 };
|
||||
@@ -116,23 +204,32 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
const transformedItem = transformRequestToSaveToFilesystem(itemToSave);
|
||||
await itemSchema.validate(transformedItem);
|
||||
|
||||
const format = collection.format || DEFAULT_COLLECTION_FORMAT;
|
||||
const targetFilename = resolveRequestFilename(sanitizedFilename, format);
|
||||
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);
|
||||
|
||||
await ipcRenderer.invoke('renderer:save-transient-request', {
|
||||
sourcePathname: item.pathname,
|
||||
targetDirname,
|
||||
targetFilename,
|
||||
request: transformedItem,
|
||||
format
|
||||
format: targetFormat,
|
||||
sourceFormat
|
||||
});
|
||||
|
||||
dispatch(
|
||||
closeTabs({
|
||||
tabUids: [item.uid]
|
||||
insertTaskIntoQueue({
|
||||
uid: uuid(),
|
||||
type: 'OPEN_REQUEST',
|
||||
collectionUid: targetCollection.uid,
|
||||
itemPathname: targetPathname,
|
||||
preview: false
|
||||
})
|
||||
);
|
||||
|
||||
dispatch(closeTabs({ tabUids: [item.uid] }));
|
||||
|
||||
dispatch({
|
||||
type: 'collections/deleteItem',
|
||||
payload: {
|
||||
@@ -144,7 +241,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
toast.success('Request saved successfully');
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
toast.error(err?.message || 'Failed to save request');
|
||||
toast.error(formatIpcError(err) || 'Failed to save request');
|
||||
console.error('Error saving request:', err);
|
||||
}
|
||||
};
|
||||
@@ -154,6 +251,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
setNewFolderName('');
|
||||
setNewFolderDirectoryName('');
|
||||
setShowFilesystemName(false);
|
||||
setIsEditingFolderFilename(false);
|
||||
};
|
||||
|
||||
const handleCancelNewFolder = () => {
|
||||
@@ -161,26 +259,38 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
setNewFolderName('');
|
||||
setNewFolderDirectoryName('');
|
||||
setShowFilesystemName(false);
|
||||
setIsEditingFolderFilename(false);
|
||||
};
|
||||
|
||||
const handleNewFolderNameChange = (value) => {
|
||||
setNewFolderName(value);
|
||||
if (!showFilesystemName) {
|
||||
if (!isEditingFolderFilename) {
|
||||
setNewFolderDirectoryName(sanitizeName(value));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDirectoryNameChange = (value) => {
|
||||
setNewFolderDirectoryName(value);
|
||||
};
|
||||
|
||||
const handleCreateNewFolder = async () => {
|
||||
const directoryName = newFolderDirectoryName.trim() || sanitizeName(newFolderName.trim());
|
||||
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 parentFolder = getCurrentParentFolder();
|
||||
const targetCollectionUid = selectedTargetCollection?.uid || collection?.uid;
|
||||
|
||||
try {
|
||||
await dispatch(newFolder(newFolderName.trim(), directoryName, collection?.uid, parentFolder?.uid));
|
||||
await dispatch(newFolder(trimmedFolderName, directoryName, targetCollectionUid, parentFolder?.uid));
|
||||
toast.success('New folder created!');
|
||||
|
||||
setPendingFolderNavigation(directoryName);
|
||||
handleCancelNewFolder();
|
||||
} catch (err) {
|
||||
const errorMessage = err?.message || 'An error occurred while adding the folder';
|
||||
@@ -193,6 +303,11 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
setSearchText('');
|
||||
};
|
||||
|
||||
const handleBreadcrumbNavigate = useCallback((index) => {
|
||||
navigateToBreadcrumb(index);
|
||||
setSearchText('');
|
||||
}, [navigateToBreadcrumb]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
@@ -201,7 +316,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
<StyledWrapper>
|
||||
<Modal
|
||||
size="md"
|
||||
title="Save Request"
|
||||
title={isSelectingCollection ? 'Select Collection' : 'Save Request'}
|
||||
handleCancel={handleCancel}
|
||||
handleConfirm={handleConfirm}
|
||||
confirmText="Save"
|
||||
@@ -223,168 +338,253 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
spellCheck="false"
|
||||
value={requestName}
|
||||
onChange={(e) => setRequestName(e.target.value)}
|
||||
autoFocus={true}
|
||||
autoFocus={!isSelectingCollection}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="collections-section">
|
||||
<div className="collections-label">Save to Collections</div>
|
||||
{collection && (
|
||||
<div
|
||||
className={`collection-name ${!isAtRoot ? 'collection-name-clickable' : ''}`}
|
||||
onClick={!isAtRoot ? navigateToRoot : undefined}
|
||||
>
|
||||
<span>{collection.name}</span>
|
||||
{breadcrumbs.length > 0 && (
|
||||
<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 && (
|
||||
<>
|
||||
{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>
|
||||
))}
|
||||
<IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />
|
||||
<FolderBreadcrumbs
|
||||
collectionName={(selectedTargetCollection || collection).name}
|
||||
breadcrumbs={breadcrumbs}
|
||||
isAtRoot={isAtRoot}
|
||||
onNavigateToRoot={navigateToRoot}
|
||||
onNavigateToBreadcrumb={handleBreadcrumbNavigate}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isAtRoot && <IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="search-container">
|
||||
<SearchInput
|
||||
searchText={searchText}
|
||||
setSearchText={setSearchText}
|
||||
placeholder="Search for folder"
|
||||
autoFocus={false}
|
||||
/>
|
||||
</div>
|
||||
{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="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 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>
|
||||
</div>
|
||||
|
||||
{showFilesystemName && (
|
||||
<div className="new-folder-filesystem-wrapper">
|
||||
<label className="new-folder-filesystem-label">Name on filesystem</label>
|
||||
<input
|
||||
type="text"
|
||||
className="new-folder-input"
|
||||
value={newFolderDirectoryName}
|
||||
onChange={(e) => handleDirectoryNameChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleCreateNewFolder();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div 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>
|
||||
)}
|
||||
</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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
</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 && (
|
||||
{!showNewFolderInput && !isSelectingCollection && (
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
@@ -400,9 +600,11 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
<Button type="button" color="secondary" variant="ghost" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" color="primary" onClick={handleConfirm}>
|
||||
Save
|
||||
</Button>
|
||||
{!isSelectingCollection && (
|
||||
<Button type="button" color="primary" onClick={handleConfirm}>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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;
|
||||
@@ -0,0 +1,887 @@
|
||||
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;
|
||||
@@ -0,0 +1,30 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
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;
|
||||
@@ -0,0 +1,372 @@
|
||||
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;
|
||||
@@ -2,8 +2,7 @@ import React from 'react';
|
||||
import Modal from 'components/Modal';
|
||||
import { isItemAFolder } from 'utils/tabs';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { deleteItem } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { deleteItem, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { recursivelyGetAllItemUids } from 'utils/collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -3,8 +3,7 @@ import Modal from 'components/Modal';
|
||||
import Portal from 'components/Portal';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { deleteResponseExample } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { saveRequest, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
|
||||
const DeleteResponseExampleModal = ({ onClose, example, item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -4,12 +4,11 @@ import * as Yup from 'yup';
|
||||
import Modal from 'components/Modal';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { isItemAFolder } from 'utils/tabs';
|
||||
import { renameItem, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { renameItem, saveRequest, closeTabs } 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';
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useMemo } 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,10 +18,14 @@ const Collections = ({ showSearch }) => {
|
||||
|
||||
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]);
|
||||
|
||||
if (!workspaceCollections || !workspaceCollections.length) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
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;
|
||||
@@ -0,0 +1,54 @@
|
||||
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;
|
||||
@@ -0,0 +1,30 @@
|
||||
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;
|
||||
@@ -0,0 +1,62 @@
|
||||
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;
|
||||
@@ -1,176 +1,120 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { IconFileImport } from '@tabler/icons';
|
||||
import { toastError } from 'utils/common/error';
|
||||
import React, { useState } from 'react';
|
||||
import { IconFileImport, IconBrandGit, IconUnlink, IconX } from '@tabler/icons';
|
||||
import Modal from 'components/Modal';
|
||||
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 classnames from 'classnames';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import FileTab from './FileTab';
|
||||
import GitHubTab from './GitHubTab';
|
||||
import UrlTab from './UrlTab';
|
||||
import FullscreenLoader from './FullscreenLoader/index';
|
||||
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 IMPORT_TABS = {
|
||||
FILE: 'file',
|
||||
GITHUB: 'github',
|
||||
URL: 'url'
|
||||
};
|
||||
|
||||
const ImportCollection = ({ onClose, handleSubmit }) => {
|
||||
const { theme } = useTheme();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [tab, setTab] = useState(IMPORT_TABS.FILE);
|
||||
|
||||
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 handleTabSelect = (value) => () => {
|
||||
setTab(value);
|
||||
setErrorMessage('');
|
||||
};
|
||||
|
||||
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]);
|
||||
}
|
||||
const getTabClassname = (tabName) => {
|
||||
return classnames(`flex tab items-center py-2 px-4 ${tabName}`, {
|
||||
active: tabName === tab
|
||||
});
|
||||
};
|
||||
|
||||
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="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>
|
||||
<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
|
||||
</div>
|
||||
</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>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -43,19 +43,21 @@ 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) => {
|
||||
const convertCollection = async (format, rawData, groupingType, collectionFormat) => {
|
||||
try {
|
||||
let collection;
|
||||
|
||||
switch (format) {
|
||||
case 'openapi':
|
||||
collection = convertOpenapiToBruno(rawData, { groupBy: groupingType });
|
||||
collection = convertOpenapiToBruno(rawData, { groupBy: groupingType, collectionFormat });
|
||||
break;
|
||||
case 'wsdl':
|
||||
collection = await wsdlToBruno(rawData);
|
||||
@@ -72,6 +74,10 @@ const convertCollection = async (format, rawData, groupingType) => {
|
||||
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');
|
||||
}
|
||||
@@ -96,6 +102,7 @@ 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);
|
||||
@@ -120,7 +127,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
|
||||
.required('Location is required')
|
||||
}),
|
||||
onSubmit: async (values) => {
|
||||
const convertedCollection = await convertCollection(format, rawData, groupingType);
|
||||
const convertedCollection = await convertCollection(format, rawData, groupingType, collectionFormat);
|
||||
handleSubmit(convertedCollection, values.collectionLocation, { format: collectionFormat });
|
||||
}
|
||||
});
|
||||
@@ -159,7 +166,19 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
|
||||
}
|
||||
}, [inputRef]);
|
||||
|
||||
const onSubmit = () => formik.handleSubmit();
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
@@ -212,30 +231,32 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
|
||||
</span>
|
||||
</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>
|
||||
{!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>
|
||||
|
||||
{isOpenApi && (
|
||||
|
||||
@@ -15,14 +15,17 @@ import {
|
||||
IconTerminal2
|
||||
} from '@tabler/icons';
|
||||
|
||||
import { importCollection, openCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { importCollection, openCollection, importCollectionFromZip } 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';
|
||||
@@ -44,33 +47,50 @@ 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]);
|
||||
|
||||
const handleImportCollection = ({ rawData, type }) => {
|
||||
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 }) => {
|
||||
setImportCollectionModalOpen(false);
|
||||
setImportData({ rawData, type });
|
||||
|
||||
if (type === 'git-repository') {
|
||||
setGitRepositoryUrl(repositoryUrl);
|
||||
setShowCloneGitModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setImportData({ rawData, type, ...rest });
|
||||
setImportCollectionLocationModalOpen(true);
|
||||
};
|
||||
|
||||
const handleImportCollectionLocation = (convertedCollection, collectionLocation, options = {}) => {
|
||||
dispatch(importCollection(convertedCollection, collectionLocation, options))
|
||||
const importAction = options.isZipImport
|
||||
? importCollectionFromZip(convertedCollection.zipFilePath, collectionLocation)
|
||||
: importCollection(convertedCollection, collectionLocation, options);
|
||||
|
||||
dispatch(importAction)
|
||||
.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);
|
||||
};
|
||||
@@ -241,7 +261,7 @@ const CollectionsSection = () => {
|
||||
handleSubmit={handleImportCollection}
|
||||
/>
|
||||
)}
|
||||
{importCollectionLocationModalOpen && importData && (
|
||||
{importCollectionLocationModalOpen && importData && (importData.type !== 'multiple' && importData.type !== 'bulk') && (
|
||||
<ImportCollectionLocation
|
||||
rawData={importData.rawData}
|
||||
format={importData.type}
|
||||
@@ -249,6 +269,20 @@ 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"
|
||||
|
||||
@@ -169,14 +169,21 @@ 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) {
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = String(this.props.value);
|
||||
this.editor.setValue(String(this.props.value ?? ''));
|
||||
this.editor.setCursor(cursor);
|
||||
// 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);
|
||||
|
||||
// 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)) {
|
||||
|
||||
@@ -10,7 +10,6 @@ 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';
|
||||
@@ -18,6 +17,7 @@ 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,6 +28,8 @@ 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 = () => {
|
||||
@@ -35,19 +37,15 @@ const StatusBar = () => {
|
||||
};
|
||||
|
||||
const handlePreferencesClick = () => {
|
||||
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 collectionUid = activeTab?.collectionUid || activeWorkspace?.scratchCollectionUid;
|
||||
|
||||
dispatch(
|
||||
addTab({
|
||||
type: 'preferences',
|
||||
uid: collectionUid ? `${collectionUid}-preferences` : 'preferences',
|
||||
collectionUid: collectionUid
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const openGlobalSearch = () => {
|
||||
|
||||
@@ -4,9 +4,10 @@ import StyledWrapper from './StyledWrapper';
|
||||
import SingleLineEditor from 'components/SingleLineEditor/index';
|
||||
import { useTheme } from 'providers/Theme/index';
|
||||
|
||||
const TagList = ({ tagsHintList = [], handleAddTag, tags, handleRemoveTag, onSave, handleValidation }) => {
|
||||
const TagList = ({ tagsHintList = [], handleAddTag, tags, handleRemoveTag, onSave, handleValidation, collectionFormat }) => {
|
||||
const { displayedTheme } = useTheme();
|
||||
const tagNameRegex = /^[\w-]+$/;
|
||||
const isBruFormat = collectionFormat === 'bru';
|
||||
const tagNameRegex = isBruFormat ? /^[\w-]+$/ : /^[\w-][\w\s-]*[\w-]$|^[\w-]+$/;
|
||||
const [text, setText] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@@ -16,8 +17,14 @@ const TagList = ({ tagsHintList = [], handleAddTag, tags, handleRemoveTag, onSav
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
if (!tagNameRegex.test(text)) {
|
||||
setError('Tags must only contain alpha-numeric characters, "-", "_"');
|
||||
setError(isBruFormat
|
||||
? 'Tags in BRU format must only contain alpha-numeric characters, "-", "_".'
|
||||
: 'Tags must only contain alpha-numeric characters, spaces, "-", "_"'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (tags.includes(text)) {
|
||||
@@ -28,7 +35,6 @@ const TagList = ({ tagsHintList = [], handleAddTag, tags, handleRemoveTag, onSav
|
||||
const error = handleValidation(text);
|
||||
if (error) {
|
||||
setError(error);
|
||||
setText('');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
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;
|
||||
@@ -134,13 +134,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
};
|
||||
|
||||
const handleColorChange = (color) => {
|
||||
dispatch(updateGlobalEnvironmentColor(environment.uid, color))
|
||||
.then(() => {
|
||||
toast.success('Environment color updated!');
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('An error occurred while updating the environment color');
|
||||
});
|
||||
dispatch(updateGlobalEnvironmentColor(environment.uid, color));
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -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 } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { addGlobalEnvironment, renameGlobalEnvironment, selectGlobalEnvironment, setGlobalEnvironmentDraft, clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-environments';
|
||||
import {
|
||||
saveWorkspaceDotEnvVariables,
|
||||
saveWorkspaceDotEnvRaw,
|
||||
@@ -72,9 +72,22 @@ 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;
|
||||
}
|
||||
|
||||
@@ -422,7 +435,7 @@ const EnvironmentList = ({
|
||||
dispatch(deleteWorkspaceDotEnvFile(workspace.uid, filename))
|
||||
.then(() => {
|
||||
toast.success(`${filename} file deleted!`);
|
||||
setIsDotEnvModified(false);
|
||||
handleDotEnvModifiedChange(false);
|
||||
if (selectedDotEnvFile === filename) {
|
||||
const remainingFiles = dotEnvFiles.filter((f) => f.filename !== filename);
|
||||
if (remainingFiles.length > 0) {
|
||||
@@ -465,7 +478,7 @@ const EnvironmentList = ({
|
||||
onSave={handleSaveDotEnv}
|
||||
onSaveRaw={handleSaveDotEnvRaw}
|
||||
isModified={isDotEnvModified}
|
||||
setIsModified={setIsDotEnvModified}
|
||||
setIsModified={handleDotEnvModifiedChange}
|
||||
dotEnvExists={selectedDotEnvData?.exists}
|
||||
viewMode={dotEnvViewMode}
|
||||
/>
|
||||
|
||||
@@ -28,7 +28,14 @@ const CollectionsList = ({ workspace }) => {
|
||||
return [];
|
||||
}
|
||||
|
||||
return workspace.collections.map((wc) => {
|
||||
const filteredCollections = workspace.collections.filter((wc) => {
|
||||
if (workspace.scratchTempDirectory) {
|
||||
return normalizePath(wc.path) !== normalizePath(workspace.scratchTempDirectory);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return filteredCollections.map((wc) => {
|
||||
const loadedCollection = collections.find(
|
||||
(c) => normalizePath(c.pathname) === normalizePath(wc.path)
|
||||
);
|
||||
@@ -64,7 +71,7 @@ const CollectionsList = ({ workspace }) => {
|
||||
}
|
||||
};
|
||||
});
|
||||
}, [workspace.collections, collections]);
|
||||
}, [workspace.collections, workspace.scratchTempDirectory, collections]);
|
||||
|
||||
const handleOpenCollectionClick = (collection, event) => {
|
||||
if (event.target.closest('.collection-menu')) {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { IconPlus, IconFolder, IconDownload } from '@tabler/icons';
|
||||
import { importCollection, openCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { importCollection, openCollection, importCollectionFromZip } 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';
|
||||
@@ -19,6 +21,8 @@ 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;
|
||||
|
||||
@@ -51,25 +55,36 @@ const WorkspaceOverview = ({ workspace }) => {
|
||||
setImportCollectionModalOpen(true);
|
||||
};
|
||||
|
||||
const handleImportCollectionSubmit = ({ rawData, type }) => {
|
||||
const handleImportCollectionSubmit = ({ rawData, type, repositoryUrl, ...rest }) => {
|
||||
setImportCollectionModalOpen(false);
|
||||
setImportData({ rawData, type });
|
||||
|
||||
if (type === 'git-repository') {
|
||||
setGitRepositoryUrl(repositoryUrl);
|
||||
setShowCloneGitModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setImportData({ rawData, type, ...rest });
|
||||
setImportCollectionLocationModalOpen(true);
|
||||
};
|
||||
|
||||
const handleImportCollectionLocation = (convertedCollection, collectionLocation, options = {}) => {
|
||||
dispatch(importCollection(convertedCollection, collectionLocation, options))
|
||||
const importAction = options.isZipImport
|
||||
? importCollectionFromZip(convertedCollection.zipFilePath, collectionLocation)
|
||||
: importCollection(convertedCollection, collectionLocation, options);
|
||||
|
||||
dispatch(importAction)
|
||||
.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 && (
|
||||
@@ -83,7 +98,7 @@ const WorkspaceOverview = ({ workspace }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{importCollectionLocationModalOpen && importData && (
|
||||
{importCollectionLocationModalOpen && importData && (importData.type !== 'multiple' && importData.type !== 'bulk') && (
|
||||
<ImportCollectionLocation
|
||||
rawData={importData.rawData}
|
||||
format={importData.type}
|
||||
@@ -91,6 +106,20 @@ 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">
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
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;
|
||||
@@ -1,197 +0,0 @@
|
||||
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;
|
||||
@@ -1,61 +0,0 @@
|
||||
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;
|
||||
@@ -1,45 +0,0 @@
|
||||
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;
|
||||
@@ -1,158 +0,0 @@
|
||||
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;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { isItemAFolder } from 'utils/collections';
|
||||
import { sortByNameThenSequence } from 'utils/common/index';
|
||||
import filter from 'lodash/filter';
|
||||
@@ -63,6 +63,7 @@ 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 {};
|
||||
@@ -143,6 +144,10 @@ const useCollectionFolderTree = (collectionUid) => {
|
||||
setSelectedFolderUid(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
reset();
|
||||
}, [collectionUid, reset]);
|
||||
|
||||
return {
|
||||
currentFolders,
|
||||
breadcrumbs,
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function useProtoFileManagement(collection) {
|
||||
|
||||
const cachedMethods = protofileCache[absolutePath];
|
||||
if (cachedMethods && !isLoadingMethods && !isManualRefresh) {
|
||||
return { methods: cachedMethods, error: null };
|
||||
return { methods: cachedMethods, error: null, fromCache: true };
|
||||
}
|
||||
|
||||
setIsLoadingMethods(true);
|
||||
@@ -67,7 +67,7 @@ export default function useProtoFileManagement(collection) {
|
||||
[absolutePath]: methods
|
||||
}));
|
||||
|
||||
return { methods, error: null };
|
||||
return { methods, error: null, fromCache: false };
|
||||
} catch (err) {
|
||||
console.error('Error loading gRPC methods:', err);
|
||||
return { methods: [], error: err };
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function useReflectionManagement(item, collectionUid) {
|
||||
|
||||
const cachedMethods = reflectionCache[url];
|
||||
if (!isManualRefresh && cachedMethods && !isLoadingMethods) {
|
||||
return { methods: cachedMethods, error: null };
|
||||
return { methods: cachedMethods, error: null, fromCache: true };
|
||||
}
|
||||
|
||||
setIsLoadingMethods(true);
|
||||
@@ -44,7 +44,7 @@ export default function useReflectionManagement(item, collectionUid) {
|
||||
[url]: methods
|
||||
}));
|
||||
|
||||
return { methods, error: null };
|
||||
return { methods, error: null, fromCache: false };
|
||||
} catch (error) {
|
||||
console.error('Error loading gRPC methods:', error);
|
||||
return { methods: [], error };
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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';
|
||||
@@ -77,7 +76,6 @@ 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);
|
||||
@@ -144,8 +142,6 @@ export default function Main() {
|
||||
<ApiSpecPanel key={activeApiSpecUid} />
|
||||
) : showManageWorkspacePage ? (
|
||||
<ManageWorkspace />
|
||||
) : showHomePage || !activeTabUid ? (
|
||||
<WorkspaceHome />
|
||||
) : (
|
||||
<>
|
||||
<RequestTabs />
|
||||
|
||||
@@ -62,7 +62,6 @@ const SaveRequestsModal = ({ onClose }) => {
|
||||
const requests = filter(items, (item) => isItemARequest(item) && hasRequestChanges(item));
|
||||
each(requests, (draft) => {
|
||||
requestDrafts.push({
|
||||
type: 'request',
|
||||
...draft,
|
||||
collectionUid: collectionUid
|
||||
});
|
||||
@@ -116,7 +115,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) => d.type === 'request');
|
||||
const requestDrafts = allDrafts.filter((d) => isItemARequest(d));
|
||||
const collectionEnvironmentDrafts = allDrafts.filter((d) => d.type === 'collection-environment');
|
||||
const globalEnvironmentDrafts = allDrafts.filter((d) => d.type === 'global-environment');
|
||||
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
updateCookies,
|
||||
updatePreferences
|
||||
updatePreferences,
|
||||
setGitVersion
|
||||
} from 'providers/ReduxStore/slices/app';
|
||||
import {
|
||||
addTab
|
||||
} from 'providers/ReduxStore/slices/tabs';
|
||||
import {
|
||||
setActiveWorkspaceTab
|
||||
} from 'providers/ReduxStore/slices/workspaceTabs';
|
||||
import {
|
||||
brunoConfigUpdateEvent,
|
||||
collectionAddDirectoryEvent,
|
||||
@@ -28,7 +26,10 @@ 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';
|
||||
@@ -274,24 +275,21 @@ const useIpcEvents = () => {
|
||||
const removeShowPreferencesListener = ipcRenderer.on('main:open-preferences', () => {
|
||||
const state = store.getState();
|
||||
const activeWorkspaceUid = state.workspaces?.activeWorkspaceUid;
|
||||
const { showHomePage, showManageWorkspacePage, showApiSpecPage } = state.app;
|
||||
const workspaces = state.workspaces?.workspaces;
|
||||
const tabs = state.tabs?.tabs;
|
||||
const activeTabUid = state.tabs?.activeTabUid;
|
||||
const activeTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
|
||||
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 activeWorkspace = workspaces?.find((w) => w.uid === activeWorkspaceUid);
|
||||
const collectionUid = activeTab?.collectionUid || activeWorkspace?.scratchCollectionUid;
|
||||
|
||||
dispatch(
|
||||
addTab({
|
||||
type: 'preferences',
|
||||
uid: collectionUid ? `${collectionUid}-preferences` : 'preferences',
|
||||
collectionUid
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const removePreferencesUpdatesListener = ipcRenderer.on('main:load-preferences', (val) => {
|
||||
@@ -332,6 +330,10 @@ const useIpcEvents = () => {
|
||||
dispatch(updateCollectionLoadingState(val));
|
||||
});
|
||||
|
||||
const gitVersionListener = ipcRenderer.on('main:git-version', (val) => {
|
||||
dispatch(setGitVersion(val));
|
||||
});
|
||||
|
||||
return () => {
|
||||
removeCollectionTreeUpdateListener();
|
||||
removeApiSpecTreeUpdateListener();
|
||||
@@ -363,6 +365,7 @@ const useIpcEvents = () => {
|
||||
removeCollectionLoadingStateListener();
|
||||
removePersistentEnvVariablesUpdateListener();
|
||||
removeSystemResourcesListener();
|
||||
gitVersionListener();
|
||||
};
|
||||
}, [isElectron]);
|
||||
};
|
||||
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
saveRequest,
|
||||
saveCollectionRoot,
|
||||
saveFolderRoot,
|
||||
saveCollectionSettings
|
||||
saveCollectionSettings,
|
||||
closeTabs
|
||||
} from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
|
||||
import { addTab, closeTabs, reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { closeWorkspaceTab } from 'providers/ReduxStore/slices/workspaceTabs';
|
||||
import { addTab, reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import { getKeyBindingsForActionAllOS } from './keyMappings';
|
||||
|
||||
@@ -26,8 +26,6 @@ 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);
|
||||
|
||||
@@ -174,9 +172,7 @@ export const HotkeysProvider = (props) => {
|
||||
// close tab hotkey
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('closeTab')], (e) => {
|
||||
if (showHomePage && activeWorkspaceTabUid) {
|
||||
dispatch(closeWorkspaceTab({ uid: activeWorkspaceTabUid }));
|
||||
} else if (activeTabUid) {
|
||||
if (activeTabUid) {
|
||||
dispatch(
|
||||
closeTabs({
|
||||
tabUids: [activeTabUid]
|
||||
@@ -190,7 +186,7 @@ export const HotkeysProvider = (props) => {
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('closeTab')]);
|
||||
};
|
||||
}, [activeTabUid, showHomePage, activeWorkspaceTabUid]);
|
||||
}, [activeTabUid]);
|
||||
|
||||
// Switch to the previous tab
|
||||
useEffect(() => {
|
||||
|
||||
@@ -4,7 +4,6 @@ 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';
|
||||
@@ -28,7 +27,6 @@ export const store = configureStore({
|
||||
app: appReducer,
|
||||
collections: collectionsReducer,
|
||||
tabs: tabsReducer,
|
||||
workspaceTabs: workspaceTabsReducer,
|
||||
notifications: notificationsReducer,
|
||||
globalEnvironments: globalEnvironmentsReducer,
|
||||
logs: logsReducer,
|
||||
|
||||
@@ -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, closeTabs, closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { collectionAddFileEvent, collectionChangeFileEvent } from 'providers/ReduxStore/slices/collections';
|
||||
import { findCollectionByUid, findItemInCollectionByPathname, getDefaultRequestPaneTab, findItemInCollectionByItemUid, findItemInCollection, flattenItems } from 'utils/collections/index';
|
||||
import { findCollectionByUid, findItemInCollectionByPathname, getDefaultRequestPaneTab, findItemInCollectionByItemUid } from 'utils/collections/index';
|
||||
import { taskTypes } from './utils';
|
||||
|
||||
const taskMiddleware = createListenerMiddleware();
|
||||
@@ -29,14 +29,13 @@ 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: !isTransient
|
||||
preview: task?.preview ?? true
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -93,39 +92,4 @@ 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;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import filter from 'lodash/filter';
|
||||
import brunoClipboard from 'utils/bruno-clipboard';
|
||||
import { addTab, focusTab, closeTabs } from './tabs';
|
||||
import { addTab, focusTab } from './tabs';
|
||||
|
||||
const initialState = {
|
||||
isDragging: false,
|
||||
@@ -48,6 +48,8 @@ const initialState = {
|
||||
},
|
||||
cookies: [],
|
||||
taskQueue: [],
|
||||
gitOperationProgress: {},
|
||||
gitVersion: null,
|
||||
clipboard: {
|
||||
hasCopiedItems: false // Whether clipboard has Bruno data (for UI)
|
||||
},
|
||||
@@ -123,6 +125,19 @@ 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;
|
||||
@@ -164,6 +179,9 @@ export const {
|
||||
updateSystemProxyVariables,
|
||||
updateGenerateCode,
|
||||
toggleSidebarCollapse,
|
||||
updateGitOperationProgress,
|
||||
removeGitOperationProgress,
|
||||
setGitVersion,
|
||||
setClipboard
|
||||
} = appSlice.actions;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import trim from 'lodash/trim';
|
||||
import path, { normalizePath } from 'utils/common/path';
|
||||
import { insertTaskIntoQueue, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import toast from 'react-hot-toast';
|
||||
import IpcErrorModal from 'components/Errors/IpcErrorModal/index';
|
||||
import {
|
||||
findCollectionByUid,
|
||||
findEnvironmentInCollection,
|
||||
@@ -64,7 +65,7 @@ import {
|
||||
} from './index';
|
||||
|
||||
import { each } from 'lodash';
|
||||
import { closeAllCollectionTabs, updateResponsePaneScrollPosition } from 'providers/ReduxStore/slices/tabs';
|
||||
import { closeAllCollectionTabs, closeTabs as _closeTabs, updateResponsePaneScrollPosition } from 'providers/ReduxStore/slices/tabs';
|
||||
import { removeCollectionFromWorkspace } from 'providers/ReduxStore/slices/workspaces';
|
||||
import { resolveRequestFilename } from 'utils/common/platform';
|
||||
import { interpolateUrl, parsePathParams, splitOnFirst } from 'utils/url/index';
|
||||
@@ -1338,7 +1339,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
|
||||
uid: uuid(),
|
||||
type: 'OPEN_REQUEST',
|
||||
collectionUid,
|
||||
itemPathname: fullName
|
||||
itemPathname: fullName,
|
||||
preview: false
|
||||
})
|
||||
);
|
||||
resolve();
|
||||
@@ -1494,7 +1496,8 @@ export const newGrpcRequest = (params) => (dispatch, getState) => {
|
||||
uid: uuid(),
|
||||
type: 'OPEN_REQUEST',
|
||||
collectionUid,
|
||||
itemPathname: fullName
|
||||
itemPathname: fullName,
|
||||
preview: false
|
||||
})
|
||||
);
|
||||
resolve();
|
||||
@@ -1621,7 +1624,8 @@ export const newWsRequest = (params) => (dispatch, getState) => {
|
||||
uid: uuid(),
|
||||
type: 'OPEN_REQUEST',
|
||||
collectionUid,
|
||||
itemPathname: fullName
|
||||
itemPathname: fullName,
|
||||
preview: false
|
||||
})
|
||||
);
|
||||
resolve();
|
||||
@@ -1770,7 +1774,7 @@ export const addEnvironment = (name, collectionUid) => (dispatch, getState) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const importEnvironment = ({ name, variables, collectionUid }) => (dispatch, getState) => {
|
||||
export const importEnvironment = ({ name, variables, color, collectionUid }) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const state = getState();
|
||||
const collection = findCollectionByUid(state.collections.collections, collectionUid);
|
||||
@@ -1782,7 +1786,7 @@ export const importEnvironment = ({ name, variables, collectionUid }) => (dispat
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:create-environment', collection.pathname, sanitizedName, variables)
|
||||
.invoke('renderer:create-environment', collection.pathname, sanitizedName, variables, color)
|
||||
.then(
|
||||
dispatch(
|
||||
updateLastAction({
|
||||
@@ -2436,6 +2440,53 @@ export const updateBrunoConfig = (brunoConfig, collectionUid) => (dispatch, getS
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens a scratch collection and creates it in Redux state.
|
||||
* This is a simplified version of openCollectionEvent for scratch collections,
|
||||
* without workspace management, toasts, or sidebar toggles.
|
||||
*
|
||||
* @param {string} uid - The unique identifier for the scratch collection
|
||||
* @param {string} pathname - The filesystem path to the scratch collection
|
||||
* @param {Object} brunoConfig - The Bruno configuration object for the collection
|
||||
* @returns {Promise} Resolves when the collection is created, rejects on error
|
||||
*/
|
||||
export const openScratchCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, getState) => {
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const state = getState();
|
||||
const existingCollection = state.collections.collections.find(
|
||||
(c) => normalizePath(c.pathname) === normalizePath(pathname)
|
||||
);
|
||||
|
||||
if (existingCollection) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = {
|
||||
version: '1',
|
||||
uid,
|
||||
name: brunoConfig.name,
|
||||
pathname,
|
||||
items: [],
|
||||
runtimeVariables: {},
|
||||
brunoConfig
|
||||
};
|
||||
|
||||
ipcRenderer
|
||||
.invoke('renderer:get-collection-security-config', pathname)
|
||||
.then((securityConfig) => {
|
||||
collectionSchema
|
||||
.validate(collection)
|
||||
.then(() => dispatch(_createCollection({ ...collection, securityConfig })))
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, getState) => {
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
@@ -2444,24 +2495,20 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
|
||||
const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid);
|
||||
const workspaceProcessEnvVariables = activeWorkspace?.processEnvVariables || {};
|
||||
|
||||
// Check if collection already exists in Redux state
|
||||
const existingCollection = state.collections.collections.find(
|
||||
(c) => normalizePath(c.pathname) === normalizePath(pathname)
|
||||
);
|
||||
|
||||
// Check if collection is already in the current workspace
|
||||
const isAlreadyInWorkspace = activeWorkspace?.collections?.some(
|
||||
(c) => normalizePath(c.path) === normalizePath(pathname)
|
||||
);
|
||||
|
||||
// If collection already exists in Redux AND in current workspace, show toast and return
|
||||
if (existingCollection && isAlreadyInWorkspace) {
|
||||
toast.success('Collection is already opened');
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// If collection exists in Redux but not in workspace, add to workspace
|
||||
if (existingCollection) {
|
||||
if (state.app.sidebarCollapsed) {
|
||||
dispatch(toggleSidebarCollapse());
|
||||
@@ -2490,7 +2537,6 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
|
||||
return;
|
||||
}
|
||||
|
||||
// Collection doesn't exist - create it
|
||||
const collection = {
|
||||
version: '1',
|
||||
uid: uid,
|
||||
@@ -2517,7 +2563,6 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
|
||||
);
|
||||
|
||||
if (currentWorkspace) {
|
||||
// Set collection-workspace mapping for workspace env vars
|
||||
ipcRenderer.invoke('renderer:set-collection-workspace', uid, currentWorkspace.pathname);
|
||||
|
||||
const alreadyInWorkspace = currentWorkspace.collections?.some(
|
||||
@@ -2641,25 +2686,46 @@ export const importCollection = (collection, collectionLocation, options = {}) =
|
||||
try {
|
||||
const state = getState();
|
||||
const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid);
|
||||
const isMultiple = Array.isArray(collection);
|
||||
|
||||
const collectionPath = await ipcRenderer.invoke('renderer:import-collection', collection, collectionLocation, options.format || DEFAULT_COLLECTION_FORMAT);
|
||||
const result = await ipcRenderer.invoke('renderer:import-collection', collection, collectionLocation, options.format || DEFAULT_COLLECTION_FORMAT);
|
||||
const importedPaths = result.success.items;
|
||||
|
||||
if (activeWorkspace && activeWorkspace.pathname && activeWorkspace.type !== 'default') {
|
||||
const workspaceCollection = {
|
||||
name: collection.name,
|
||||
path: collectionPath
|
||||
};
|
||||
|
||||
await ipcRenderer.invoke('renderer:add-collection-to-workspace', activeWorkspace.pathname, workspaceCollection);
|
||||
if (importedPaths.length > 0 && activeWorkspace && activeWorkspace.pathname && activeWorkspace.type !== 'default') {
|
||||
for (const importedItem of importedPaths) {
|
||||
const workspaceCollection = {
|
||||
name: importedItem.name,
|
||||
path: importedItem.path
|
||||
};
|
||||
await ipcRenderer.invoke('renderer:add-collection-to-workspace', activeWorkspace.pathname, workspaceCollection);
|
||||
}
|
||||
}
|
||||
|
||||
resolve(collectionPath);
|
||||
resolve(isMultiple ? importedPaths : importedPaths[0]);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const importCollectionFromZip = (zipFilePath, collectionLocation) => async (dispatch, getState) => {
|
||||
const { ipcRenderer } = window;
|
||||
const state = getState();
|
||||
const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid);
|
||||
|
||||
const collectionPath = await ipcRenderer.invoke('renderer:import-collection-zip', zipFilePath, collectionLocation);
|
||||
|
||||
if (activeWorkspace && activeWorkspace.pathname && activeWorkspace.type !== 'default') {
|
||||
const collectionName = path.basename(collectionPath);
|
||||
await ipcRenderer.invoke('renderer:add-collection-to-workspace', activeWorkspace.pathname, {
|
||||
name: collectionName,
|
||||
path: collectionPath
|
||||
});
|
||||
}
|
||||
|
||||
return collectionPath;
|
||||
};
|
||||
|
||||
export const moveCollectionAndPersist
|
||||
= ({ draggedItem, targetItem }) =>
|
||||
(dispatch, getState) => {
|
||||
@@ -2964,3 +3030,75 @@ export const deleteDotEnvFile = (collectionUid, filename = '.env') => (dispatch,
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
export const cloneGitRepository = (data) => (dispatch, getState) => {
|
||||
const { ipcRenderer } = window;
|
||||
return new Promise((resolve, reject) => {
|
||||
ipcRenderer
|
||||
.invoke('renderer:clone-git-repository', data)
|
||||
.then((res) => {
|
||||
console.log('clone done', res);
|
||||
})
|
||||
.then(resolve)
|
||||
.catch((err) => {
|
||||
toast.custom(<IpcErrorModal error={err?.message} />);
|
||||
reject();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const scanForBrunoFiles = (dir) => (dispatch, getState) => {
|
||||
const { ipcRenderer } = window;
|
||||
return new Promise((resolve, reject) => {
|
||||
ipcRenderer
|
||||
.invoke('renderer:scan-for-bruno-files', dir)
|
||||
.then(resolve)
|
||||
.catch((err) => {
|
||||
reject();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Close tabs and delete any transient request files from the filesystem.
|
||||
* This thunk wraps the closeTabs reducer to handle transient file cleanup automatically.
|
||||
*/
|
||||
export const closeTabs = ({ tabUids }) => async (dispatch, getState) => {
|
||||
const { ipcRenderer } = window;
|
||||
const state = getState();
|
||||
const collections = state.collections.collections;
|
||||
const tempDirectories = state.collections.tempDirectories || {};
|
||||
|
||||
// Find transient items and group by temp directory before closing tabs
|
||||
const transientByTempDir = {};
|
||||
each(tabUids, (tabUid) => {
|
||||
for (const collection of collections) {
|
||||
const item = findItemInCollection(collection, tabUid);
|
||||
if (item?.isTransient && item.pathname) {
|
||||
const tempDir = tempDirectories[collection.uid];
|
||||
if (tempDir) {
|
||||
if (!transientByTempDir[tempDir]) {
|
||||
transientByTempDir[tempDir] = [];
|
||||
}
|
||||
transientByTempDir[tempDir].push(item.pathname);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Close the tabs first
|
||||
await dispatch(_closeTabs({ tabUids }));
|
||||
|
||||
// Delete transient files after tabs are closed
|
||||
for (const [tempDir, filePaths] of Object.entries(transientByTempDir)) {
|
||||
try {
|
||||
const results = await ipcRenderer.invoke('renderer:delete-transient-requests', filePaths, tempDir);
|
||||
if (results.errors?.length > 0) {
|
||||
console.error('Errors deleting transient files:', results.errors);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete transient request files:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2868,6 +2868,7 @@ export const collectionsSlice = createSlice({
|
||||
const prevEphemerals = (existingEnv.variables || []).filter((v) => v.ephemeral);
|
||||
existingEnv.name = environment.name;
|
||||
existingEnv.variables = environment.variables;
|
||||
existingEnv.color = environment.color;
|
||||
/*
|
||||
Apply temporary (ephemeral) values only to variables that actually exist in the file. This prevents deleted temporaries from “popping back” after a save. If a variable is present in the file, we temporarily override the UI value while also remembering the on-disk value in persistedValue for future saves.
|
||||
*/
|
||||
|
||||
@@ -18,12 +18,13 @@ export const globalEnvironmentsSlice = createSlice({
|
||||
state.activeGlobalEnvironmentUid = action.payload?.activeGlobalEnvironmentUid;
|
||||
},
|
||||
_addGlobalEnvironment: (state, action) => {
|
||||
const { name, uid, variables = [] } = action.payload;
|
||||
const { name, uid, variables = [], color } = action.payload;
|
||||
if (name?.length) {
|
||||
state.globalEnvironments.push({
|
||||
uid,
|
||||
name,
|
||||
variables
|
||||
variables,
|
||||
color
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -110,7 +111,7 @@ const getWorkspaceContext = (state) => {
|
||||
return { workspaceUid, workspacePath: workspace?.pathname };
|
||||
};
|
||||
|
||||
export const addGlobalEnvironment = ({ name, variables = [] }) => (dispatch, getState) => {
|
||||
export const addGlobalEnvironment = ({ name, variables = [], color }) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const uid = uuid();
|
||||
const environment = { name, uid, variables };
|
||||
@@ -120,12 +121,13 @@ export const addGlobalEnvironment = ({ name, variables = [] }) => (dispatch, get
|
||||
|
||||
environmentSchema
|
||||
.validate(environment)
|
||||
.then(() => ipcRenderer.invoke('renderer:create-global-environment', { name, uid, variables, workspaceUid, workspacePath }))
|
||||
.then(() => ipcRenderer.invoke('renderer:create-global-environment', { name, uid, variables, color, workspaceUid, workspacePath }))
|
||||
.then((result) => {
|
||||
const finalUid = result?.uid || uid;
|
||||
const finalName = result?.name || name;
|
||||
const finalVariables = result?.variables || variables;
|
||||
dispatch(_addGlobalEnvironment({ name: finalName, uid: finalUid, variables: finalVariables }));
|
||||
const finalColor = result?.color || color;
|
||||
dispatch(_addGlobalEnvironment({ name: finalName, uid: finalUid, variables: finalVariables, color: finalColor }));
|
||||
return finalUid;
|
||||
})
|
||||
.then((finalUid) => dispatch(selectGlobalEnvironment({ environmentUid: finalUid })))
|
||||
@@ -181,6 +183,13 @@ export const renameGlobalEnvironment = ({ name: newName, environmentUid }) => (d
|
||||
.invoke('renderer:get-global-environments', { workspaceUid, workspacePath })
|
||||
.then((data) => {
|
||||
dispatch(updateGlobalEnvironments(data));
|
||||
if (resolvedUid !== environmentUid) {
|
||||
const currentState = getState();
|
||||
const draft = currentState.globalEnvironments.globalEnvironmentDraft;
|
||||
if (draft?.environmentUid === environmentUid) {
|
||||
dispatch(setGlobalEnvironmentDraft({ environmentUid: resolvedUid, variables: draft.variables }));
|
||||
}
|
||||
}
|
||||
return resolvedUid;
|
||||
});
|
||||
})
|
||||
|
||||
@@ -26,7 +26,9 @@ export const tabsSlice = createSlice({
|
||||
'collection-runner',
|
||||
'environment-settings',
|
||||
'global-environment-settings',
|
||||
'preferences'
|
||||
'preferences',
|
||||
'workspaceOverview',
|
||||
'workspaceEnvironments'
|
||||
];
|
||||
|
||||
const existingTab = find(state.tabs, (tab) => tab.uid === uid);
|
||||
@@ -94,7 +96,11 @@ export const tabsSlice = createSlice({
|
||||
state.activeTabUid = uid;
|
||||
},
|
||||
focusTab: (state, action) => {
|
||||
state.activeTabUid = action.payload.uid;
|
||||
const { uid } = action.payload;
|
||||
const tabExists = state.tabs.some((t) => t.uid === uid);
|
||||
if (tabExists) {
|
||||
state.activeTabUid = uid;
|
||||
}
|
||||
},
|
||||
switchTab: (state, action) => {
|
||||
if (!state.tabs || !state.tabs.length) {
|
||||
@@ -169,8 +175,10 @@ export const tabsSlice = createSlice({
|
||||
const activeTab = find(state.tabs, (t) => t.uid === state.activeTabUid);
|
||||
const tabUids = action.payload.tabUids || [];
|
||||
|
||||
// remove the tabs from the state
|
||||
state.tabs = filter(state.tabs, (t) => !tabUids.includes(t.uid));
|
||||
const nonClosableTypes = ['workspaceOverview', 'workspaceEnvironments'];
|
||||
state.tabs = filter(state.tabs, (t) =>
|
||||
!tabUids.includes(t.uid) || nonClosableTypes.includes(t.type)
|
||||
);
|
||||
|
||||
if (activeTab && state.tabs.length) {
|
||||
const { collectionUid } = activeTab;
|
||||
@@ -197,9 +205,14 @@ export const tabsSlice = createSlice({
|
||||
}
|
||||
},
|
||||
closeAllCollectionTabs: (state, action) => {
|
||||
const collectionUid = action.payload.collectionUid;
|
||||
const { collectionUid } = action.payload;
|
||||
const prevActiveTabUid = state.activeTabUid;
|
||||
state.tabs = filter(state.tabs, (t) => t.collectionUid !== collectionUid);
|
||||
state.activeTabUid = null;
|
||||
|
||||
const activeTabStillExists = state.tabs.some((t) => t.uid === prevActiveTabUid);
|
||||
if (!activeTabStillExists) {
|
||||
state.activeTabUid = state.tabs.length > 0 ? last(state.tabs).uid : null;
|
||||
}
|
||||
},
|
||||
makeTabPermanent: (state, action) => {
|
||||
const { uid } = action.payload;
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import filter from 'lodash/filter';
|
||||
import find from 'lodash/find';
|
||||
import last from 'lodash/last';
|
||||
|
||||
const initialState = {
|
||||
tabs: [],
|
||||
activeTabUid: null
|
||||
};
|
||||
|
||||
export const workspaceTabsSlice = createSlice({
|
||||
name: 'workspaceTabs',
|
||||
initialState,
|
||||
reducers: {
|
||||
addWorkspaceTab: (state, action) => {
|
||||
const { uid, workspaceUid, type, label, permanent = false } = action.payload;
|
||||
|
||||
const existingTab = find(state.tabs, (tab) => tab.uid === uid);
|
||||
if (existingTab) {
|
||||
state.activeTabUid = existingTab.uid;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if a tab of the same type already exists for this workspace
|
||||
const existingTypeTab = find(
|
||||
state.tabs,
|
||||
(tab) => tab.workspaceUid === workspaceUid && tab.type === type
|
||||
);
|
||||
if (existingTypeTab) {
|
||||
state.activeTabUid = existingTypeTab.uid;
|
||||
return;
|
||||
}
|
||||
|
||||
state.tabs.push({
|
||||
uid,
|
||||
workspaceUid,
|
||||
type,
|
||||
label,
|
||||
permanent
|
||||
});
|
||||
state.activeTabUid = uid;
|
||||
},
|
||||
focusWorkspaceTab: (state, action) => {
|
||||
state.activeTabUid = action.payload.uid;
|
||||
},
|
||||
closeWorkspaceTab: (state, action) => {
|
||||
const tabUid = action.payload.uid;
|
||||
const tab = find(state.tabs, (t) => t.uid === tabUid);
|
||||
|
||||
// Don't allow closing permanent tabs
|
||||
if (tab?.permanent) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.tabs = filter(state.tabs, (t) => t.uid !== tabUid);
|
||||
|
||||
// If we closed the active tab, activate another one
|
||||
if (state.activeTabUid === tabUid && state.tabs.length > 0) {
|
||||
state.activeTabUid = last(state.tabs).uid;
|
||||
} else if (state.tabs.length === 0) {
|
||||
state.activeTabUid = null;
|
||||
}
|
||||
},
|
||||
closeWorkspaceTabs: (state, action) => {
|
||||
const tabUids = action.payload.tabUids || [];
|
||||
|
||||
// Filter out permanent tabs from the close request
|
||||
const tabsToClose = tabUids.filter((uid) => {
|
||||
const tab = find(state.tabs, (t) => t.uid === uid);
|
||||
return tab && !tab.permanent;
|
||||
});
|
||||
|
||||
state.tabs = filter(state.tabs, (t) => !tabsToClose.includes(t.uid));
|
||||
|
||||
// If active tab was closed, activate another one
|
||||
if (tabsToClose.includes(state.activeTabUid)) {
|
||||
if (state.tabs.length > 0) {
|
||||
state.activeTabUid = last(state.tabs).uid;
|
||||
} else {
|
||||
state.activeTabUid = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
closeAllWorkspaceTabs: (state, action) => {
|
||||
const workspaceUid = action.payload?.workspaceUid;
|
||||
|
||||
if (workspaceUid) {
|
||||
// Close non-permanent tabs for specific workspace
|
||||
state.tabs = filter(
|
||||
state.tabs,
|
||||
(t) => t.workspaceUid !== workspaceUid || t.permanent
|
||||
);
|
||||
} else {
|
||||
// Close all non-permanent tabs
|
||||
state.tabs = filter(state.tabs, (t) => t.permanent);
|
||||
}
|
||||
|
||||
// If active tab was closed, activate another one
|
||||
const activeTabExists = find(state.tabs, (t) => t.uid === state.activeTabUid);
|
||||
if (!activeTabExists) {
|
||||
state.activeTabUid = state.tabs.length > 0 ? last(state.tabs).uid : null;
|
||||
}
|
||||
},
|
||||
reorderWorkspaceTabs: (state, action) => {
|
||||
const { sourceUid, targetUid } = action.payload;
|
||||
const tabs = state.tabs;
|
||||
|
||||
const sourceIdx = tabs.findIndex((t) => t.uid === sourceUid);
|
||||
const targetIdx = tabs.findIndex((t) => t.uid === targetUid);
|
||||
|
||||
// Don't reorder permanent tabs
|
||||
const sourceTab = tabs[sourceIdx];
|
||||
if (sourceTab?.permanent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceIdx < 0 || targetIdx < 0 || sourceIdx === targetIdx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [moved] = tabs.splice(sourceIdx, 1);
|
||||
tabs.splice(targetIdx, 0, moved);
|
||||
|
||||
state.tabs = tabs;
|
||||
},
|
||||
initializeWorkspaceTabs: (state, action) => {
|
||||
const { workspaceUid, permanentTabs } = action.payload;
|
||||
|
||||
// Check if permanent tabs already exist for this workspace
|
||||
const existingPermanentTabs = state.tabs.filter(
|
||||
(t) => t.workspaceUid === workspaceUid && t.permanent
|
||||
);
|
||||
|
||||
if (existingPermanentTabs.length === 0) {
|
||||
// Add permanent tabs
|
||||
permanentTabs.forEach((tab) => {
|
||||
state.tabs.push({
|
||||
uid: `${workspaceUid}-${tab.type}`,
|
||||
workspaceUid,
|
||||
type: tab.type,
|
||||
label: tab.label,
|
||||
permanent: true
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const workspaceActiveTab = state.tabs.find(
|
||||
(t) => t.uid === state.activeTabUid && t.workspaceUid === workspaceUid
|
||||
);
|
||||
|
||||
if (!workspaceActiveTab) {
|
||||
const workspaceTabs = state.tabs.filter((t) => t.workspaceUid === workspaceUid);
|
||||
if (workspaceTabs.length > 0) {
|
||||
state.activeTabUid = workspaceTabs[0].uid;
|
||||
}
|
||||
}
|
||||
},
|
||||
setActiveWorkspaceTab: (state, action) => {
|
||||
const { workspaceUid, type } = action.payload;
|
||||
let tab = find(
|
||||
state.tabs,
|
||||
(t) => t.workspaceUid === workspaceUid && t.type === type
|
||||
);
|
||||
|
||||
if (!tab) {
|
||||
const newTabUid = `${workspaceUid}-${type}`;
|
||||
const labels = {
|
||||
overview: 'Overview',
|
||||
environments: 'Global Environments',
|
||||
preferences: 'Preferences'
|
||||
};
|
||||
const newTab = {
|
||||
uid: newTabUid,
|
||||
workspaceUid,
|
||||
type,
|
||||
label: labels[type] || type,
|
||||
permanent: false
|
||||
};
|
||||
state.tabs.push(newTab);
|
||||
tab = newTab;
|
||||
}
|
||||
|
||||
state.activeTabUid = tab.uid;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const {
|
||||
addWorkspaceTab,
|
||||
focusWorkspaceTab,
|
||||
closeWorkspaceTab,
|
||||
closeWorkspaceTabs,
|
||||
closeAllWorkspaceTabs,
|
||||
reorderWorkspaceTabs,
|
||||
initializeWorkspaceTabs,
|
||||
setActiveWorkspaceTab
|
||||
} = workspaceTabsSlice.actions;
|
||||
|
||||
export default workspaceTabsSlice.reducer;
|
||||
@@ -6,13 +6,14 @@ import {
|
||||
updateWorkspace,
|
||||
addCollectionToWorkspace,
|
||||
removeCollectionFromWorkspace,
|
||||
updateWorkspaceLoadingState
|
||||
updateWorkspaceLoadingState,
|
||||
setWorkspaceScratchCollection
|
||||
} from '../workspaces';
|
||||
import { showHomePage } from '../app';
|
||||
import { createCollection, openCollection, openMultipleCollections } from '../collections/actions';
|
||||
import { removeCollection } from '../collections';
|
||||
import { createCollection, openCollection, openMultipleCollections, openScratchCollectionEvent } from '../collections/actions';
|
||||
import { removeCollection, addTransientDirectory, updateCollectionMountStatus } from '../collections';
|
||||
import { updateGlobalEnvironments } from '../global-environments';
|
||||
import { initializeWorkspaceTabs, setActiveWorkspaceTab } from '../workspaceTabs';
|
||||
import { addTab, focusTab } from '../tabs';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -262,15 +263,29 @@ export const switchWorkspace = (workspaceUid) => {
|
||||
dispatch(updateGlobalEnvironments({ globalEnvironments: [], activeGlobalEnvironmentUid: null }));
|
||||
}
|
||||
|
||||
const scratchCollection = await dispatch(mountScratchCollection(workspaceUid));
|
||||
await loadWorkspaceCollectionsForSwitch(dispatch, workspace);
|
||||
dispatch(showHomePage());
|
||||
|
||||
const permanentTabs = [
|
||||
{ type: 'overview', label: 'Overview' },
|
||||
{ type: 'environments', label: 'Global Environments' }
|
||||
];
|
||||
dispatch(initializeWorkspaceTabs({ workspaceUid, permanentTabs }));
|
||||
dispatch(setActiveWorkspaceTab({ workspaceUid, type: 'overview' }));
|
||||
if (scratchCollection?.uid) {
|
||||
const overviewTabUid = `${scratchCollection.uid}-overview`;
|
||||
const environmentsTabUid = `${scratchCollection.uid}-environments`;
|
||||
|
||||
dispatch(addTab({
|
||||
uid: overviewTabUid,
|
||||
collectionUid: scratchCollection.uid,
|
||||
type: 'workspaceOverview'
|
||||
}));
|
||||
|
||||
dispatch(addTab({
|
||||
uid: environmentsTabUid,
|
||||
collectionUid: scratchCollection.uid,
|
||||
type: 'workspaceEnvironments'
|
||||
}));
|
||||
|
||||
dispatch(focusTab({
|
||||
uid: overviewTabUid
|
||||
}));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -840,3 +855,88 @@ export const deleteWorkspaceDotEnvFile = (workspaceUid, filename = '.env') => (d
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
// Scratch Collection Actions
|
||||
|
||||
/**
|
||||
* Get the scratch collection for a workspace
|
||||
*/
|
||||
export const getScratchCollection = (workspaceUid) => {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const workspace = state.workspaces.workspaces.find((w) => w.uid === workspaceUid);
|
||||
if (!workspace?.scratchCollectionUid) {
|
||||
return null;
|
||||
}
|
||||
return state.collections.collections.find((c) => c.uid === workspace.scratchCollectionUid);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Mount scratch collection for a workspace
|
||||
*/
|
||||
export const mountScratchCollection = (workspaceUid) => {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const workspace = state.workspaces.workspaces.find((w) => w.uid === workspaceUid);
|
||||
|
||||
if (!workspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (workspace.scratchCollectionUid) {
|
||||
const existingCollection = state.collections.collections.find(
|
||||
(c) => c.uid === workspace.scratchCollectionUid
|
||||
);
|
||||
if (existingCollection) {
|
||||
return existingCollection;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const tempDirectoryPath = await ipcRenderer.invoke('renderer:mount-workspace-scratch', {
|
||||
workspaceUid,
|
||||
workspacePath: workspace.pathname || 'default'
|
||||
});
|
||||
|
||||
const { generateUidBasedOnHash } = await import('utils/common');
|
||||
const scratchCollectionUid = generateUidBasedOnHash(tempDirectoryPath);
|
||||
|
||||
const brunoConfig = {
|
||||
opencollection: '1.0.0',
|
||||
name: 'Scratch',
|
||||
type: 'collection',
|
||||
ignore: ['node_modules', '.git']
|
||||
};
|
||||
|
||||
await ipcRenderer.invoke('renderer:add-collection-watcher', {
|
||||
collectionPath: tempDirectoryPath,
|
||||
collectionUid: scratchCollectionUid,
|
||||
brunoConfig
|
||||
});
|
||||
|
||||
await dispatch(openScratchCollectionEvent(scratchCollectionUid, tempDirectoryPath, brunoConfig));
|
||||
|
||||
dispatch(setWorkspaceScratchCollection({
|
||||
workspaceUid,
|
||||
scratchCollectionUid,
|
||||
scratchTempDirectory: tempDirectoryPath
|
||||
}));
|
||||
|
||||
dispatch(addTransientDirectory({
|
||||
collectionUid: scratchCollectionUid,
|
||||
pathname: tempDirectoryPath
|
||||
}));
|
||||
|
||||
dispatch(updateCollectionMountStatus({ collectionUid: scratchCollectionUid, mountStatus: 'mounted' }));
|
||||
|
||||
return { uid: scratchCollectionUid, pathname: tempDirectoryPath };
|
||||
} catch (error) {
|
||||
console.error('Error mounting scratch collection:', error);
|
||||
if (workspace.scratchCollectionUid) {
|
||||
dispatch(updateCollectionMountStatus({ collectionUid: workspace.scratchCollectionUid, mountStatus: 'unmounted' }));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -116,6 +116,16 @@ export const workspacesSlice = createSlice({
|
||||
workspace.dotEnvVariables = mainEnvFile?.variables || [];
|
||||
workspace.dotEnvExists = mainEnvFile?.exists || false;
|
||||
}
|
||||
},
|
||||
|
||||
// Set scratch collection info on workspace
|
||||
setWorkspaceScratchCollection: (state, action) => {
|
||||
const { workspaceUid, scratchCollectionUid, scratchTempDirectory } = action.payload;
|
||||
const workspace = state.workspaces.find((w) => w.uid === workspaceUid);
|
||||
if (workspace) {
|
||||
workspace.scratchCollectionUid = scratchCollectionUid;
|
||||
workspace.scratchTempDirectory = scratchTempDirectory;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -129,7 +139,8 @@ export const {
|
||||
removeCollectionFromWorkspace,
|
||||
updateWorkspaceLoadingState,
|
||||
workspaceDotEnvUpdateEvent,
|
||||
setWorkspaceDotEnvVariables
|
||||
setWorkspaceDotEnvVariables,
|
||||
setWorkspaceScratchCollection
|
||||
} = workspacesSlice.actions;
|
||||
|
||||
export default workspacesSlice.reducer;
|
||||
|
||||
@@ -398,14 +398,14 @@ const lightPastelTheme = {
|
||||
},
|
||||
|
||||
codemirror: {
|
||||
bg: 'transparent',
|
||||
bg: colors.BACKGROUND,
|
||||
border: colors.WHITE,
|
||||
placeholder: {
|
||||
color: colors.GRAY_6,
|
||||
opacity: 0.75
|
||||
},
|
||||
gutter: {
|
||||
bg: 'transparent'
|
||||
bg: colors.BACKGROUND
|
||||
},
|
||||
variable: {
|
||||
valid: colors.GREEN,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user