diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollection/FileTab.js b/packages/bruno-app/src/components/Sidebar/ImportCollection/FileTab.js
new file mode 100644
index 000000000..d29f8a7a1
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/ImportCollection/FileTab.js
@@ -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 (
+
+
+
+
+
+
+ Drop file to import or{' '}
+
+
+
+ Supports Bruno, OpenCollection, Postman, Insomnia, OpenAPI v3, WSDL, and ZIP formats
+
+
+
+
+ );
+};
+
+export default FileTab;
diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js b/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js
index 9ad1d139f..a1bda3e01 100644
--- a/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js
@@ -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 ;
}
- const acceptedFileTypes = [
- '.json',
- '.yaml',
- '.yml',
- '.wsdl',
- 'application/json',
- 'application/yaml',
- 'application/x-yaml',
- 'text/xml',
- 'application/xml'
- ];
-
return (
-
-
Import from file
+ {errorMessage && (
-
-
-
-
- Drop file to import or{' '}
-
-
-
- Supports Bruno, OpenCollection, Postman, Insomnia, OpenAPI v3, and WSDL formats
-
+
+
+ {errorMessage}
+
+
setErrorMessage('')}
+ style={{ color: theme.status?.danger?.text || '#dc2626' }}
+ >
+
+
-
+ )}
+
+
);
diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js b/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js
index 094c2d9f6..b8e18e3db 100644
--- a/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js
+++ b/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js
@@ -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 (
@@ -212,30 +231,32 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
-
-
-
-
+ {!isZipImport && (
+
+
+
+
+ )}
{isOpenApi && (
diff --git a/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js b/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js
index 2749157aa..d3fec1217 100644
--- a/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js
@@ -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);
diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/index.js
index b8b688922..83c2ac8b2 100644
--- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/index.js
+++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/index.js
@@ -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);
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
index a1f1ce16a..2aa6144ef 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
@@ -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) => {
diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js
index 2fa8656dc..c03d0771d 100644
--- a/packages/bruno-electron/src/ipc/collection.js
+++ b/packages/bruno-electron/src/ipc/collection.js
@@ -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) => {