feat: add ZIP file import for collections (#7063)

* feat: add ZIP file import for collections
This commit is contained in:
naman-bruno
2026-02-09 15:00:54 +05:30
committed by Bijin A B
parent 3080c3e144
commit 46bc0ffce7
7 changed files with 439 additions and 186 deletions

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ import {
IconTerminal2
} from '@tabler/icons';
import { importCollection, openCollection } from 'providers/ReduxStore/slices/collections/actions';
import { importCollection, openCollection, importCollectionFromZip } from 'providers/ReduxStore/slices/collections/actions';
import { sortCollections } from 'providers/ReduxStore/slices/collections/index';
import { normalizePath } from 'utils/common/path';
@@ -52,14 +52,18 @@ const CollectionsSection = () => {
);
}, [activeWorkspace, collections]);
const handleImportCollection = ({ rawData, type }) => {
const handleImportCollection = ({ rawData, type, ...rest }) => {
setImportCollectionModalOpen(false);
setImportData({ rawData, type });
setImportData({ rawData, type, ...rest });
setImportCollectionLocationModalOpen(true);
};
const handleImportCollectionLocation = (convertedCollection, collectionLocation, options = {}) => {
dispatch(importCollection(convertedCollection, collectionLocation, options))
const importAction = options.isZipImport
? importCollectionFromZip(convertedCollection.zipFilePath, collectionLocation)
: importCollection(convertedCollection, collectionLocation, options);
dispatch(importAction)
.then(() => {
setImportCollectionLocationModalOpen(false);
setImportData(null);

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { IconPlus, IconFolder, IconDownload } from '@tabler/icons';
import { importCollection, openCollection } from 'providers/ReduxStore/slices/collections/actions';
import { importCollection, openCollection, importCollectionFromZip } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import CreateCollection from 'components/Sidebar/CreateCollection';
import ImportCollection from 'components/Sidebar/ImportCollection';
@@ -51,14 +51,18 @@ const WorkspaceOverview = ({ workspace }) => {
setImportCollectionModalOpen(true);
};
const handleImportCollectionSubmit = ({ rawData, type }) => {
const handleImportCollectionSubmit = ({ rawData, type, ...rest }) => {
setImportCollectionModalOpen(false);
setImportData({ rawData, type });
setImportData({ rawData, type, ...rest });
setImportCollectionLocationModalOpen(true);
};
const handleImportCollectionLocation = (convertedCollection, collectionLocation, options = {}) => {
dispatch(importCollection(convertedCollection, collectionLocation, options))
const importAction = options.isZipImport
? importCollectionFromZip(convertedCollection.zipFilePath, collectionLocation)
: importCollection(convertedCollection, collectionLocation, options);
dispatch(importAction)
.then(() => {
setImportCollectionLocationModalOpen(false);
setImportData(null);

View File

@@ -2663,6 +2663,24 @@ export const importCollection = (collection, collectionLocation, options = {}) =
});
};
export const importCollectionFromZip = (zipFilePath, collectionLocation) => async (dispatch, getState) => {
const { ipcRenderer } = window;
const state = getState();
const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid);
const collectionPath = await ipcRenderer.invoke('renderer:import-collection-zip', zipFilePath, collectionLocation);
if (activeWorkspace && activeWorkspace.pathname && activeWorkspace.type !== 'default') {
const collectionName = path.basename(collectionPath);
await ipcRenderer.invoke('renderer:add-collection-to-workspace', activeWorkspace.pathname, {
name: collectionName,
path: collectionPath
});
}
return collectionPath;
};
export const moveCollectionAndPersist
= ({ draggedItem, targetItem }) =>
(dispatch, getState) => {

View File

@@ -4,6 +4,7 @@ const fsExtra = require('fs-extra');
const os = require('os');
const path = require('path');
const archiver = require('archiver');
const extractZip = require('extract-zip');
const { ipcMain, shell, dialog, app } = require('electron');
const {
parseRequest,
@@ -2050,6 +2051,115 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
throw error;
}
});
ipcMain.handle('renderer:import-collection-zip', async (event, zipFilePath, collectionLocation) => {
try {
if (!fs.existsSync(zipFilePath)) {
throw new Error('ZIP file does not exist');
}
if (!collectionLocation || !fs.existsSync(collectionLocation)) {
throw new Error('Collection location does not exist');
}
const tempDir = path.join(os.tmpdir(), `bruno_zip_import_${Date.now()}`);
await fsExtra.ensureDir(tempDir);
// Validates that no symlinks point outside the base directory
const validateNoExternalSymlinks = (dir, baseDir) => {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const stat = fs.lstatSync(fullPath);
if (stat.isSymbolicLink()) {
const linkTarget = fs.readlinkSync(fullPath);
const resolvedTarget = path.resolve(path.dirname(fullPath), linkTarget);
if (!resolvedTarget.startsWith(baseDir + path.sep) && resolvedTarget !== baseDir) {
throw new Error(`Security error: Symlink "${entry.name}" points outside extraction directory`);
}
}
if (stat.isDirectory() && !stat.isSymbolicLink()) {
validateNoExternalSymlinks(fullPath, baseDir);
}
}
};
try {
await extractZip(zipFilePath, { dir: tempDir });
validateNoExternalSymlinks(tempDir, tempDir);
const extractedItems = fs.readdirSync(tempDir);
let collectionDir = tempDir;
if (extractedItems.length === 1) {
const singleItem = path.join(tempDir, extractedItems[0]);
const singleItemStat = fs.lstatSync(singleItem);
if (singleItemStat.isDirectory() && !singleItemStat.isSymbolicLink()) {
collectionDir = singleItem;
}
}
const brunoJsonPath = path.join(collectionDir, 'bruno.json');
const openCollectionYmlPath = path.join(collectionDir, 'opencollection.yml');
if (!fs.existsSync(brunoJsonPath) && !fs.existsSync(openCollectionYmlPath)) {
throw new Error('Invalid collection: Neither bruno.json nor opencollection.yml found in the ZIP file');
}
// Ensure config files are not symlinks
if (fs.existsSync(brunoJsonPath) && fs.lstatSync(brunoJsonPath).isSymbolicLink()) {
throw new Error('Security error: bruno.json cannot be a symbolic link');
}
if (fs.existsSync(openCollectionYmlPath) && fs.lstatSync(openCollectionYmlPath).isSymbolicLink()) {
throw new Error('Security error: opencollection.yml cannot be a symbolic link');
}
let collectionName = 'Imported Collection';
if (fs.existsSync(openCollectionYmlPath)) {
try {
const content = fs.readFileSync(openCollectionYmlPath, 'utf8');
const { brunoConfig } = parseCollection(content, { format: 'yml' });
collectionName = brunoConfig?.name || collectionName;
} catch (e) {
console.error(`Error parsing opencollection.yml at ${openCollectionYmlPath}:`, e);
}
} else if (fs.existsSync(brunoJsonPath)) {
try {
const config = JSON.parse(fs.readFileSync(brunoJsonPath, 'utf8'));
collectionName = config.name || collectionName;
} catch (e) {
console.error(`Error parsing bruno.json at ${brunoJsonPath}:`, e);
}
}
let sanitizedName = sanitizeName(collectionName);
if (!sanitizedName) {
sanitizedName = `untitled-${Date.now()}`;
}
let finalCollectionPath = path.join(collectionLocation, sanitizedName);
let counter = 1;
while (fs.existsSync(finalCollectionPath)) {
finalCollectionPath = path.join(collectionLocation, `${sanitizedName} (${counter})`);
counter++;
}
await fsExtra.move(collectionDir, finalCollectionPath);
if (tempDir !== collectionDir) {
await fsExtra.remove(tempDir).catch(() => {});
}
return finalCollectionPath;
} catch (error) {
await fsExtra.remove(tempDir).catch(() => {});
throw error;
}
} catch (error) {
throw error;
}
});
};
const registerMainEventHandlers = (mainWindow, watcher) => {