feat: move import collection from git url and spec url from enterprise edition to opensource (#7127)

* feat: move import collection from git url and spec url from enterprise edition to opensource

* fix: corrected a typo

* test: add unit and e2e tests for import collection migration

* fix: guard against missing userAgentData platform in getOSName — Default platform to '' to prevent TypeError when navigator.userAgentData is unavailable (GitNotFoundModal/index.js)

fix: UID mismatch between status tracking and UI rendering in bulk import — Preserve synthetic file-${index} UID on converted collections so initialStatus, rename tracking, and the render loop all use the same key (BulkImportCollectionLocation/index.js)

fix: isConfirmDisabled returning non-boolean value — Changed .length checks to explicit comparisons (> 0, === 0) so the function always returns true/false (CloneGitRespository/index.js)

fix: missing ipcRenderer declaration in cloneGitRepository and scanForBrunoFiles — Added const { ipcRenderer } = window; to both actions to prevent ReferenceError at runtime (collections/actions.js)

fix: use strict equality in filterItemsInCollection — Changed == to === for item.name and item.type comparisons (importers/common.js)

fix: variable shadowing in transformItemsInCollection and hydrateSeqInCollection — Renamed forEach callback parameter from collection to col to avoid shadowing the outer parameter (importers/common.js)

fix: scanForBrunoFiles traversing node_modules and .git directories — Added exclusion for node_modules and .git to match getCollectionStats pattern, preventing app freezes on large repos (filesystem.js)

fix: diff hunk header using string character count instead of line count — Preserved prefixedLines array to compute lineCount before joining, so the @@ header has the correct line count (git.js)

fix: test locators not scoped to modal in bulk import e2e test — Changed page.getByTestId to bulkImportModal.getByTestId for grouping dropdown interactions (002-all-collection-types.spec.ts)

fix: missing afterEach cleanup in GitHub repository import test — Added closeAllCollections hook to match sibling test specs, replaced unused dotenv/config import (github-repository-import.spec.ts)

* fix: batch name tracking and git utility fixes

- Fix usedNamesInBatch tracking original name instead of final name, which
  could produce duplicate environment names within the same batch
  (BulkImportCollectionLocation/index.js)

- Remove unused lodash import (git.js)

- Add missing early return in fetchRemotes when gitRootPath is falsy,
  preventing getSimpleGitInstanceForPath from running with undefined (git.js)

* fix: correct variable naming and state management in CloneGitRepository component

- Renamed `collectionpaths` to `collectionPaths` for consistency and clarity.
- Updated references throughout the component to use the corrected variable name.
- Removed error toast notification to streamline error handling during repository cloning.
This commit is contained in:
Abhishek S Lal
2026-02-13 19:35:23 +05:30
committed by GitHub
parent 4e1123bd2d
commit e000e377d1
41 changed files with 4798 additions and 153 deletions

1
.gitignore vendored
View File

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

132
package-lock.json generated
View File

@@ -23,7 +23,8 @@
"packages/bruno-filestore"
],
"dependencies": {
"ajv": "^8.17.1"
"ajv": "^8.17.1",
"git-url-parse": "^14.1.0"
},
"devDependencies": {
"@eslint/compat": "^1.3.2",
@@ -5521,6 +5522,44 @@
"jsep": "^0.4.0||^1.0.0"
}
},
"node_modules/@kwsites/file-exists": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
"integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==",
"license": "MIT",
"dependencies": {
"debug": "^4.1.1"
}
},
"node_modules/@kwsites/file-exists/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@kwsites/file-exists/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/@kwsites/promise-deferred": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz",
"integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==",
"license": "MIT"
},
"node_modules/@lydell/node-pty": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.1.0.tgz",
@@ -17363,6 +17402,25 @@
"node": ">=6.0"
}
},
"node_modules/git-up": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/git-up/-/git-up-7.0.0.tgz",
"integrity": "sha512-ONdIrbBCFusq1Oy0sC71F5azx8bVkvtZtMJAsv+a6lz5YAmbNnLD6HAB4gptHZVLPR8S2/kVN6Gab7lryq5+lQ==",
"license": "MIT",
"dependencies": {
"is-ssh": "^1.4.0",
"parse-url": "^8.1.0"
}
},
"node_modules/git-url-parse": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/git-url-parse/-/git-url-parse-14.1.0.tgz",
"integrity": "sha512-8xg65dTxGHST3+zGpycMMFZcoTzAdZ2dOtu4vmgIfkTFnVHBxHMzBC2L1k8To7EmrSiHesT8JgPLT91VKw1B5g==",
"license": "MIT",
"dependencies": {
"git-up": "^7.0.0"
}
},
"node_modules/github-markdown-css": {
"version": "5.8.1",
"resolved": "https://registry.npmjs.org/github-markdown-css/-/github-markdown-css-5.8.1.tgz",
@@ -18903,6 +18961,15 @@
"node": ">=0.10.0"
}
},
"node_modules/is-ssh": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/is-ssh/-/is-ssh-1.4.1.tgz",
"integrity": "sha512-JNeu1wQsHjyHgn9NcWTaXq6zWSR6hqE0++zhfZlkFBbScNkyvxCdeV8sRkSBaeLKxmbpR21brail63ACNxJ0Tg==",
"license": "MIT",
"dependencies": {
"protocols": "^2.0.1"
}
},
"node_modules/is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
@@ -22427,6 +22494,24 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse-path": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/parse-path/-/parse-path-7.1.0.tgz",
"integrity": "sha512-EuCycjZtfPcjWk7KTksnJ5xPMvWGA/6i4zrLYhRG0hGvC3GPU/jGUj3Cy+ZR0v30duV3e23R95T1lE2+lsndSw==",
"license": "MIT",
"dependencies": {
"protocols": "^2.0.0"
}
},
"node_modules/parse-url": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/parse-url/-/parse-url-8.1.0.tgz",
"integrity": "sha512-xDvOoLU5XRrcOZvnI6b8zA6n9O9ejNk/GExuz1yBuWUGn9KA97GI6HTs6u02wKara1CeVmZhH+0TZFdWScR89w==",
"license": "MIT",
"dependencies": {
"parse-path": "^7.0.0"
}
},
"node_modules/parse5": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
@@ -23990,6 +24075,12 @@
"node": ">=12.0.0"
}
},
"node_modules/protocols": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/protocols/-/protocols-2.0.2.tgz",
"integrity": "sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -26752,6 +26843,44 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/simple-git": {
"version": "3.30.0",
"resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.30.0.tgz",
"integrity": "sha512-q6lxyDsCmEal/MEGhP1aVyQ3oxnagGlBDOVSIB4XUVLl1iZh0Pah6ebC9V4xBap/RfgP2WlI8EKs0WS0rMEJHg==",
"license": "MIT",
"dependencies": {
"@kwsites/file-exists": "^1.1.1",
"@kwsites/promise-deferred": "^1.1.1",
"debug": "^4.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/steveukx/git-js?sponsor=1"
}
},
"node_modules/simple-git/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/simple-git/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/simple-update-notifier": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
@@ -33329,6 +33458,7 @@
"mime-types": "^2.1.35",
"nanoid": "3.3.8",
"qs": "^6.14.1",
"simple-git": "^3.22.0",
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^6.0.0",
"uuid": "^9.0.0",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -92,6 +92,53 @@ const FileTab = ({
}
};
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 {
@@ -149,7 +196,10 @@ const FileTab = ({
return;
}
if (fileArray.length > 0) {
if (fileArray.length > 1) {
// Process multiple non-ZIP files normally
await handleMultipleFiles(fileArray);
} else if (fileArray.length === 1) {
await processFile(fileArray[0]);
}
};
@@ -200,17 +250,18 @@ const FileTab = ({
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 to import or{' '}
Drop file(s) to import or{' '}
<button
className="underline cursor-pointer"
onClick={handleBrowseFiles}
style={{ color: theme.textLink }}
>
choose a file
choose file(s)
</button>
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 text-center">

View File

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

View File

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

View File

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

View File

@@ -1,41 +1,92 @@
import React, { useState } from 'react';
import { IconX } from '@tabler/icons';
import { IconFileImport, IconBrandGit, IconUnlink, IconX } from '@tabler/icons';
import Modal from 'components/Modal';
import classnames from 'classnames';
import StyledWrapper from './StyledWrapper';
import FileTab from './FileTab';
import GitHubTab from './GitHubTab';
import UrlTab from './UrlTab';
import FullscreenLoader from './FullscreenLoader/index';
import { useTheme } from 'providers/Theme';
const IMPORT_TABS = {
FILE: 'file',
GITHUB: 'github',
URL: 'url'
};
const ImportCollection = ({ onClose, handleSubmit }) => {
const { theme } = useTheme();
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [tab, setTab] = useState(IMPORT_TABS.FILE);
const handleTabSelect = (value) => () => {
setTab(value);
setErrorMessage('');
};
const getTabClassname = (tabName) => {
return classnames(`flex tab items-center py-2 px-4 ${tabName}`, {
active: tabName === tab
});
};
if (isLoading) {
return <FullscreenLoader isLoading={isLoading} />;
}
return (
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose} dataTestId="import-collection-modal">
<div className="flex flex-col">
<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>
{errorMessage && (
<div
className="mb-4 p-2 border rounded-md"
style={{
backgroundColor: theme.status?.danger?.background || '#fef2f2',
borderColor: theme.status?.danger?.border || '#fecaca'
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 || '#dc2626' }}
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 || '#dc2626' }}
style={{ color: theme.status.danger.text }}
>
<IconX size={16} strokeWidth={1.5} />
</div>
@@ -43,12 +94,27 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
</div>
)}
<FileTab
setIsLoading={setIsLoading}
handleSubmit={handleSubmit}
setErrorMessage={setErrorMessage}
/>
</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>
);
};

View File

@@ -24,6 +24,8 @@ 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';
@@ -45,6 +47,8 @@ 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 [];
@@ -57,8 +61,15 @@ const CollectionsSection = () => {
});
}, [activeWorkspace, collections, workspaces]);
const handleImportCollection = ({ rawData, type, ...rest }) => {
const handleImportCollection = ({ rawData, type, repositoryUrl, ...rest }) => {
setImportCollectionModalOpen(false);
if (type === 'git-repository') {
setGitRepositoryUrl(repositoryUrl);
setShowCloneGitModal(true);
return;
}
setImportData({ rawData, type, ...rest });
setImportCollectionLocationModalOpen(true);
};
@@ -72,14 +83,14 @@ const CollectionsSection = () => {
.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);
};
@@ -250,7 +261,7 @@ const CollectionsSection = () => {
handleSubmit={handleImportCollection}
/>
)}
{importCollectionLocationModalOpen && importData && (
{importCollectionLocationModalOpen && importData && (importData.type !== 'multiple' && importData.type !== 'bulk') && (
<ImportCollectionLocation
rawData={importData.rawData}
format={importData.type}
@@ -258,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"

View File

@@ -6,6 +6,8 @@ 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,8 +55,15 @@ const WorkspaceOverview = ({ workspace }) => {
setImportCollectionModalOpen(true);
};
const handleImportCollectionSubmit = ({ rawData, type, ...rest }) => {
const handleImportCollectionSubmit = ({ rawData, type, repositoryUrl, ...rest }) => {
setImportCollectionModalOpen(false);
if (type === 'git-repository') {
setGitRepositoryUrl(repositoryUrl);
setShowCloneGitModal(true);
return;
}
setImportData({ rawData, type, ...rest });
setImportCollectionLocationModalOpen(true);
};
@@ -66,14 +77,14 @@ const WorkspaceOverview = ({ workspace }) => {
.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 && (
@@ -87,7 +98,7 @@ const WorkspaceOverview = ({ workspace }) => {
/>
)}
{importCollectionLocationModalOpen && importData && (
{importCollectionLocationModalOpen && importData && (importData.type !== 'multiple' && importData.type !== 'bulk') && (
<ImportCollectionLocation
rawData={importData.rawData}
format={importData.type}
@@ -95,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">

View File

@@ -1,7 +1,8 @@
import { useEffect } from 'react';
import {
updateCookies,
updatePreferences
updatePreferences,
setGitVersion
} from 'providers/ReduxStore/slices/app';
import {
addTab
@@ -329,6 +330,10 @@ const useIpcEvents = () => {
dispatch(updateCollectionLoadingState(val));
});
const gitVersionListener = ipcRenderer.on('main:git-version', (val) => {
dispatch(setGitVersion(val));
});
return () => {
removeCollectionTreeUpdateListener();
removeApiSpecTreeUpdateListener();
@@ -360,6 +365,7 @@ const useIpcEvents = () => {
removeCollectionLoadingStateListener();
removePersistentEnvVariablesUpdateListener();
removeSystemResourcesListener();
gitVersionListener();
};
}, [isElectron]);
};

View File

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

View File

@@ -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,
@@ -2685,19 +2686,22 @@ 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);
}
@@ -3027,6 +3031,34 @@ export const deleteDotEnvFile = (collectionUid, filename = '.env') => (dispatch,
});
};
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.

View File

@@ -0,0 +1,63 @@
import gitUrlParse from 'git-url-parse';
const isGitUrl = (str) => {
try {
const parsed = gitUrlParse(str);
if (!parsed) {
return false;
}
// Validate that it has the essential parts of a git URL and uses valid protocols
const validProtocols = ['git', 'ssh', 'http', 'https'];
return !!(
parsed
&& parsed.owner
&& parsed.source
&& validProtocols.includes(parsed.protocol)
);
} catch (error) {
return false;
}
};
export const getRepoNameFromUrl = (url) => {
try {
const parsedUrl = gitUrlParse(url);
return parsedUrl.name;
} catch (error) {
throw new Error('Invalid Git URL');
}
};
export const containsGitHubToken = (remoteUrl) => {
const GITHUB_TOKEN_REGEX = /(ghp_|gho_|ghu_|ghs_|ghr_)[A-Za-z0-9_]{30,}/;
return GITHUB_TOKEN_REGEX.test(remoteUrl);
};
export const getSafeGitRemoteUrls = (remotes = []) => {
const remoteUrls = remotes
?.map((remote) => remote?.refs?.fetch)
?.filter((url) => typeof url === 'string' && url?.trim()?.length > 0);
const safeRemoteUrls = remoteUrls
?.filter((remoteUrl) => !containsGitHubToken(remoteUrl));
return safeRemoteUrls || [];
};
export const isGitRepositoryUrl = (url) => {
try {
if (!url || typeof url !== 'string') {
return false;
}
// First try the URL as-is
if (isGitUrl(url)) {
return true;
}
return false;
} catch {
return false;
}
};

View File

@@ -0,0 +1,112 @@
import { containsGitHubToken, getSafeGitRemoteUrls, isGitRepositoryUrl } from './index';
describe('containsGitHubToken', () => {
test('should return true for a URL containing a GitHub token', () => {
expect(containsGitHubToken('https://ghp_abcdefgh1234567890abcdefgh12345678@github.com'))
.toBe(true);
});
test('should return false for a URL without a GitHub token', () => {
expect(containsGitHubToken('https://github.com/user/repo.git'))
.toBe(false);
});
test('should return false for an empty string', () => {
expect(containsGitHubToken(''))
.toBe(false);
});
test('should return false for a null value', () => {
expect(containsGitHubToken(null))
.toBe(false);
});
test('should return false for a URL with a similar but invalid token', () => {
expect(containsGitHubToken('https://ghz_abcdefgh1234567890@github.com'))
.toBe(false);
});
});
describe('getSafeGitRemoteUrls', () => {
test('should filter out URLs containing GitHub tokens', () => {
const remotes = [
{ refs: { fetch: 'https://ghp_abcdefgh1234567890abcdefgh12345678@github.com' } },
{ refs: { fetch: 'https://github.com/user/repo.git' } },
{ refs: { fetch: 'git@github.com:user/repo.git' } }
];
expect(getSafeGitRemoteUrls(remotes)).toEqual([
'https://github.com/user/repo.git',
'git@github.com:user/repo.git'
]);
});
test('should return an empty array if all URLs contain GitHub tokens', () => {
const remotes = [
{ refs: { fetch: 'https://ghp_abcdefgh1234567890abcdefgh12345678@github.com' } },
{ refs: { fetch: 'https://gho_abcdefgh1234567890abcdefgh12345678@github.com' } }
];
expect(getSafeGitRemoteUrls(remotes)).toEqual([]);
});
test('should return an empty array if no valid URLs are present', () => {
const remotes = [
{ refs: { fetch: '' } },
{ refs: { fetch: null } },
{ refs: { fetch: undefined } }
];
expect(getSafeGitRemoteUrls(remotes)).toEqual([]);
});
test('should return an empty array if input is null or undefined', () => {
expect(getSafeGitRemoteUrls(null)).toEqual([]);
expect(getSafeGitRemoteUrls(undefined)).toEqual([]);
});
test('should ignore remotes with no fetch property', () => {
const remotes = [
{ refs: {} },
{}
];
expect(getSafeGitRemoteUrls(remotes)).toEqual([]);
});
});
describe('isGitRepositoryUrl', () => {
test('should return true for valid HTTPS GitHub URLs', () => {
expect(isGitRepositoryUrl('https://github.com/user/repo.git')).toBe(true);
expect(isGitRepositoryUrl('https://github.com/user/repo')).toBe(true); // automatically adds .git suffix
});
test('should return true for valid SSH GitHub URLs', () => {
expect(isGitRepositoryUrl('git@github.com:user/repo.git')).toBe(true);
});
test('should return true for custom Git server URLs', () => {
expect(isGitRepositoryUrl('https://git.example.com/user/repo.git')).toBe(true);
expect(isGitRepositoryUrl('git@git.example.com:user/repo.git')).toBe(true);
});
test('should return false for invalid URLs', () => {
expect(isGitRepositoryUrl('')).toBe(false);
expect(isGitRepositoryUrl('not-a-url')).toBe(false);
expect(isGitRepositoryUrl('https://example.com')).toBe(false);
expect(isGitRepositoryUrl('ftp://github.com/user/repo.git')).toBe(false);
});
test('should return true for HTTPS URLs without .git suffix for valid Git hosts', () => {
expect(isGitRepositoryUrl('https://github.com/user/repo')).toBe(true);
expect(isGitRepositoryUrl('https://gitlab.com/user/repo')).toBe(true);
expect(isGitRepositoryUrl('https://bitbucket.org/user/repo')).toBe(true);
});
test('should return false for null or undefined', () => {
expect(isGitRepositoryUrl(null)).toBe(false);
expect(isGitRepositoryUrl(undefined)).toBe(false);
});
test('should handle malformed URLs gracefully', () => {
expect(isGitRepositoryUrl('https://')).toBe(false);
expect(isGitRepositoryUrl('git@')).toBe(false);
expect(isGitRepositoryUrl('://invalid')).toBe(false);
});
});

View File

@@ -1,22 +1,31 @@
import jsyaml from 'js-yaml';
import each from 'lodash/each';
import get from 'lodash/get';
import filter from 'lodash/filter';
import cloneDeep from 'lodash/cloneDeep';
import { uuid } from 'utils/common';
import { isItemARequest } from 'utils/collections';
import { collectionSchema } from '@usebruno/schema';
import { BrunoError } from 'utils/common/error';
import { isOpenApiSpec } from './openapi-collection';
import { isPostmanCollection } from './postman-collection';
import { isInsomniaCollection } from './insomnia-collection';
export const validateSchema = (collection = {}) => {
return new Promise((resolve, reject) => {
collectionSchema
.validate(collection)
.then(() => resolve(collection))
.catch((err) => {
console.log(err);
reject(new BrunoError('The Collection file is corrupted'));
});
});
export const validateSchema = async (collections = []) => {
collections = Array.isArray(collections) ? collections : [collections];
try {
await Promise.all(
collections.map(async (collection) => {
await collectionSchema.validate(collection);
})
);
return collections;
} catch (err) {
console.log(err);
throw new BrunoError('The Collection file is corrupted');
}
};
export const updateUidsInCollection = (_collection) => {
@@ -66,6 +75,18 @@ export const updateUidsInCollection = (_collection) => {
return collection;
};
export const filterItemsInCollection = (collection) => {
// this filters out the bruno.json item in older collection exports
collection.items = filter(collection.items, (item) => {
if (item?.name === 'bruno' && item?.type === 'json') {
return false;
}
return true;
});
return collection;
};
// todo
// need to eventually get rid of supporting old collection app models
// 1. start with making request type a constant fetched from a single place
@@ -156,7 +177,12 @@ export const transformItemsInCollection = (collection) => {
});
};
transformItems(collection.items);
if (Array.isArray(collection)) {
collection.forEach((col) => transformItems(col.items));
} else {
transformItems(collection.items);
}
return collection;
};
@@ -173,7 +199,38 @@ export const hydrateSeqInCollection = (collection) => {
}
});
};
hydrateSeq(collection.items);
if (Array.isArray(collection)) {
collection.forEach((col) => hydrateSeq(col.items));
} else {
hydrateSeq(collection.items);
}
return collection;
};
/**
* Gets the schema type(postman, insomnia, openapi) of the CollectionJSON data
* @param {Object} data - The JSON data to get the type of
* @returns {'openapi' | 'postman' | 'insomnia' | 'unknown'} - The type of the CollectionJSON data
*/
const getCollectionSpecType = (data) => {
return isOpenApiSpec(data) ? 'openapi' : isPostmanCollection(data) ? 'postman' : isInsomniaCollection(data) ? 'insomnia' : 'unknown';
};
export const fetchAndValidateApiSpecFromUrl = ({ url }) => {
const { ipcRenderer } = window;
return new Promise((resolve, reject) => {
ipcRenderer
.invoke('renderer:fetch-api-spec', url)
.then((res) => jsyaml.load(res))
.then((data) => {
const specType = getCollectionSpecType(data);
resolve({ data, specType: specType });
})
.catch((err) => {
console.error(err);
reject(new BrunoError('Failed to fetch API specification: ' + err.message));
});
});
};

View File

@@ -70,6 +70,7 @@
"mime-types": "^2.1.35",
"nanoid": "3.3.8",
"qs": "^6.14.1",
"simple-git": "^3.22.0",
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^6.0.0",
"uuid": "^9.0.0",

View File

@@ -41,6 +41,7 @@ const registerPreferencesIpc = require('./ipc/preferences');
const registerSystemMonitorIpc = require('./ipc/system-monitor');
const registerWorkspaceIpc = require('./ipc/workspace');
const registerApiSpecIpc = require('./ipc/apiSpec');
const registerGitIpc = require('./ipc/git');
const collectionWatcher = require('./app/collection-watcher');
const WorkspaceWatcher = require('./app/workspace-watcher');
const ApiSpecWatcher = require('./app/apiSpecsWatcher');
@@ -403,6 +404,7 @@ app.on('ready', async () => {
registerNotificationsIpc(mainWindow, collectionWatcher);
registerFilesystemIpc(mainWindow);
registerSystemMonitorIpc(mainWindow, systemMonitor);
registerGitIpc(mainWindow);
});
// Quit the app once all windows are closed

View File

@@ -53,7 +53,8 @@ const {
isValidDotEnvFilename,
isBrunoConfigFile,
isBruEnvironmentConfig,
isCollectionRootBruFile
isCollectionRootBruFile,
scanForBrunoFiles
} = require('../utils/filesystem');
const { openCollectionDialog, openCollectionsByPathname, registerScratchCollectionPath } = require('../app/collections');
const { generateUidBasedOnHash, stringifyJson, safeStringifyJSON, safeParseJSON } = require('../utils/common');
@@ -72,6 +73,7 @@ const collectionWatcher = require('../app/collection-watcher');
const { transformBrunoConfigBeforeSave } = require('../utils/transformBrunoConfig');
const { REQUEST_TYPES } = require('../utils/constants');
const { cancelOAuth2AuthorizationRequest, isOauth2AuthorizationRequestInProgress } = require('../utils/oauth2-protocol-handler');
const { findUniqueFolderName } = require('../utils/collection-import');
const environmentSecretsStore = new EnvironmentSecretsStore();
const collectionSecurityStore = new CollectionSecurityStore();
@@ -1106,122 +1108,171 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
});
ipcMain.handle('renderer:import-collection', async (_, collection, collectionLocation, format = DEFAULT_COLLECTION_FORMAT) => {
try {
let collectionName = sanitizeName(collection.name);
let collectionPath = path.join(collectionLocation, collectionName);
let collections = Array.isArray(collection) ? collection : [collection];
let completedImports = 0;
let failedImports = 0;
let successfulImports = [];
if (fs.existsSync(collectionPath)) {
throw new Error(`collection: ${collectionPath} already exists`);
}
for (let coll of collections) {
try {
// Sending a "started" and "ended" event to renderer to start and stop the spinner.
mainWindow.webContents.send('main:collection-import-started', coll.uid);
const getFilenameWithFormat = (item, format) => {
if (item?.filename) {
const ext = path.extname(item.filename);
if (ext === '.bru' || ext === '.yml') {
return item.filename.replace(ext, `.${format}`);
}
return item.filename;
let collectionName = sanitizeName(coll.name);
let collectionPath = path.join(collectionLocation, collectionName);
// Auto-rename if collection already exists
if (fs.existsSync(collectionPath)) {
const uniqueName = await findUniqueFolderName(coll.name, collectionLocation);
collectionName = sanitizeName(uniqueName);
collectionPath = path.join(collectionLocation, collectionName);
coll.name = uniqueName;
}
return `${item.name}.${format}`;
};
// Recursive function to parse the collection items and create files/folders
const parseCollectionItems = async (items = [], currentPath) => {
await Promise.all(items.map(async (item) => {
if (['http-request', 'graphql-request', 'grpc-request', 'ws-request'].includes(item.type)) {
let sanitizedFilename = sanitizeName(getFilenameWithFormat(item, format));
const content = await stringifyRequestViaWorker(item, { format });
const filePath = path.join(currentPath, sanitizedFilename);
const getFilenameWithFormat = (item, format) => {
if (item?.filename) {
const ext = path.extname(item.filename);
if (ext === '.bru' || ext === '.yml') {
return item.filename.replace(ext, `.${format}`);
}
return item.filename;
}
return `${item.name}.${format}`;
};
// Recursive function to parse the collection items and create files/folders
const parseCollectionItems = async (items = [], currentPath) => {
await Promise.all(items.map(async (item) => {
if (['http-request', 'graphql-request', 'grpc-request', 'ws-request'].includes(item.type)) {
let sanitizedFilename = sanitizeName(getFilenameWithFormat(item, format));
const content = await stringifyRequestViaWorker(item, { format });
const filePath = path.join(currentPath, sanitizedFilename);
safeWriteFileSync(filePath, content);
}
if (item.type === 'folder') {
let sanitizedFolderName = sanitizeName(item?.filename || item?.name);
const folderPath = path.join(currentPath, sanitizedFolderName);
fs.mkdirSync(folderPath);
if (item?.root?.meta?.name) {
const folderFilePath = path.join(folderPath, `folder.${format}`);
item.root.meta.seq = item.seq;
const folderContent = await stringifyFolder(item.root, { format });
safeWriteFileSync(folderFilePath, folderContent);
}
if (item.items && item.items.length) {
await parseCollectionItems(item.items, folderPath);
}
}
// Handle items of type 'js'
if (item.type === 'js') {
let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.js`);
const filePath = path.join(currentPath, sanitizedFilename);
safeWriteFileSync(filePath, item.fileContent);
}
}));
};
const parseEnvironments = async (environments = [], collectionPath) => {
const envDirPath = path.join(collectionPath, 'environments');
if (!fs.existsSync(envDirPath)) {
fs.mkdirSync(envDirPath);
}
await Promise.all(environments.map(async (env) => {
const content = await stringifyEnvironment(env, { format });
let sanitizedEnvFilename = sanitizeName(`${env.name}.${format}`);
const filePath = path.join(envDirPath, sanitizedEnvFilename);
safeWriteFileSync(filePath, content);
}
if (item.type === 'folder') {
let sanitizedFolderName = sanitizeName(item?.filename || item?.name);
const folderPath = path.join(currentPath, sanitizedFolderName);
fs.mkdirSync(folderPath);
}));
};
if (item?.root?.meta?.name) {
const folderFilePath = path.join(folderPath, `folder.${format}`);
item.root.meta.seq = item.seq;
const folderContent = await stringifyFolder(item.root, { format });
safeWriteFileSync(folderFilePath, folderContent);
}
const getBrunoJsonConfig = (collection) => {
let brunoConfig = collection.brunoConfig;
if (item.items && item.items.length) {
await parseCollectionItems(item.items, folderPath);
}
if (!brunoConfig) {
brunoConfig = {
version: '1',
name: collection.name,
type: 'collection',
ignore: ['node_modules', '.git']
};
}
// Handle items of type 'js'
if (item.type === 'js') {
let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.js`);
const filePath = path.join(currentPath, sanitizedFilename);
safeWriteFileSync(filePath, item.fileContent);
}
}));
};
const parseEnvironments = async (environments = [], collectionPath) => {
const envDirPath = path.join(collectionPath, 'environments');
if (!fs.existsSync(envDirPath)) {
fs.mkdirSync(envDirPath);
return brunoConfig;
};
await createDirectory(collectionPath);
const uid = generateUidBasedOnHash(collectionPath);
const brunoConfig = getBrunoJsonConfig(coll);
if (format === 'yml') {
brunoConfig.opencollection = '1.0.0';
const collectionContent = await stringifyCollection(coll.root, brunoConfig, { format });
await writeFile(path.join(collectionPath, 'opencollection.yml'), collectionContent);
} else if (format === 'bru') {
const stringifiedBrunoConfig = await stringifyJson(brunoConfig);
await writeFile(path.join(collectionPath, 'bruno.json'), stringifiedBrunoConfig);
const collectionContent = await stringifyCollection(coll.root, brunoConfig, { format });
await writeFile(path.join(collectionPath, 'collection.bru'), collectionContent);
} else {
throw new Error(`Invalid format: ${format}`);
}
await Promise.all(environments.map(async (env) => {
const content = await stringifyEnvironment(env, { format });
let sanitizedEnvFilename = sanitizeName(`${env.name}.${format}`);
const filePath = path.join(envDirPath, sanitizedEnvFilename);
safeWriteFileSync(filePath, content);
}));
};
// create folder and files based on collection
await parseCollectionItems(coll.items, collectionPath);
await parseEnvironments(coll.environments, collectionPath);
const getBrunoJsonConfig = (collection) => {
let brunoConfig = collection.brunoConfig;
const { size, filesCount } = await getCollectionStats(collectionPath);
brunoConfig.size = size;
brunoConfig.filesCount = filesCount;
if (!brunoConfig) {
brunoConfig = {
version: '1',
name: collection.name,
type: 'collection',
ignore: ['node_modules', '.git']
};
}
mainWindow.webContents.send('main:collection-opened', collectionPath, uid, brunoConfig);
ipcMain.emit('main:collection-opened', mainWindow, collectionPath, uid, brunoConfig);
return brunoConfig;
};
mainWindow.webContents.send('main:collection-import-ended', coll.uid);
await createDirectory(collectionPath);
successfulImports.push({
path: collectionPath,
name: coll.name
});
// Increment completed imports
completedImports++;
} catch (error) {
mainWindow.webContents.send('main:collection-import-failed', coll.uid, {
message: `Error ${error.message}`
});
console.error(`Failed to import collection: ${coll.name}, Error: ${error.message}`);
const uid = generateUidBasedOnHash(collectionPath);
let brunoConfig = getBrunoJsonConfig(collection);
// Increment failed imports
failedImports++;
if (format === 'yml') {
brunoConfig.opencollection = '1.0.0';
const collectionContent = await stringifyCollection(collection.root, brunoConfig, { format });
await writeFile(path.join(collectionPath, 'opencollection.yml'), collectionContent);
} else if (format === 'bru') {
const stringifiedBrunoConfig = await stringifyJson(brunoConfig);
await writeFile(path.join(collectionPath, 'bruno.json'), stringifiedBrunoConfig);
const collectionContent = await stringifyCollection(collection.root, brunoConfig, { format });
await writeFile(path.join(collectionPath, 'collection.bru'), collectionContent);
} else {
throw new Error(`Invalid format: ${format}`);
// Continue with next collection instead of breaking
continue;
}
const { size, filesCount } = await getCollectionStats(collectionPath);
brunoConfig.size = size;
brunoConfig.filesCount = filesCount;
mainWindow.webContents.send('main:collection-opened', collectionPath, uid, brunoConfig);
ipcMain.emit('main:collection-opened', mainWindow, collectionPath, uid, brunoConfig);
// create folder and files based on collection
await parseCollectionItems(collection.items, collectionPath);
await parseEnvironments(collection.environments, collectionPath);
return collectionPath;
} catch (error) {
return Promise.reject(error);
}
// Send final status when all collections have been processed (either succeeded or failed)
if ((completedImports + failedImports) === collections.length) {
mainWindow.webContents.send('main:all-collections-import-ended', {
message: `Import completed. ${completedImports} collections imported successfully, ${failedImports} failed.`,
status: {
total: collections.length,
succeeded: completedImports,
failed: failedImports
}
});
}
return {
success: {
count: completedImports,
items: successfulImports
}
};
});
ipcMain.handle('renderer:clone-folder', async (event, itemFolder, collectionPath, collectionPathname) => {
@@ -2320,6 +2371,14 @@ const registerMainEventHandlers = (mainWindow, watcher) => {
app.addRecentDocument(pathname);
});
ipcMain.handle('renderer:scan-for-bruno-files', (event, dir) => {
try {
return scanForBrunoFiles(dir);
} catch (error) {
throw new Error(error.message);
}
});
// The app listen for this event and allows the user to save unsaved requests before closing the app
ipcMain.on('main:start-quit-flow', () => {
mainWindow.webContents.send('main:start-quit-flow');

View File

@@ -0,0 +1,22 @@
const { ipcMain } = require('electron');
const { cloneGitRepository } = require('../utils/git');
const { createDirectory, removeDirectory } = require('../utils/filesystem');
const registerGitIpc = (mainWindow) => {
ipcMain.handle('renderer:clone-git-repository', async (event, { url, path, processUid }) => {
let directoryCreated = false;
try {
await createDirectory(path);
directoryCreated = true;
await cloneGitRepository(mainWindow, { url, path, processUid });
return 'Repository cloned successfully';
} catch (error) {
if (directoryCreated) {
await removeDirectory(path);
}
return Promise.reject(error);
}
});
};
module.exports = registerGitIpc;

View File

@@ -1,5 +1,6 @@
const { ipcMain, nativeTheme } = require('electron');
const { getPreferences, savePreferences } = require('../store/preferences');
const { getGitVersion } = require('../utils/git');
const { globalEnvironmentsStore } = require('../store/global-environments');
const { getCachedSystemProxy, refreshSystemProxy } = require('../store/system-proxy');
@@ -20,6 +21,9 @@ const registerPreferencesIpc = (mainWindow) => {
console.error(error);
}
const gitVersion = await getGitVersion();
mainWindow.webContents.send('main:git-version', gitVersion);
ipcMain.emit('main:renderer-ready', mainWindow);
});

View File

@@ -474,6 +474,33 @@ const isCollectionRootBruFile = (pathname, collectionPath) => {
return dirname === collectionPath && basename === 'collection.bru';
};
const scanForBrunoFiles = async (dir) => {
const brunoFolders = [];
const scanDir = (currentDir) => {
const files = fs.readdirSync(currentDir);
if (files && files.length) {
files.forEach((file) => {
const fullPath = path.join(currentDir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
if (['node_modules', '.git'].includes(file)) {
return;
}
scanDir(fullPath);
} else if (file === 'bruno.json') {
brunoFolders.push(currentDir);
}
});
}
};
scanDir(dir);
return brunoFolders;
};
module.exports = {
DEFAULT_GITIGNORE,
isValidPathname,
@@ -514,5 +541,6 @@ module.exports = {
isValidDotEnvFilename,
isBrunoConfigFile,
isBruEnvironmentConfig,
isCollectionRootBruFile
isCollectionRootBruFile,
scanForBrunoFiles
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
import { test, expect } from '../../../playwright';
import * as path from 'path';
import { closeAllCollections } from '../../utils/page';
test.describe('Multiple Files Upload', () => {
const testDataDir = path.join(__dirname, '../test-data');
test.afterEach(async ({ page }) => {
// cleanup: close all collections
await closeAllCollections(page);
});
test('Multiple files can be uploaded together', async ({ page, createTmpDir }) => {
const postmanFile = path.join(testDataDir, 'sample-postman.json');
const insomniaFile = path.join(testDataDir, 'sample-insomnia.json');
await page.getByTestId('collections-header-add-menu').click();
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByRole('dialog');
await importModal.waitFor({ state: 'visible' });
await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
await page.setInputFiles('input[type="file"]', [postmanFile, insomniaFile]);
// Wait for the loader to disappear
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
// Verify that the Bulk Import modal is now displayed
const bulkImportModal = page.getByRole('dialog');
await expect(bulkImportModal.locator('.bruno-modal-header-title')).toContainText('Bulk Import');
// Check that the Collections count shows 2 collections in the Bulk Import modal
await expect(bulkImportModal.getByText('Collections (2)')).toBeVisible();
// Verify collection names are displayed
await expect(bulkImportModal.getByText('Sample Postman Collection')).toBeVisible();
await expect(bulkImportModal.getByText('Sample Insomnia Collection')).toBeVisible();
// Select a location and import
await page.locator('#collection-location').fill(await createTmpDir('multiple-files-test'));
await bulkImportModal.getByRole('button', { name: 'Import' }).click();
// Wait for import to complete (summary modal shows with "Close" button)
await expect(bulkImportModal.getByRole('button', { name: 'Close' })).toBeVisible();
// Close the summary modal
await bulkImportModal.getByRole('button', { name: 'Close' }).click();
await bulkImportModal.waitFor({ state: 'hidden' });
// Verify collections were imported successfully
await expect(page.locator('#sidebar-collection-name').getByText('Sample Postman Collection')).toBeVisible();
await expect(page.locator('#sidebar-collection-name').getByText('Sample Insomnia Collection')).toBeVisible();
});
});

View File

@@ -0,0 +1,68 @@
import { test, expect } from '../../../playwright';
import * as path from 'path';
import { closeAllCollections } from '../../utils/page';
test.describe('All Collection Types Bulk Import', () => {
const testDataDir = path.join(__dirname, '../test-data');
test.afterEach(async ({ page }) => {
// cleanup: close all collections
await closeAllCollections(page);
});
test('All 4 collection types appear in bulk import', async ({ page, createTmpDir }) => {
const postmanFile = path.join(testDataDir, 'sample-postman.json');
const insomniaFile = path.join(testDataDir, 'sample-insomnia.json');
const brunoFile = path.join(testDataDir, 'sample-bruno.json');
const openapiFile = path.join(testDataDir, 'sample-openapi.yaml');
await page.getByTestId('collections-header-add-menu').click();
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByRole('dialog');
await importModal.waitFor({ state: 'visible' });
await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
await page.setInputFiles('input[type="file"]', [postmanFile, insomniaFile, brunoFile, openapiFile]);
// Wait for the loader to disappear
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
// Verify that the Bulk Import modal is displayed (no separate settings modal anymore)
const bulkImportModal = page.getByRole('dialog');
await expect(bulkImportModal.locator('.bruno-modal-header-title')).toContainText('Bulk Import');
// Check that the Collections count shows 4 collections in the Bulk Import modal
await expect(bulkImportModal.getByText('Collections (4)')).toBeVisible();
await expect(bulkImportModal.getByText('Sample Postman Collection')).toBeVisible();
await expect(bulkImportModal.getByText('Sample Insomnia Collection')).toBeVisible();
await expect(bulkImportModal.getByText('Sample Bruno Collection')).toBeVisible();
await expect(bulkImportModal.getByText('Sample API')).toBeVisible();
// Verify that OpenAPI settings are visible (since one file is OpenAPI)
await expect(bulkImportModal.getByText('Folder arrangement')).toBeVisible();
await expect(bulkImportModal.getByTestId('grouping-dropdown')).toBeVisible();
// Optionally change grouping to path-based
await bulkImportModal.getByTestId('grouping-dropdown').click();
await bulkImportModal.getByTestId('grouping-option-path').click();
// Select a location and import
await page.locator('#collection-location').fill(await createTmpDir('all-collection-types-test'));
await bulkImportModal.getByRole('button', { name: 'Import' }).click();
// Wait for import to complete (summary modal shows with "Close" button)
await expect(bulkImportModal.getByRole('button', { name: 'Close' })).toBeVisible();
// Close the summary modal
await bulkImportModal.getByRole('button', { name: 'Close' }).click();
await bulkImportModal.waitFor({ state: 'hidden' });
// Verify all collections were imported successfully
await expect(page.locator('#sidebar-collection-name').getByText('Sample Postman Collection')).toBeVisible();
await expect(page.locator('#sidebar-collection-name').getByText('Sample Insomnia Collection')).toBeVisible();
await expect(page.locator('#sidebar-collection-name').getByText('Sample Bruno Collection')).toBeVisible();
await expect(page.locator('#sidebar-collection-name').getByText('Sample API')).toBeVisible();
});
});

View File

@@ -0,0 +1,43 @@
{
"version": "1",
"uid": "bruno_test_collection_1",
"name": "Sample Bruno Collection",
"items": [
{
"uid": "bruno_test_request_1",
"type": "http-request",
"name": "Get Sample Data",
"seq": 1,
"request": {
"url": "https://jsonplaceholder.typicode.com/todos/1",
"method": "GET",
"headers": [],
"params": [],
"body": {
"mode": "none"
},
"auth": {
"mode": "none"
},
"script": {},
"vars": {},
"assertions": [],
"tests": "",
"docs": ""
}
}
],
"environments": [],
"activeEnvironmentUid": null,
"root": {
"request": {
"headers": [],
"auth": {
"mode": "none"
},
"script": {},
"vars": {},
"tests": ""
}
}
}

View File

@@ -0,0 +1,41 @@
{
"_type": "export",
"__export_format": 4,
"__export_date": "2023-01-01T00:00:00.000Z",
"__export_source": "insomnia.desktop.app:v2023.1.0",
"resources": [
{
"_id": "req_123",
"authentication": {},
"body": {},
"created": 1672531200000,
"description": "",
"headers": [],
"isPrivate": false,
"metaSortKey": -1672531200000,
"method": "GET",
"modified": 1672531200000,
"name": "Get Posts",
"parameters": [],
"parentId": "wrk_456",
"settingDisableRenderRequestBody": false,
"settingEncodeUrl": true,
"settingFollowRedirects": "global",
"settingRebuildPath": true,
"settingSendCookies": true,
"settingStoreCookies": true,
"url": "https://jsonplaceholder.typicode.com/posts",
"_type": "request"
},
{
"_id": "wrk_456",
"created": 1672531200000,
"description": "Sample Insomnia collection for testing",
"modified": 1672531200000,
"name": "Sample Insomnia Collection",
"parentId": null,
"scope": "collection",
"_type": "workspace"
}
]
}

View File

@@ -0,0 +1,61 @@
openapi: 3.0.0
info:
title: Sample API
description: A simple API for testing OpenAPI imports
version: 1.0.0
servers:
- url: https://jsonplaceholder.typicode.com
paths:
/posts:
get:
summary: Get all posts
description: Retrieve a list of all posts
responses:
'200':
description: List of posts
content:
application/json:
schema:
type: array
items:
type: object
properties:
id:
type: integer
title:
type: string
body:
type: string
userId:
type: integer
post:
summary: Create a new post
description: Create a new post
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
title:
type: string
body:
type: string
userId:
type: integer
responses:
'201':
description: Post created successfully
/posts/{id}:
get:
summary: Get a specific post
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: Post details

View File

@@ -0,0 +1,44 @@
{
"info": {
"name": "Sample Postman Collection",
"description": "A simple collection for testing imports",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "Get Users",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "https://jsonplaceholder.typicode.com/users",
"protocol": "https",
"host": ["jsonplaceholder", "typicode", "com"],
"path": ["users"]
}
}
},
{
"name": "Create User",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"John Doe\",\n \"email\": \"john@example.com\"\n}"
},
"url": {
"raw": "https://jsonplaceholder.typicode.com/users",
"protocol": "https",
"host": ["jsonplaceholder", "typicode", "com"],
"path": ["users"]
}
}
}
]
}

View File

@@ -0,0 +1,38 @@
import { test, expect } from '../../../playwright';
import { closeAllCollections } from '../../utils/page';
test.describe('GitHub Repository URL Import', () => {
test.afterEach(async ({ page }) => {
await closeAllCollections(page);
});
test('GitHub repository URL import', async ({ page }) => {
const githubUrl = 'https://github.com/usebruno/github-rest-api-collection';
// Test GitHub repository import
await page.getByTestId('collections-header-add-menu').click();
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByRole('dialog');
await importModal.waitFor({ state: 'visible' });
await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
// Select the GitHub tab
await page.getByTestId('github-tab').click();
// Fill in the URL input
await page.getByTestId('git-url-input').fill(githubUrl);
await page.locator('#clone-git-button').click();
// Wait for the loader to disappear
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
// Verify that the Clone Git Repository modal is displayed
const cloneModal = page.getByRole('dialog');
await expect(cloneModal.locator('.bruno-modal-header-title')).toContainText('Clone Git Repository');
// Cleanup: close any open modals using Cancel button (avoids form validation)
await page.getByRole('button', { name: 'Cancel' }).click();
});
});

View File

@@ -0,0 +1,44 @@
import { test, expect } from '../../../playwright';
import { closeAllCollections, openCollection } from '../../utils/page';
test.describe('Insomnia URL Import', () => {
test.afterEach(async ({ page }) => {
// cleanup: close all collections
await closeAllCollections(page);
});
test('Insomnia URL import', async ({ page, createTmpDir }) => {
const insomniaUrl = 'https://raw.githubusercontent.com/usebruno/bruno/refs/heads/main/tests/import/insomnia/fixtures/insomnia-v5.yaml';
await page.getByTestId('collections-header-add-menu').click();
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByTestId('import-collection-modal');
await importModal.waitFor({ state: 'visible' });
await page.getByTestId('url-tab').click();
await page.getByTestId('url-input').waitFor({ state: 'visible' });
await page.getByTestId('url-input').fill(insomniaUrl);
await page.locator('#import-url-button').click();
// Wait for the loader to disappear
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
// Verify that the collection location modal appears
const locationModal = page.getByTestId('import-collection-location-modal');
await expect(locationModal.getByText('Test API Collection v5')).toBeVisible();
// Select a location and import
await page.locator('#collection-location').fill(await createTmpDir('test-api-collection-v5'));
await locationModal.getByRole('button', { name: 'Import' }).click();
// Verify the collection was imported successfully and configure it
await expect(page.locator('#sidebar-collection-name').getByText('Test API Collection v5')).toBeVisible();
await openCollection(page, 'Test API Collection v5');
// Verify these folder names are present
await expect(page.locator('.collection-item-name').getByText('API Tests')).toBeVisible();
await expect(page.locator('.collection-item-name').getByText('Data Management')).toBeVisible();
});
});

View File

@@ -0,0 +1,102 @@
import { test, expect } from '../../../playwright';
import { closeAllCollections, openCollection } from '../../utils/page';
test.describe('OpenAPI URL Import', () => {
test.afterEach(async ({ page }) => {
// cleanup: close all collections
await closeAllCollections(page);
});
test('Swagger/OpenAPI URL import', async ({ page, createTmpDir }) => {
const openapiUrl = 'https://petstore.swagger.io/v2/swagger.json';
await page.getByTestId('collections-header-add-menu').click();
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByTestId('import-collection-modal');
await importModal.waitFor({ state: 'visible' });
await page.getByTestId('url-tab').click();
await page.getByTestId('url-input').waitFor({ state: 'visible' });
await page.getByTestId('url-input').fill(openapiUrl);
await page.locator('#import-url-button').click();
// Wait for the loader to disappear
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
// Verify that the collection location modal appears with OpenAPI settings
const locationModal = page.getByTestId('import-collection-location-modal');
await expect(locationModal.getByText('Swagger Petstore')).toBeVisible();
// Verify OpenAPI settings are available in the location modal
await expect(locationModal.getByText('Folder arrangement')).toBeVisible();
await expect(locationModal.getByTestId('grouping-dropdown')).toBeVisible();
// Select a location and import with default grouping (tags)
await page.locator('#collection-location').fill(await createTmpDir('swagger-petstore'));
await locationModal.getByRole('button', { name: 'Import' }).click();
// Verify the collection was imported successfully and configure it
await expect(page.locator('#sidebar-collection-name').getByText('Swagger Petstore')).toBeVisible();
await openCollection(page, 'Swagger Petstore');
// Verify these folder names are present (tag-based grouping)
await expect(page.locator('.collection-item-name').getByText('pet')).toBeVisible();
await expect(page.locator('.collection-item-name').getByText('store')).toBeVisible();
await expect(page.locator('.collection-item-name').getByText('user')).toBeVisible();
});
test('Swagger/OpenAPI URL import with path-based grouping', async ({ page, createTmpDir }) => {
const openapiUrl = 'https://petstore.swagger.io/v2/swagger.json';
await page.getByTestId('collections-header-add-menu').click();
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByTestId('import-collection-modal');
await importModal.waitFor({ state: 'visible' });
await page.getByTestId('url-tab').click();
await page.getByTestId('url-input').waitFor({ state: 'visible' });
await page.getByTestId('url-input').fill(openapiUrl);
await page.locator('#import-url-button').click();
// Wait for the loader to disappear
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
// Verify that the collection location modal appears with OpenAPI settings
const locationModal = page.getByTestId('import-collection-location-modal');
await expect(locationModal.getByText('Swagger Petstore')).toBeVisible();
// Verify OpenAPI settings are available in the location modal
await expect(locationModal.getByText('Folder arrangement')).toBeVisible();
// Select path-based grouping from the dropdown
await locationModal.getByTestId('grouping-dropdown').click();
// Wait for dropdown options to be visible and select path-based grouping
await page.getByTestId('grouping-option-path').waitFor({ state: 'visible' });
await page.getByTestId('grouping-option-path').click();
// Select a location and import with path-based grouping
await page.locator('#collection-location').fill(await createTmpDir('swagger-petstore-path'));
await locationModal.getByRole('button', { name: 'Import' }).click();
// Verify the collection was imported successfully and configure it
await expect(page.locator('#sidebar-collection-name').getByText('Swagger Petstore')).toBeVisible();
await openCollection(page, 'Swagger Petstore');
// Verify that the collection has been imported with path-based grouping
// Should have folders based on URL paths like 'pet', 'store', 'user'
await expect(page.locator('.collection-item-name').getByText('pet')).toBeVisible();
await expect(page.locator('.collection-item-name').getByText('store')).toBeVisible();
await expect(page.locator('.collection-item-name').getByText('user')).toBeVisible();
// Expand the pet folder to check for nested path structure
await page.locator('.collection-item-name').getByText('pet').click();
// Verify that the pet folder contains path-based subfolders like '{petId}'
await expect(page.locator('.collection-item-name').getByText('{petId}')).toBeVisible();
});
});

View File

@@ -0,0 +1,44 @@
import { test, expect } from '../../../playwright';
import { closeAllCollections, openCollection } from '../../utils/page';
test.describe('Postman URL Import', () => {
test.afterEach(async ({ page }) => {
// cleanup: close all collections
await closeAllCollections(page);
});
test('Postman URL import', async ({ page, createTmpDir }) => {
const postmanUrl = 'https://raw.githubusercontent.com/usebruno/bruno/refs/heads/main/tests/import/postman/fixtures/postman-v21.json';
await page.getByTestId('collections-header-add-menu').click();
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByTestId('import-collection-modal');
await importModal.waitFor({ state: 'visible' });
await page.getByTestId('url-tab').click();
await page.getByTestId('url-input').waitFor({ state: 'visible' });
await page.getByTestId('url-input').fill(postmanUrl);
await page.locator('#import-url-button').click();
// Wait for the loader to disappear
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
// Verify that the collection location modal appears
const locationModal = page.getByTestId('import-collection-location-modal');
await expect(locationModal.getByText('Postman v2.1 Collection')).toBeVisible();
// Select a location and import
await page.locator('#collection-location').fill(await createTmpDir('postman-v21-collection'));
await locationModal.getByRole('button', { name: 'Import' }).click();
// Verify the collection was imported successfully and configure it
await expect(page.locator('#sidebar-collection-name').getByText('Postman v2.1 Collection')).toBeVisible();
await openCollection(page, 'Postman v2.1 Collection');
// Verify these folder names are present
await expect(page.locator('.collection-item-name').getByText('Get Users')).toBeVisible();
await expect(page.locator('.collection-item-name').getByText('Create User')).toBeVisible();
});
});