feat: implement collection migration to YML format (#7669)

This commit is contained in:
naman-bruno
2026-06-22 15:01:43 +05:30
committed by GitHub
parent 21efded2bf
commit d1ebf578b2
18 changed files with 834 additions and 3 deletions

View File

@@ -8,6 +8,7 @@ import ShareCollection from 'components/ShareCollection/index';
import GenerateDocumentation from 'components/Sidebar/Collections/Collection/GenerateDocumentation';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import StyledWrapper from './StyledWrapper';
import Migration from '../Migration';
const Info = ({ collection }) => {
const dispatch = useDispatch();
@@ -126,6 +127,8 @@ const Info = ({ collection }) => {
</div>
</div>
{showGenerateDocumentationModal && <GenerateDocumentation collectionUid={collection.uid} onClose={() => setShowGenerateDocumentationModal(false)} />}
<Migration collection={collection} />
</div>
</div>
</StyledWrapper>

View File

@@ -0,0 +1,55 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
.migration-section {
padding-top: 1.5rem;
margin-top: 1.5rem;
}
.icon-box.migration {
background-color: ${(props) => rgba(props.theme.colors.text.yellow, 0.08)};
border: 1px solid ${(props) => rgba(props.theme.colors.text.yellow, 0.09)};
svg {
color: ${(props) => props.theme.colors.text.yellow};
}
}
.backup-section {
border: 1px solid ${(props) => props.theme.border.border2};
border-radius: ${(props) => props.theme.border.radius.base};
background-color: ${(props) => props.theme.background.mantle};
padding: 12px 14px;
}
.backup-section-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
color: ${(props) => props.theme.text};
}
.backup-section-title {
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 500;
color: ${(props) => props.theme.colors.text.muted};
text-transform: uppercase;
letter-spacing: 0.04em;
}
.backup-section-help {
font-size: ${(props) => props.theme.font.size.base};
color: ${(props) => props.theme.colors.text.muted};
line-height: 1.45;
margin: 0 0 10px 0;
}
.backup-section-action {
display: flex;
justify-content: flex-start;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,135 @@
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import { IconFileCode, IconTransform } from '@tabler/icons';
import toast from 'react-hot-toast';
import { migrateCollectionToYml } from 'providers/ReduxStore/slices/collections/actions';
import Modal from 'components/Modal';
import Button from 'ui/Button';
import StyledWrapper from './StyledWrapper';
const Migration = ({ collection }) => {
const dispatch = useDispatch();
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [isMigrating, setIsMigrating] = useState(false);
const [isExporting, setIsExporting] = useState(false);
// Only show for bru format collections
if (collection.format !== 'bru') {
return null;
}
const handleMigrate = () => {
setIsMigrating(true);
setShowConfirmModal(false);
dispatch(migrateCollectionToYml(collection.uid))
.catch(() => { })
.finally(() => setIsMigrating(false));
};
const handleExportBackup = async () => {
if (isExporting) return;
setIsExporting(true);
try {
const { ipcRenderer } = window;
const result = await ipcRenderer.invoke('renderer:export-collection-zip', collection.pathname, collection.name);
if (result?.success) {
toast.success('Collection backup exported');
}
} catch (error) {
toast.error('Failed to export backup: ' + error.message);
} finally {
setIsExporting(false);
}
};
return (
<StyledWrapper>
<div className="migration-section">
<div className="text-lg font-medium flex items-center gap-2 mb-4">
<IconTransform size={20} stroke={1.5} />
Migration
</div>
<div className="flex items-start">
<div className="icon-box migration flex-shrink-0 p-3 rounded-lg">
<IconFileCode className="w-5 h-5" stroke={1.5} />
</div>
<div className="ml-4">
<div className="font-medium">Migrate to YML file format</div>
<div className="my-1 text-muted text-sm">
This collection is stored in BRU format.{' '}
Switch to YML.{' '}
<a
href="https://blog.usebruno.com/making-yaml-the-default-in-bruno-v3.1"
target="_blank"
rel="noopener noreferrer"
className="text-link hover:underline"
>
Learn More &#x2197;
</a>
</div>
<Button
data-testid="migrate-collection-to-yml-button"
size="sm"
color="primary"
className="mt-2"
onClick={() => setShowConfirmModal(true)}
disabled={isMigrating}
loading={isMigrating}
>
Convert to YML
</Button>
</div>
</div>
</div>
{showConfirmModal && (
<Modal
size="md"
title="Migrate to YML format"
confirmText="Migrate"
confirmDisabled={isExporting}
handleConfirm={handleMigrate}
handleCancel={() => setShowConfirmModal(false)}
>
<div>
<p>
This will convert all files in <strong>{collection.name}</strong> from <code>.bru</code> format to <code>.yml</code> format.
</p>
<div className="mt-4 text-sm text-muted">
<p className="font-medium mb-2">What will happen:</p>
<ul className="list-disc ml-5 flex flex-col gap-1">
<li>All <code>.bru</code> request files will be converted to <code>.yml</code></li>
<li>Environment files will be converted to YML format</li>
<li><code>bruno.json</code> will be replaced with <code>opencollection.yml</code></li>
<li>The collection will be reloaded after migration</li>
</ul>
</div>
<div className="backup-section mt-4">
<div className="backup-section-head">
<span className="backup-section-title">Backup</span>
</div>
<p className="backup-section-help">
Export this collection as a ZIP archive before migrating, in case you want to restore it later.
</p>
<div className="backup-section-action">
<Button
data-testid="export-collection-backup-button"
size="sm"
color="secondary"
variant="outline"
onClick={handleExportBackup}
disabled={isExporting}
>
{isExporting ? 'Exporting…' : 'Export Collection'}
</Button>
</div>
</div>
</div>
</Modal>
)}
</StyledWrapper>
);
};
export default Migration;

View File

@@ -151,6 +151,62 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.danger};
margin-left: 8px;
}
.migrate-yml-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 4px 2px 8px;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: 999px;
background: transparent;
color: ${(props) => props.theme.text};
font-size: 12px;
line-height: 1;
transition: background-color 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
.pill-main {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
border: none;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.pill-label {
font-weight: 500;
}
.pill-dismiss {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.15s ease, background-color 0.15s ease;
&:hover {
opacity: 1;
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
}
}
.display-icon{
padding: 4px;
box-sizing: content-box;

View File

@@ -14,13 +14,14 @@ import {
IconFolder,
IconUpload,
IconFileCode,
IconFileOff
IconFileOff,
IconTransform
} from '@tabler/icons';
import OpenAPISyncIcon from 'components/Icons/OpenAPISync';
import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction, confirmWorkspaceCreation, cancelWorkspaceCreation } from 'providers/ReduxStore/slices/workspaces/actions';
import { updateWorkspace } from 'providers/ReduxStore/slices/workspaces';
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
import { toggleCollectionFileMode } from 'providers/ReduxStore/slices/collections';
import { toggleCollectionFileMode, updateSettingsSelectedTab } from 'providers/ReduxStore/slices/collections';
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
import { uuid } from 'utils/common';
import toast from 'react-hot-toast';
@@ -38,6 +39,19 @@ import classNames from 'classnames';
import StyledWrapper from './StyledWrapper';
import { useTheme } from 'providers/Theme';
const MIGRATE_PILL_DISMISSED_KEY = 'bruno.migrateToYmlPill.dismissed';
const readDismissedCollections = () => {
try {
const raw = localStorage.getItem(MIGRATE_PILL_DISMISSED_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
const CollectionHeader = ({ collection, isScratchCollection }) => {
const dispatch = useDispatch();
const workspaces = useSelector((state) => state.workspaces.workspaces);
@@ -56,6 +70,27 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
const [closeWorkspaceModalOpen, setCloseWorkspaceModalOpen] = useState(false);
const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false);
// Migrate-to-YML pill dismissal state (persisted by collection pathname)
const [migratePillDismissed, setMigratePillDismissed] = useState(true);
useEffect(() => {
if (!collection?.pathname) return;
const dismissed = readDismissedCollections();
setMigratePillDismissed(dismissed.includes(collection.pathname));
}, [collection?.pathname]);
const dismissMigratePill = (e) => {
e?.stopPropagation();
if (!collection?.pathname) return;
const dismissed = readDismissedCollections();
if (!dismissed.includes(collection.pathname)) {
dismissed.push(collection.pathname);
try {
localStorage.setItem(MIGRATE_PILL_DISMISSED_KEY, JSON.stringify(dismissed));
} catch { }
}
setMigratePillDismissed(true);
};
const switcherRef = useRef();
const workspaceActionsRef = useRef();
const workspaceNameInputRef = useRef(null);
@@ -212,6 +247,17 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
);
};
const viewMigrationSettings = () => {
dispatch(
addTab({
uid: collection.uid,
collectionUid: collection.uid,
type: 'collection-settings'
})
);
dispatch(updateSettingsSelectedTab({ collectionUid: collection.uid, tab: 'overview' }));
};
const viewOpenApiSync = () => {
dispatch(addTab({
uid: uuid(),
@@ -584,6 +630,31 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
{/* Right side: Actions (only for regular collections) */}
{!isScratchCollection && (
<div className="flex flex-grow gap-1.5 items-center justify-end">
{collection.format === 'bru' && !migratePillDismissed && (
<div
className="migrate-yml-pill"
data-testid="migrate-yml-pill"
title="Migrate this collection to YML"
>
<button
type="button"
className="pill-main"
onClick={viewMigrationSettings}
>
<IconTransform size={13} strokeWidth={1.5} />
<span className="pill-label">Migrate to YML</span>
</button>
<button
type="button"
className="pill-dismiss"
onClick={dismissMigratePill}
aria-label="Dismiss"
data-testid="migrate-yml-pill-dismiss"
>
<IconX size={12} strokeWidth={2} />
</button>
</div>
)}
{/* OpenAPI Sync - standalone only when configured and beta enabled */}
{hasOpenApiSyncConfigured && (
<ToolHint

View File

@@ -62,7 +62,8 @@ import {
updateCollectionVar,
addTransientDirectory,
addSaveTransientRequestModal,
updatePathParam
updatePathParam,
toggleCollection
} from './index';
import { each } from 'lodash';
@@ -3375,3 +3376,73 @@ export const reopenClosedTab = ({ collectionUid } = {}) => async (dispatch) => {
dispatch(reopenLastClosedTab({ collectionUid }));
await dispatch(ensureActiveTabInCurrentWorkspace());
};
export const migrateCollectionToYml = (collectionUid) => (dispatch, getState) => {
const { ipcRenderer } = window;
return new Promise((resolve, reject) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
if (!collection) {
return reject(new Error('Collection not found'));
}
const collectionPathname = collection.pathname;
const uid = collection.uid;
ipcRenderer
.invoke('renderer:migrate-collection-to-yml', collectionPathname, collectionUid)
.then(async (updatedBrunoConfig) => {
// Remove old collection state so we can recreate it with the new yml config.
// openCollectionEvent requires no existing collection with the same pathname.
dispatch(_removeCollection({ collectionUid }));
dispatch(closeAllCollectionTabs({ collectionUid }));
try {
// Reopen the collection with updated config (now yml format)
await dispatch(openCollectionEvent(uid, collectionPathname, updatedBrunoConfig));
// Mount the collection (starts the watcher and loads items)
await dispatch(mountCollection({
collectionUid: uid,
collectionPathname: collectionPathname,
brunoConfig: updatedBrunoConfig
}));
} catch (reopenError) {
// Files on disk are already yml; best-effort recovery so the
// collection doesn't disappear from the UI. openCollectionEvent is
// a no-op if it already succeeded, and mountCollection is what we
// retry when it was the failing step.
try {
await dispatch(openCollectionEvent(uid, collectionPathname, updatedBrunoConfig));
await dispatch(mountCollection({
collectionUid: uid,
collectionPathname: collectionPathname,
brunoConfig: updatedBrunoConfig
}));
} catch (_) {}
throw reopenError;
}
// Expand the collection in the sidebar (only if collapsed)
const reopenedCollection = findCollectionByUid(getState().collections.collections, uid);
if (reopenedCollection?.collapsed) {
dispatch(toggleCollection(uid));
}
// Reopen collection settings on the overview tab
dispatch(addTab({
uid: uid,
collectionUid: uid,
type: 'collection-settings'
}));
dispatch(updateSettingsSelectedTab({ collectionUid: uid, tab: 'overview' }));
toast.success('Collection migrated to YML format successfully');
resolve();
})
.catch((err) => {
toast.error(`Migration failed: ${err.message || 'Unknown error'}`);
reject(err);
});
});
};

View File

@@ -42,6 +42,7 @@ const {
isWindowsOS,
hasRequestExtension,
getCollectionFormat,
searchForFiles,
searchForRequestFiles,
validateName,
getCollectionStats,
@@ -2624,6 +2625,124 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
throw error;
}
});
ipcMain.handle('renderer:migrate-collection-to-yml', async (event, collectionPathname, collectionUid) => {
const format = getCollectionFormat(collectionPathname);
if (format === 'yml') {
throw new Error('Collection is already in YML format');
}
// Stop the watcher during migration to avoid triggering events
if (watcher) {
watcher.removeWatcher(collectionPathname, mainWindow, collectionUid);
}
// Track all written yml files so we can roll back on failure
const writtenYmlFiles = [];
try {
const brunoJsonPath = path.join(collectionPathname, 'bruno.json');
const brunoJsonContent = fs.readFileSync(brunoJsonPath, 'utf8');
const brunoConfig = JSON.parse(brunoJsonContent);
const collectionBruPath = path.join(collectionPathname, 'collection.bru');
let collectionRoot = {};
if (fs.existsSync(collectionBruPath)) {
const collectionBruContent = fs.readFileSync(collectionBruPath, 'utf8');
collectionRoot = parseCollection(collectionBruContent, { format: 'bru' });
}
const ymlBrunoConfig = { ...brunoConfig };
delete ymlBrunoConfig.version;
ymlBrunoConfig.opencollection = '1.0.0';
const ocYmlPath = path.join(collectionPathname, 'opencollection.yml');
const ymlCollectionContent = stringifyCollection(collectionRoot, ymlBrunoConfig, { format: 'yml' });
await writeFile(ocYmlPath, ymlCollectionContent);
writtenYmlFiles.push(ocYmlPath);
const bruFiles = searchForFiles(collectionPathname, '.bru');
const envDirPath = path.join(collectionPathname, 'environments');
const bruFilesToDelete = [];
for (const bruFilePath of bruFiles) {
const basename = path.basename(bruFilePath);
const dirname = path.dirname(bruFilePath);
if (basename === 'collection.bru' && path.normalize(dirname) === path.normalize(collectionPathname)) {
bruFilesToDelete.push(bruFilePath);
continue;
}
if (path.normalize(dirname) === path.normalize(envDirPath)) {
continue;
}
if (basename === 'folder.bru') {
const folderBruContent = fs.readFileSync(bruFilePath, 'utf8');
const folderData = parseFolder(folderBruContent, { format: 'bru' });
const ymlContent = stringifyFolder(folderData, { format: 'yml' });
const ymlFilePath = path.join(dirname, 'folder.yml');
await writeFile(ymlFilePath, ymlContent);
writtenYmlFiles.push(ymlFilePath);
bruFilesToDelete.push(bruFilePath);
continue;
}
const bruContent = fs.readFileSync(bruFilePath, 'utf8');
const requestData = parseRequest(bruContent, { format: 'bru' });
const ymlContent = stringifyRequest(requestData, { format: 'yml' });
const ymlFilePath = bruFilePath.replace(/\.bru$/, '.yml');
await writeFile(ymlFilePath, ymlContent);
writtenYmlFiles.push(ymlFilePath);
bruFilesToDelete.push(bruFilePath);
}
if (fs.existsSync(envDirPath)) {
const envBruFiles = searchForFiles(envDirPath, '.bru');
for (const envBruFilePath of envBruFiles) {
const envBruContent = fs.readFileSync(envBruFilePath, 'utf8');
const envData = parseEnvironment(envBruContent, { format: 'bru' });
const ymlContent = stringifyEnvironment(envData, { format: 'yml' });
const ymlFilePath = envBruFilePath.replace(/\.bru$/, '.yml');
await writeFile(ymlFilePath, ymlContent);
writtenYmlFiles.push(ymlFilePath);
bruFilesToDelete.push(envBruFilePath);
}
}
for (const bruFile of bruFilesToDelete) {
fs.unlinkSync(bruFile);
}
fs.unlinkSync(brunoJsonPath);
const { size, filesCount } = await getCollectionStats(collectionPathname);
ymlBrunoConfig.size = size;
ymlBrunoConfig.filesCount = filesCount;
return ymlBrunoConfig;
} catch (error) {
for (const ymlFile of writtenYmlFiles) {
try {
if (fs.existsSync(ymlFile)) {
fs.unlinkSync(ymlFile);
}
} catch (_) {
}
}
// Restart the watcher on the original bru collection
if (watcher) {
try {
const config = JSON.parse(fs.readFileSync(path.join(collectionPathname, 'bruno.json'), 'utf8'));
watcher.addWatcher(mainWindow, collectionPathname, collectionUid, config);
} catch (watcherError) {
console.error('Failed to restart watcher after migration error:', watcherError);
}
}
throw error;
}
});
};
const registerMainEventHandlers = (mainWindow, watcher) => {