Skip to content

Commit d1ebf57

Browse files
authored
feat: implement collection migration to YML format (usebruno#7669)
1 parent 21efded commit d1ebf57

18 files changed

Lines changed: 834 additions & 3 deletions

File tree

packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import ShareCollection from 'components/ShareCollection/index';
88
import GenerateDocumentation from 'components/Sidebar/Collections/Collection/GenerateDocumentation';
99
import { addTab } from 'providers/ReduxStore/slices/tabs';
1010
import StyledWrapper from './StyledWrapper';
11+
import Migration from '../Migration';
1112

1213
const Info = ({ collection }) => {
1314
const dispatch = useDispatch();
@@ -126,6 +127,8 @@ const Info = ({ collection }) => {
126127
</div>
127128
</div>
128129
{showGenerateDocumentationModal && <GenerateDocumentation collectionUid={collection.uid} onClose={() => setShowGenerateDocumentationModal(false)} />}
130+
131+
<Migration collection={collection} />
129132
</div>
130133
</div>
131134
</StyledWrapper>
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import styled from 'styled-components';
2+
import { rgba } from 'polished';
3+
4+
const StyledWrapper = styled.div`
5+
.migration-section {
6+
padding-top: 1.5rem;
7+
margin-top: 1.5rem;
8+
}
9+
10+
.icon-box.migration {
11+
background-color: ${(props) => rgba(props.theme.colors.text.yellow, 0.08)};
12+
border: 1px solid ${(props) => rgba(props.theme.colors.text.yellow, 0.09)};
13+
14+
svg {
15+
color: ${(props) => props.theme.colors.text.yellow};
16+
}
17+
}
18+
19+
.backup-section {
20+
border: 1px solid ${(props) => props.theme.border.border2};
21+
border-radius: ${(props) => props.theme.border.radius.base};
22+
background-color: ${(props) => props.theme.background.mantle};
23+
padding: 12px 14px;
24+
}
25+
26+
.backup-section-head {
27+
display: flex;
28+
align-items: center;
29+
gap: 8px;
30+
margin-bottom: 6px;
31+
color: ${(props) => props.theme.text};
32+
}
33+
34+
.backup-section-title {
35+
font-size: ${(props) => props.theme.font.size.sm};
36+
font-weight: 500;
37+
color: ${(props) => props.theme.colors.text.muted};
38+
text-transform: uppercase;
39+
letter-spacing: 0.04em;
40+
}
41+
42+
.backup-section-help {
43+
font-size: ${(props) => props.theme.font.size.base};
44+
color: ${(props) => props.theme.colors.text.muted};
45+
line-height: 1.45;
46+
margin: 0 0 10px 0;
47+
}
48+
49+
.backup-section-action {
50+
display: flex;
51+
justify-content: flex-start;
52+
}
53+
`;
54+
55+
export default StyledWrapper;
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import React, { useState } from 'react';
2+
import { useDispatch } from 'react-redux';
3+
import { IconFileCode, IconTransform } from '@tabler/icons';
4+
import toast from 'react-hot-toast';
5+
import { migrateCollectionToYml } from 'providers/ReduxStore/slices/collections/actions';
6+
import Modal from 'components/Modal';
7+
import Button from 'ui/Button';
8+
import StyledWrapper from './StyledWrapper';
9+
10+
const Migration = ({ collection }) => {
11+
const dispatch = useDispatch();
12+
const [showConfirmModal, setShowConfirmModal] = useState(false);
13+
const [isMigrating, setIsMigrating] = useState(false);
14+
const [isExporting, setIsExporting] = useState(false);
15+
16+
// Only show for bru format collections
17+
if (collection.format !== 'bru') {
18+
return null;
19+
}
20+
21+
const handleMigrate = () => {
22+
setIsMigrating(true);
23+
setShowConfirmModal(false);
24+
dispatch(migrateCollectionToYml(collection.uid))
25+
.catch(() => { })
26+
.finally(() => setIsMigrating(false));
27+
};
28+
29+
const handleExportBackup = async () => {
30+
if (isExporting) return;
31+
setIsExporting(true);
32+
try {
33+
const { ipcRenderer } = window;
34+
const result = await ipcRenderer.invoke('renderer:export-collection-zip', collection.pathname, collection.name);
35+
if (result?.success) {
36+
toast.success('Collection backup exported');
37+
}
38+
} catch (error) {
39+
toast.error('Failed to export backup: ' + error.message);
40+
} finally {
41+
setIsExporting(false);
42+
}
43+
};
44+
45+
return (
46+
<StyledWrapper>
47+
<div className="migration-section">
48+
<div className="text-lg font-medium flex items-center gap-2 mb-4">
49+
<IconTransform size={20} stroke={1.5} />
50+
Migration
51+
</div>
52+
53+
<div className="flex items-start">
54+
<div className="icon-box migration flex-shrink-0 p-3 rounded-lg">
55+
<IconFileCode className="w-5 h-5" stroke={1.5} />
56+
</div>
57+
<div className="ml-4">
58+
<div className="font-medium">Migrate to YML file format</div>
59+
<div className="my-1 text-muted text-sm">
60+
This collection is stored in BRU format.{' '}
61+
Switch to YML.{' '}
62+
<a
63+
href="https://blog.usebruno.com/making-yaml-the-default-in-bruno-v3.1"
64+
target="_blank"
65+
rel="noopener noreferrer"
66+
className="text-link hover:underline"
67+
>
68+
Learn More &#x2197;
69+
</a>
70+
</div>
71+
<Button
72+
data-testid="migrate-collection-to-yml-button"
73+
size="sm"
74+
color="primary"
75+
className="mt-2"
76+
onClick={() => setShowConfirmModal(true)}
77+
disabled={isMigrating}
78+
loading={isMigrating}
79+
>
80+
Convert to YML
81+
</Button>
82+
</div>
83+
</div>
84+
</div>
85+
86+
{showConfirmModal && (
87+
<Modal
88+
size="md"
89+
title="Migrate to YML format"
90+
confirmText="Migrate"
91+
confirmDisabled={isExporting}
92+
handleConfirm={handleMigrate}
93+
handleCancel={() => setShowConfirmModal(false)}
94+
>
95+
<div>
96+
<p>
97+
This will convert all files in <strong>{collection.name}</strong> from <code>.bru</code> format to <code>.yml</code> format.
98+
</p>
99+
<div className="mt-4 text-sm text-muted">
100+
<p className="font-medium mb-2">What will happen:</p>
101+
<ul className="list-disc ml-5 flex flex-col gap-1">
102+
<li>All <code>.bru</code> request files will be converted to <code>.yml</code></li>
103+
<li>Environment files will be converted to YML format</li>
104+
<li><code>bruno.json</code> will be replaced with <code>opencollection.yml</code></li>
105+
<li>The collection will be reloaded after migration</li>
106+
</ul>
107+
</div>
108+
<div className="backup-section mt-4">
109+
<div className="backup-section-head">
110+
<span className="backup-section-title">Backup</span>
111+
</div>
112+
<p className="backup-section-help">
113+
Export this collection as a ZIP archive before migrating, in case you want to restore it later.
114+
</p>
115+
<div className="backup-section-action">
116+
<Button
117+
data-testid="export-collection-backup-button"
118+
size="sm"
119+
color="secondary"
120+
variant="outline"
121+
onClick={handleExportBackup}
122+
disabled={isExporting}
123+
>
124+
{isExporting ? 'Exporting…' : 'Export Collection'}
125+
</Button>
126+
</div>
127+
</div>
128+
</div>
129+
</Modal>
130+
)}
131+
</StyledWrapper>
132+
);
133+
};
134+
135+
export default Migration;

packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,62 @@ const StyledWrapper = styled.div`
151151
color: ${(props) => props.theme.colors.text.danger};
152152
margin-left: 8px;
153153
}
154+
155+
.migrate-yml-pill {
156+
display: inline-flex;
157+
align-items: center;
158+
gap: 6px;
159+
padding: 2px 4px 2px 8px;
160+
border: 1px solid ${(props) => props.theme.input.border};
161+
border-radius: 999px;
162+
background: transparent;
163+
color: ${(props) => props.theme.text};
164+
font-size: 12px;
165+
line-height: 1;
166+
transition: background-color 0.15s ease;
167+
168+
&:hover {
169+
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
170+
}
171+
172+
.pill-main {
173+
display: inline-flex;
174+
align-items: center;
175+
gap: 6px;
176+
padding: 0;
177+
border: none;
178+
background: transparent;
179+
color: inherit;
180+
font: inherit;
181+
cursor: pointer;
182+
}
183+
184+
.pill-label {
185+
font-weight: 500;
186+
}
187+
188+
.pill-dismiss {
189+
display: inline-flex;
190+
align-items: center;
191+
justify-content: center;
192+
width: 16px;
193+
height: 16px;
194+
padding: 0;
195+
border: none;
196+
border-radius: 50%;
197+
background: transparent;
198+
color: inherit;
199+
cursor: pointer;
200+
opacity: 0.6;
201+
transition: opacity 0.15s ease, background-color 0.15s ease;
202+
203+
&:hover {
204+
opacity: 1;
205+
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
206+
}
207+
}
208+
}
209+
154210
.display-icon{
155211
padding: 4px;
156212
box-sizing: content-box;

packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@ import {
1414
IconFolder,
1515
IconUpload,
1616
IconFileCode,
17-
IconFileOff
17+
IconFileOff,
18+
IconTransform
1819
} from '@tabler/icons';
1920
import OpenAPISyncIcon from 'components/Icons/OpenAPISync';
2021
import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction, confirmWorkspaceCreation, cancelWorkspaceCreation } from 'providers/ReduxStore/slices/workspaces/actions';
2122
import { updateWorkspace } from 'providers/ReduxStore/slices/workspaces';
2223
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
23-
import { toggleCollectionFileMode } from 'providers/ReduxStore/slices/collections';
24+
import { toggleCollectionFileMode, updateSettingsSelectedTab } from 'providers/ReduxStore/slices/collections';
2425
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
2526
import { uuid } from 'utils/common';
2627
import toast from 'react-hot-toast';
@@ -38,6 +39,19 @@ import classNames from 'classnames';
3839
import StyledWrapper from './StyledWrapper';
3940
import { useTheme } from 'providers/Theme';
4041

42+
const MIGRATE_PILL_DISMISSED_KEY = 'bruno.migrateToYmlPill.dismissed';
43+
44+
const readDismissedCollections = () => {
45+
try {
46+
const raw = localStorage.getItem(MIGRATE_PILL_DISMISSED_KEY);
47+
if (!raw) return [];
48+
const parsed = JSON.parse(raw);
49+
return Array.isArray(parsed) ? parsed : [];
50+
} catch {
51+
return [];
52+
}
53+
};
54+
4155
const CollectionHeader = ({ collection, isScratchCollection }) => {
4256
const dispatch = useDispatch();
4357
const workspaces = useSelector((state) => state.workspaces.workspaces);
@@ -56,6 +70,27 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
5670
const [closeWorkspaceModalOpen, setCloseWorkspaceModalOpen] = useState(false);
5771
const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false);
5872

73+
// Migrate-to-YML pill dismissal state (persisted by collection pathname)
74+
const [migratePillDismissed, setMigratePillDismissed] = useState(true);
75+
useEffect(() => {
76+
if (!collection?.pathname) return;
77+
const dismissed = readDismissedCollections();
78+
setMigratePillDismissed(dismissed.includes(collection.pathname));
79+
}, [collection?.pathname]);
80+
81+
const dismissMigratePill = (e) => {
82+
e?.stopPropagation();
83+
if (!collection?.pathname) return;
84+
const dismissed = readDismissedCollections();
85+
if (!dismissed.includes(collection.pathname)) {
86+
dismissed.push(collection.pathname);
87+
try {
88+
localStorage.setItem(MIGRATE_PILL_DISMISSED_KEY, JSON.stringify(dismissed));
89+
} catch { }
90+
}
91+
setMigratePillDismissed(true);
92+
};
93+
5994
const switcherRef = useRef();
6095
const workspaceActionsRef = useRef();
6196
const workspaceNameInputRef = useRef(null);
@@ -212,6 +247,17 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
212247
);
213248
};
214249

250+
const viewMigrationSettings = () => {
251+
dispatch(
252+
addTab({
253+
uid: collection.uid,
254+
collectionUid: collection.uid,
255+
type: 'collection-settings'
256+
})
257+
);
258+
dispatch(updateSettingsSelectedTab({ collectionUid: collection.uid, tab: 'overview' }));
259+
};
260+
215261
const viewOpenApiSync = () => {
216262
dispatch(addTab({
217263
uid: uuid(),
@@ -584,6 +630,31 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
584630
{/* Right side: Actions (only for regular collections) */}
585631
{!isScratchCollection && (
586632
<div className="flex flex-grow gap-1.5 items-center justify-end">
633+
{collection.format === 'bru' && !migratePillDismissed && (
634+
<div
635+
className="migrate-yml-pill"
636+
data-testid="migrate-yml-pill"
637+
title="Migrate this collection to YML"
638+
>
639+
<button
640+
type="button"
641+
className="pill-main"
642+
onClick={viewMigrationSettings}
643+
>
644+
<IconTransform size={13} strokeWidth={1.5} />
645+
<span className="pill-label">Migrate to YML</span>
646+
</button>
647+
<button
648+
type="button"
649+
className="pill-dismiss"
650+
onClick={dismissMigratePill}
651+
aria-label="Dismiss"
652+
data-testid="migrate-yml-pill-dismiss"
653+
>
654+
<IconX size={12} strokeWidth={2} />
655+
</button>
656+
</div>
657+
)}
587658
{/* OpenAPI Sync - standalone only when configured and beta enabled */}
588659
{hasOpenApiSyncConfigured && (
589660
<ToolHint

0 commit comments

Comments
 (0)