From 5904c36cdbf273988cf16885f75df45f60ac28ce Mon Sep 17 00:00:00 2001 From: naman-bruno Date: Mon, 2 Feb 2026 21:01:03 +0530 Subject: [PATCH] feat: enhance ShareCollection component with export options and UI improvements (#7016) --- .../ShareCollection/StyledWrapper.js | 167 +++++++++++-- .../src/components/ShareCollection/index.js | 223 +++++++++++------- packages/bruno-electron/src/ipc/collection.js | 61 +++++ 3 files changed, 346 insertions(+), 105 deletions(-) diff --git a/packages/bruno-app/src/components/ShareCollection/StyledWrapper.js b/packages/bruno-app/src/components/ShareCollection/StyledWrapper.js index d8614cc77..e1eb17204 100644 --- a/packages/bruno-app/src/components/ShareCollection/StyledWrapper.js +++ b/packages/bruno-app/src/components/ShareCollection/StyledWrapper.js @@ -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}; } `; diff --git a/packages/bruno-app/src/components/ShareCollection/index.js b/packages/bruno-app/src/components/ShareCollection/index.js index 03863d3d8..d3ffe0838 100644 --- a/packages/bruno-app/src/components/ShareCollection/index.js +++ b/packages/bruno-app/src/components/ShareCollection/index.js @@ -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 ( - - -
-
+ +

+ Bruno uses{' '} + -

- {isCollectionLoading ? : } + OpenCollection + + {' '}- An open format for API collections +

+ + {/* Bruno Format Section */} +
Bruno Format
+
+ {/* ZIP Option */} +
!isDisabled && setSelectedFormat(EXPORT_FORMATS.ZIP)} + > +
+ Bruno Collection (ZIP) + Recommended
-
-
Bruno Collection
-
{isCollectionLoading ? 'Loading collection...' : 'Export in Bruno format'}
+

OpenCollection format organized as folders and files

+
+
+ + Folder structure with individual .yml files +
+
+ + Collaborate with your team via pull requests +
+
+ + Extract and open directly in Bruno +
+

Best for: Team collaboration, version control, publishing

+ {/* Single File YAML Option */}
!isDisabled && setSelectedFormat(EXPORT_FORMATS.YAML)} > -
- {isCollectionLoading ? ( - - ) : ( - - )} +
+ Single File (YAML)
-
-
OpenCollection
-
{isCollectionLoading ? 'Loading collection...' : 'Export in OpenCollection format'}
+

OpenCollection format bundled into one .yml file

+
+
+ + Everything in a single YAML file +
+
+ + Paste in a gist or attach to an issue +
+

Best for: Quick sharing as a single file

+
+
Other Format
+
!isDisabled && setSelectedFormat(EXPORT_FORMATS.POSTMAN)} > - {hasNonExportableRequestTypes.has && ( -
- - - Note: - {hasNonExportableRequestTypes.types.join(', ')} - {' '} - requests in this collection will not be exported - -
- )} -
-
- {isCollectionLoading ? ( - - ) : ( - - )} -
-
-
Postman Collection
-
- {isCollectionLoading ? 'Loading collection...' : 'Export in Postman format'} -
-
+
+ +
+
+
Postman
+
Export for Postman
+ + {selectedFormat === EXPORT_FORMATS.POSTMAN && hasNonExportableRequestTypes.has && ( +
+ + + Note: {hasNonExportableRequestTypes.types.join(', ')} requests in this collection will not be exported + +
+ )} + +
+ +
); diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index a02118fb8..853425edc 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -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) => {