mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-08 06:28:33 +00:00
feat: add ZIP file import for collections (#7063)
* feat: add ZIP file import for collections
This commit is contained in:
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user