feat: enhance ShareCollection component with export options and UI improvements (#7016)

This commit is contained in:
naman-bruno
2026-02-02 21:01:03 +05:30
committed by GitHub
parent 8c997c46af
commit 5904c36cdb
3 changed files with 346 additions and 105 deletions

View File

@@ -1,5 +1,4 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
.tabs {
@@ -28,29 +27,157 @@ const StyledWrapper = styled.div`
}
}
.share-button {
display: flex;
border-radius: ${(props) => props.theme.border.radius.base};
padding: 10px;
border: 1px solid ${(props) => props.theme.border.border0};
background-color: ${(props) => props.theme.background.base};
color: ${(props) => props.theme.text};
cursor: pointer;
transition: all 0.1s ease;
&.no-padding {
padding: 0px;
}
.note-warning {
color: ${(props) => props.theme.colors.text.warning};
background-color: ${(props) => rgba(props.theme.colors.text.warning, 0.06)};
}
.section-title {
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: ${(props) => props.theme.colors.text.subtext0};
margin-bottom: 0.75rem;
}
.opencollection-link {
color: ${(props) => props.theme.textLink};
text-decoration: none;
&:hover {
background-color: ${(props) => props.theme.background.mantle};
text-decoration: underline;
}
}
.bruno-format-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.format-card {
display: flex;
flex-direction: column;
border-radius: ${(props) => props.theme.border.radius.base};
padding: 1rem;
border: 2px solid ${(props) => props.theme.border.border0};
background-color: ${(props) => props.theme.background.base};
cursor: pointer;
transition: border-color 0.15s ease;
min-height: 180px;
&:hover:not(.selected) {
border-color: ${(props) => props.theme.border.border2};
}
&.selected {
border-color: ${(props) => props.theme.primary.solid};
}
.card-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
.card-title {
font-weight: 600;
font-size: 0.9375rem;
}
.recommended-badge {
padding: 0.125rem 0.5rem;
font-size: 0.6875rem;
font-weight: 600;
border-radius: 0.25rem;
background-color: ${(props) => props.theme.colors.text.warning};
color: white;
}
}
.card-description {
font-size: 0.8125rem;
color: ${(props) => props.theme.colors.text.subtext0};
margin-bottom: 0.75rem;
}
.feature-list {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.375rem;
.feature-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-size: 0.8125rem;
color: ${(props) => props.theme.colors.text.subtext0};
.checkmark {
color: ${(props) => props.theme.colors.text.subtext0};
flex-shrink: 0;
margin-top: 0.125rem;
}
}
}
.best-for {
margin-top: 0.75rem;
font-size: 0.75rem;
font-style: italic;
color: ${(props) => props.theme.colors.text.muted};
}
}
.other-format-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.other-format-card {
display: flex;
align-items: center;
gap: 0.75rem;
border-radius: ${(props) => props.theme.border.radius.base};
padding: 0.75rem 1rem;
border: 2px solid ${(props) => props.theme.border.border0};
background-color: ${(props) => props.theme.background.base};
cursor: pointer;
transition: border-color 0.15s ease;
&:hover:not(.selected) {
border-color: ${(props) => props.theme.border.border2};
}
&.selected {
border-color: ${(props) => props.theme.primary.solid};
}
.format-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.format-info {
.format-name {
font-weight: 600;
font-size: 0.875rem;
}
.format-description {
font-size: 0.75rem;
color: ${(props) => props.theme.colors.text.subtext0};
}
}
}
.modal-footer {
display: flex;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid ${(props) => props.theme.border.border0};
}
`;

View File

@@ -1,22 +1,27 @@
import React, { useMemo } from 'react';
import React, { useState, useMemo } from 'react';
import Modal from 'components/Modal';
import { IconUpload, IconLoader2, IconAlertTriangle } from '@tabler/icons';
import Button from 'ui/Button';
import { IconCheck, IconAlertTriangle, IconFileExport } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import Bruno from 'components/Bruno';
import OpenCollectionIcon from 'components/Icons/OpenCollectionIcon';
import exportBrunoCollection from 'utils/collections/export';
import exportPostmanCollection from 'utils/exporters/postman-collection';
import exportOpenCollection from 'utils/exporters/opencollection';
import { cloneDeep } from 'lodash';
import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index';
import { useSelector } from 'react-redux';
import { findCollectionByUid, areItemsLoading } from 'utils/collections/index';
import { useApp } from 'providers/App';
import toast from 'react-hot-toast';
const EXPORT_FORMATS = {
ZIP: 'zip',
YAML: 'yaml',
POSTMAN: 'postman'
};
const ShareCollection = ({ onClose, collectionUid }) => {
const { version } = useApp();
const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid));
const isCollectionLoading = areItemsLoading(collection);
const [selectedFormat, setSelectedFormat] = useState(EXPORT_FORMATS.ZIP);
const [isExporting, setIsExporting] = useState(false);
const hasNonExportableRequestTypes = useMemo(() => {
let types = new Set();
@@ -40,109 +45,157 @@ const ShareCollection = ({ onClose, collectionUid }) => {
};
}, [collection]);
const handleExportBrunoCollection = () => {
const collectionCopy = cloneDeep(collection);
exportBrunoCollection(transformCollectionToSaveToExportAsFile(collectionCopy), version);
onClose();
const handleExportZip = async () => {
try {
const { ipcRenderer } = window;
const result = await ipcRenderer.invoke('renderer:export-collection-zip', collection.pathname, collection.name);
if (result.success) {
toast.success('Collection exported successfully');
}
} catch (error) {
toast.error('Failed to export collection: ' + error.message);
}
};
const handleExportPostmanCollection = () => {
const handleExportYaml = () => {
const collectionCopy = cloneDeep(collection);
exportOpenCollection(transformCollectionToSaveToExportAsFile(collectionCopy));
};
const handleExportPostman = () => {
const collectionCopy = cloneDeep(collection);
exportPostmanCollection(collectionCopy);
onClose();
};
const handleExportOpenCollection = () => {
const collectionCopy = cloneDeep(collection);
exportOpenCollection(transformCollectionToSaveToExportAsFile(collectionCopy), version);
onClose();
const handleProceed = async () => {
if (isCollectionLoading || isExporting) return;
setIsExporting(true);
try {
switch (selectedFormat) {
case EXPORT_FORMATS.ZIP:
await handleExportZip();
break;
case EXPORT_FORMATS.YAML:
handleExportYaml();
break;
case EXPORT_FORMATS.POSTMAN:
handleExportPostman();
break;
}
onClose();
} catch (error) {
console.error('Export error:', error);
} finally {
setIsExporting(false);
}
};
const isDisabled = isCollectionLoading || isExporting;
return (
<Modal
size="md"
title="Share Collection"
confirmText="Close"
handleConfirm={onClose}
handleCancel={onClose}
hideCancel
>
<StyledWrapper className="flex flex-col h-full w-[500px]">
<div className="space-y-2">
<div
className={`share-button ${
isCollectionLoading
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer'
}`}
onClick={isCollectionLoading ? undefined : handleExportBrunoCollection}
<Modal size="lg" title="Share Collection" handleCancel={onClose} hideFooter>
<StyledWrapper className="flex flex-col">
<p className="text-sm mb-4">
Bruno uses{' '}
<a
href="https://opencollection.com"
target="_blank"
rel="noopener noreferrer"
className="opencollection-link"
>
<div className="mr-3 p-1 rounded-full">
{isCollectionLoading ? <IconLoader2 size={28} className="animate-spin" /> : <Bruno width={28} />}
OpenCollection
</a>
{' '}- An open format for API collections
</p>
{/* Bruno Format Section */}
<div className="section-title">Bruno Format</div>
<div className="bruno-format-grid mb-6">
{/* ZIP Option */}
<div
className={`format-card ${selectedFormat === EXPORT_FORMATS.ZIP ? 'selected' : ''} ${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={() => !isDisabled && setSelectedFormat(EXPORT_FORMATS.ZIP)}
>
<div className="card-header">
<span className="card-title">Bruno Collection (ZIP)</span>
<span className="recommended-badge">Recommended</span>
</div>
<div className="flex-1">
<div className="font-medium">Bruno Collection</div>
<div className="text-xs">{isCollectionLoading ? 'Loading collection...' : 'Export in Bruno format'}</div>
<p className="card-description">OpenCollection format organized as folders and files</p>
<div className="feature-list">
<div className="feature-item">
<IconCheck size={14} className="checkmark" />
<span>Folder structure with individual .yml files</span>
</div>
<div className="feature-item">
<IconCheck size={14} className="checkmark" />
<span>Collaborate with your team via pull requests</span>
</div>
<div className="feature-item">
<IconCheck size={14} className="checkmark" />
<span>Extract and open directly in Bruno</span>
</div>
</div>
<p className="best-for">Best for: Team collaboration, version control, publishing</p>
</div>
{/* Single File YAML Option */}
<div
className={`share-button ${
isCollectionLoading
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer'
}`}
onClick={isCollectionLoading ? undefined : handleExportOpenCollection}
className={`format-card ${selectedFormat === EXPORT_FORMATS.YAML ? 'selected' : ''} ${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={() => !isDisabled && setSelectedFormat(EXPORT_FORMATS.YAML)}
>
<div className="mr-3 p-1 rounded-full">
{isCollectionLoading ? (
<IconLoader2 size={28} className="animate-spin" />
) : (
<OpenCollectionIcon size={28} />
)}
<div className="card-header">
<span className="card-title">Single File (YAML)</span>
</div>
<div className="flex-1">
<div className="font-medium">OpenCollection</div>
<div className="text-xs">{isCollectionLoading ? 'Loading collection...' : 'Export in OpenCollection format'}</div>
<p className="card-description">OpenCollection format bundled into one .yml file</p>
<div className="feature-list">
<div className="feature-item">
<IconCheck size={14} className="checkmark" />
<span>Everything in a single YAML file</span>
</div>
<div className="feature-item">
<IconCheck size={14} className="checkmark" />
<span>Paste in a gist or attach to an issue</span>
</div>
</div>
<p className="best-for">Best for: Quick sharing as a single file</p>
</div>
</div>
<div className="section-title">Other Format</div>
<div className="other-format-grid">
<div
className={`flex !flex-col share-button no-padding ${
isCollectionLoading
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer'
}`}
onClick={isCollectionLoading ? undefined : handleExportPostmanCollection}
className={`other-format-card ${selectedFormat === EXPORT_FORMATS.POSTMAN ? 'selected' : ''} ${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={() => !isDisabled && setSelectedFormat(EXPORT_FORMATS.POSTMAN)}
>
{hasNonExportableRequestTypes.has && (
<div className="px-3 py-2 w-full flex items-center note-warning">
<IconAlertTriangle size={16} className="mr-2 flex-shrink-0" />
<span>
Note:
{hasNonExportableRequestTypes.types.join(', ')}
{' '}
requests in this collection will not be exported
</span>
</div>
)}
<div className="flex items-center p-3 w-full">
<div className="mr-3 p-1 rounded-full">
{isCollectionLoading ? (
<IconLoader2 size={28} className="animate-spin" />
) : (
<IconUpload size={28} strokeWidth={1} className="" />
)}
</div>
<div className="flex-1">
<div className="font-medium">Postman Collection</div>
<div className="text-xs">
{isCollectionLoading ? 'Loading collection...' : 'Export in Postman format'}
</div>
</div>
<div className="format-icon">
<IconFileExport size={28} strokeWidth={1.5} />
</div>
<div className="format-info">
<div className="format-name">Postman</div>
<div className="format-description">Export for Postman</div>
</div>
</div>
</div>
{selectedFormat === EXPORT_FORMATS.POSTMAN && hasNonExportableRequestTypes.has && (
<div className="flex items-center mt-4 p-3 rounded" style={{ backgroundColor: 'rgba(251, 191, 36, 0.1)' }}>
<IconAlertTriangle size={16} className="mr-2 flex-shrink-0" style={{ color: '#f59e0b' }} />
<span className="text-sm" style={{ color: '#f59e0b' }}>
Note: {hasNonExportableRequestTypes.types.join(', ')} requests in this collection will not be exported
</span>
</div>
)}
<div className="modal-footer">
<Button
onClick={handleProceed}
disabled={isDisabled}
loading={isExporting}
>
{isExporting ? 'Exporting...' : 'Proceed'}
</Button>
</div>
</StyledWrapper>
</Modal>
);

View File

@@ -3,6 +3,7 @@ const fs = require('fs');
const fsExtra = require('fs-extra');
const os = require('os');
const path = require('path');
const archiver = require('archiver');
const { ipcMain, shell, dialog, app } = require('electron');
const {
parseRequest,
@@ -1952,6 +1953,66 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
const files = await getBruFilesRecursively(collectionPath);
return { name, files, ...variables };
});
ipcMain.handle('renderer:export-collection-zip', async (event, collectionPath, collectionName) => {
try {
if (!collectionPath || !fs.existsSync(collectionPath)) {
throw new Error('Collection path does not exist');
}
const defaultFileName = `${sanitizeName(collectionName)}.zip`;
const { filePath, canceled } = await dialog.showSaveDialog(mainWindow, {
title: 'Export Collection as ZIP',
defaultPath: defaultFileName,
filters: [{ name: 'Zip Files', extensions: ['zip'] }]
});
if (canceled || !filePath) {
return { success: false, canceled: true };
}
const ignoredDirectories = ['node_modules', '.git'];
await new Promise((resolve, reject) => {
const output = fs.createWriteStream(filePath);
const archive = archiver('zip', { zlib: { level: 9 } });
output.on('close', () => {
resolve();
});
archive.on('error', (err) => {
reject(err);
});
archive.pipe(output);
const addDirectoryToArchive = (dirPath, archivePath) => {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
const entryArchivePath = archivePath ? path.join(archivePath, entry.name) : entry.name;
if (entry.isDirectory()) {
if (!ignoredDirectories.includes(entry.name)) {
addDirectoryToArchive(fullPath, entryArchivePath);
}
} else {
archive.file(fullPath, { name: entryArchivePath });
}
}
};
addDirectoryToArchive(collectionPath, '');
archive.finalize();
});
return { success: true, filePath };
} catch (error) {
throw error;
}
});
};
const registerMainEventHandlers = (mainWindow, watcher) => {