mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-25 05:35:41 +00:00
feat: enhance ShareCollection component with export options and UI improvements (#7016)
This commit is contained in:
@@ -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};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user