[Feature] : Settings on folder level (#1334)

* feat(folder_settings): enable settings tab from folder, currently not using folder.bru

* feat(folder_settings): read and write in folder settings only in headers, ignore folder.bru file il requests list

* feat(folder_settings): merge collection and folder settings when sending network request

* feat(folder_settings): remove console, testing headers merging working fine

* feat(folder_settings): add missing endl for prettier check, remove redundant imports

---------

Co-authored-by: Baptiste POULAIN <baptistepoulain@MAC882.local>
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
This commit is contained in:
Baptiste Poulain 2024-06-24 08:50:26 +02:00 committed by GitHub
parent fc626041e2
commit c1a57d30dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 526 additions and 26 deletions

View File

@ -0,0 +1,56 @@
import styled from 'styled-components';
const Wrapper = styled.div`
table {
width: 100%;
border-collapse: collapse;
font-weight: 600;
table-layout: fixed;
thead,
td {
border: 1px solid ${(props) => props.theme.table.border};
}
thead {
color: ${(props) => props.theme.table.thead.color};
font-size: 0.8125rem;
user-select: none;
}
td {
padding: 6px 10px;
&:nth-child(1) {
width: 30%;
}
&:nth-child(3) {
width: 70px;
}
}
}
.btn-add-header {
font-size: 0.8125rem;
}
input[type='text'] {
width: 100%;
border: solid 1px transparent;
outline: none !important;
background-color: inherit;
&:focus {
outline: none !important;
border: solid 1px transparent;
}
}
input[type='checkbox'] {
cursor: pointer;
position: relative;
top: 1px;
}
`;
export default Wrapper;

View File

@ -0,0 +1,150 @@
import React from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { addFolderHeader, updateFolderHeader, deleteFolderHeader } from 'providers/ReduxStore/slices/collections';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const Headers = ({ collection, folder }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const headers = get(folder, 'root.request.headers', []);
const addHeader = () => {
dispatch(
addFolderHeader({
collectionUid: collection.uid,
folderUid: folder.uid
})
);
};
const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
const handleHeaderValueChange = (e, _header, type) => {
const header = cloneDeep(_header);
switch (type) {
case 'name': {
header.name = e.target.value;
break;
}
case 'value': {
header.value = e.target.value;
break;
}
case 'enabled': {
header.enabled = e.target.checked;
break;
}
}
dispatch(
updateFolderHeader({
header: header,
collectionUid: collection.uid,
folderUid: folder.uid
})
);
};
const handleRemoveHeader = (header) => {
dispatch(
deleteFolderHeader({
headerUid: header.uid,
collectionUid: collection.uid,
folderUid: folder.uid
})
);
};
return (
<StyledWrapper className="w-full">
<table>
<thead>
<tr>
<td>Name</td>
<td>Value</td>
<td></td>
</tr>
</thead>
<tbody>
{headers && headers.length
? headers.map((header) => {
return (
<tr key={header.uid}>
<td>
<SingleLineEditor
value={header.name}
theme={storedTheme}
onSave={handleSave}
onChange={(newValue) =>
handleHeaderValueChange(
{
target: {
value: newValue
}
},
header,
'name'
)
}
autocomplete={headerAutoCompleteList}
collection={collection}
/>
</td>
<td>
<SingleLineEditor
value={header.value}
theme={storedTheme}
onSave={handleSave}
onChange={(newValue) =>
handleHeaderValueChange(
{
target: {
value: newValue
}
},
header,
'value'
)
}
collection={collection}
/>
</td>
<td>
<div className="flex items-center">
<input
type="checkbox"
checked={header.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleHeaderValueChange(e, header, 'enabled')}
/>
<button tabIndex="-1" onClick={() => handleRemoveHeader(header)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>
</td>
</tr>
);
})
: null}
</tbody>
</table>
<button className="btn-add-header text-link pr-2 py-3 mt-2 select-none" onClick={addHeader}>
+ Add Header
</button>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
Save
</button>
</div>
</StyledWrapper>
);
};
export default Headers;

View File

@ -0,0 +1,52 @@
import React from 'react';
import classnames from 'classnames';
import { updateSettingsSelectedTab } from 'providers/ReduxStore/slices/collections';
import { useDispatch } from 'react-redux';
import Headers from './Headers';
const FolderSettings = ({ collection, folder }) => {
const dispatch = useDispatch();
const tab = folder?.settingsSelectedTab || 'headers';
const setTab = (tab) => {
dispatch(
updateSettingsSelectedTab({
collectionUid: folder.collectionUid,
folderUid: folder.uid,
tab
})
);
};
const getTabPanel = (tab) => {
switch (tab) {
case 'headers': {
return <Headers collection={collection} folder={folder} />;
}
// TODO: Add auth
}
};
const getTabClassname = (tabName) => {
return classnames(`tab select-none ${tabName}`, {
active: tabName === tab
});
};
return (
<div className="flex flex-col h-full relative px-4 py-4">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
Headers
</div>
{/* <div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
Auth
</div> */}
</div>
<section className={`flex ${['auth', 'script', 'docs', 'clientCert'].includes(tab) ? '' : 'mt-4'}`}>
{getTabPanel(tab)}
</section>
</div>
);
};
export default FolderSettings;

View File

@ -18,6 +18,7 @@ import CollectionSettings from 'components/CollectionSettings';
import { DocExplorer } from '@usebruno/graphql-docs';
import StyledWrapper from './StyledWrapper';
import FolderSettings from 'components/FolderSettings';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 350;
@ -131,6 +132,10 @@ const RequestTabPanel = () => {
if (focusedTab.type === 'collection-settings') {
return <CollectionSettings collection={collection} />;
}
if (focusedTab.type === 'folder-settings') {
const folder = findItemInCollection(collection, focusedTab.folderUid);
return <FolderSettings collection={collection} folder={folder} />;
}
const item = findItemInCollection(collection, activeTabUid);
if (!item || !item.uid) {

View File

@ -44,7 +44,7 @@ const CollectionToolBar = ({ collection }) => {
<div className="flex items-center p-2">
<div className="flex flex-1 items-center cursor-pointer hover:underline" onClick={viewCollectionSettings}>
<IconFiles size={18} strokeWidth={1.5} />
<span className="ml-2 mr-4 font-semibold">{collection.name}</span>
<span className="ml-2 mr-4 font-semibold">{collection?.name || 'Folder'}</span>
</div>
<div className="flex flex-1 items-center justify-end">
<span className="mr-2">

View File

@ -1,22 +1,30 @@
import React from 'react';
import { IconVariable, IconSettings, IconRun } from '@tabler/icons';
const SpecialTab = ({ handleCloseClick, type }) => {
const getTabInfo = (type) => {
const SpecialTab = ({ handleCloseClick, type, folderName }) => {
const getTabInfo = (type, folderName) => {
switch (type) {
case 'collection-settings': {
return (
<>
<IconSettings size={18} strokeWidth={1.5} className="text-yellow-600" />
<span className="ml-1">Collection</span>
<span className="ml-1 leading-6">Collection</span>
</>
);
}
case 'folder-settings': {
return (
<div className="flex items-center flex-nowrap overflow-hidden">
<IconSettings size={18} strokeWidth={1.5} className="text-yellow-600 min-w-[18px]" />
<span className="ml-1 leading-6 truncate">{folderName || 'Folder'}</span>
</div>
);
}
case 'variables': {
return (
<>
<IconVariable size={18} strokeWidth={1.5} className="text-yellow-600" />
<span className="ml-1">Variables</span>
<span className="ml-1 leading-6">Variables</span>
</>
);
}
@ -24,7 +32,7 @@ const SpecialTab = ({ handleCloseClick, type }) => {
return (
<>
<IconRun size={18} strokeWidth={1.5} className="text-yellow-600" />
<span className="ml-1">Runner</span>
<span className="ml-1 leading-6">Runner</span>
</>
);
}
@ -33,7 +41,7 @@ const SpecialTab = ({ handleCloseClick, type }) => {
return (
<>
<div className="flex items-center tab-label pl-2">{getTabInfo(type)}</div>
<div className="flex items-center tab-label pl-2">{getTabInfo(type, folderName)}</div>
<div className="flex px-2 close-icon-container" onClick={(e) => handleCloseClick(e)}>
<svg focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512" className="close-icon">
<path

View File

@ -13,7 +13,7 @@ import RequestTabNotFound from './RequestTabNotFound';
import SpecialTab from './SpecialTab';
import StyledWrapper from './StyledWrapper';
const RequestTab = ({ tab, collection }) => {
const RequestTab = ({ tab, collection, folderUid }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const [showConfirmClose, setShowConfirmClose] = useState(false);
@ -80,11 +80,11 @@ const RequestTab = ({ tab, collection }) => {
return color;
};
if (['collection-settings', 'variables', 'collection-runner'].includes(tab.type)) {
const folder = folderUid ? findItemInCollection(collection, folderUid) : null;
if (['collection-settings', 'folder-settings', 'variables', 'collection-runner'].includes(tab.type)) {
return (
<StyledWrapper className="flex items-center justify-between tab-container px-1">
<SpecialTab handleCloseClick={handleCloseClick} type={tab.type} />
<SpecialTab handleCloseClick={handleCloseClick} type={tab.type} folderName={folder?.name} />
</StyledWrapper>
);
}

View File

@ -75,7 +75,6 @@ const RequestTabs = () => {
'has-chevrons': showChevrons
});
};
// Todo: Must support ephemeral requests
return (
<StyledWrapper className={getRootClassname()}>
@ -111,7 +110,7 @@ const RequestTabs = () => {
role="tab"
onClick={() => handleClick(tab)}
>
<RequestTab key={tab.uid} tab={tab} collection={activeCollection} />
<RequestTab key={tab.uid} tab={tab} collection={activeCollection} folderUid={tab.folderUid} />
</li>
);
})

View File

@ -24,6 +24,7 @@ import { hideHomePage } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import NetworkError from 'components/ResponsePane/NetworkError/index';
import { uuid } from 'utils/common';
const CollectionItem = ({ item, collection, searchText }) => {
const tabs = useSelector((state) => state.tabs.tabs);
@ -188,6 +189,16 @@ const CollectionItem = ({ item, collection, searchText }) => {
toast.error('URL is required');
}
};
const viewFolderSettings = () => {
dispatch(
addTab({
uid: uuid(),
collectionUid: collection.uid,
folderUid: item.uid,
type: 'folder-settings'
})
);
};
const requestItems = sortRequestItems(filter(item.items, (i) => isItemARequest(i)));
const folderItems = sortFolderItems(filter(item.items, (i) => isItemAFolder(i)));
@ -345,6 +356,17 @@ const CollectionItem = ({ item, collection, searchText }) => {
>
Delete
</div>
{isFolder && (
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
viewFolderSettings();
}}
>
Settings
</div>
)}
</Dropdown>
</div>
</div>

View File

@ -11,6 +11,7 @@ import {
collectionUnlinkFileEvent,
processEnvUpdateEvent,
runFolderEvent,
folderAddFileEvent,
runRequestEvent,
scriptEnvironmentUpdateEvent
} from 'providers/ReduxStore/slices/collections';
@ -48,6 +49,13 @@ const useIpcEvents = () => {
})
);
}
if (type === 'addFileDir') {
dispatch(
folderAddFileEvent({
file: val
})
);
}
if (type === 'change') {
dispatch(
collectionChangeFileEvent({

View File

@ -14,10 +14,11 @@ import {
findParentItemInCollection,
getItemsToResequence,
isItemAFolder,
refreshUidsInItem,
findItemInCollectionByPathname,
isItemARequest,
moveCollectionItem,
moveCollectionItemToRootOfCollection,
refreshUidsInItem,
transformRequestToSaveToFilesystem
} from 'utils/collections';
import { uuid, waitForNextTick } from 'utils/common';
@ -143,7 +144,65 @@ export const saveCollectionRoot = (collectionUid) => (dispatch, getState) => {
});
};
export const sendCollectionOauth2Request = (collectionUid) => (dispatch, getState) => {
export const saveFolderRoot = (collectionUid, folderUid) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
const folder = findItemInCollection(collection, folderUid);
return new Promise((resolve, reject) => {
if (!collection) {
return reject(new Error('Collection not found'));
}
if (!folder) {
return reject(new Error('Folder not found'));
}
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:save-folder-root', folder.pathname, folder.root)
.then(() => toast.success('Folder Settings saved successfully'))
.then(resolve)
.catch((err) => {
toast.error('Failed to save folder settings!');
reject(err);
});
});
};
export const retrieveDirectoriesBetween = (pathname, parameter, filename) => {
const parameterIndex = pathname.indexOf(parameter);
const filenameIndex = pathname.indexOf(filename);
if (parameterIndex === -1 || filenameIndex === -1 || filenameIndex < parameterIndex) {
return [];
}
const directories = pathname
.substring(parameterIndex + parameter.length, filenameIndex)
.split('/')
.filter((directory) => directory.trim() !== '');
const reconstructedPaths = [];
let currentPath = pathname.substring(0, parameterIndex + parameter.length);
for (const directory of directories) {
currentPath += `/${directory}`;
reconstructedPaths.push(currentPath);
}
return reconstructedPaths;
};
export const mergeRequests = (parentRequest, childRequest) => {
return _.mergeWith({}, parentRequest, childRequest, customizer);
};
function customizer(objValue, srcValue, key) {
const exceptions = ['headers', 'params', 'vars'];
if (exceptions.includes(key) && _.isArray(objValue) && _.isArray(srcValue)) {
return _.unionBy(srcValue, objValue, 'name');
}
return undefined;
}
export const sendCollectionOauth2Request = (collectionUid, itemUid) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
@ -156,7 +215,10 @@ export const sendCollectionOauth2Request = (collectionUid) => (dispatch, getStat
const environment = findEnvironmentInCollection(collectionCopy, collection.activeEnvironmentUid);
_sendCollectionOauth2Request(collection, environment, collectionCopy.collectionVariables)
const externalSecrets = getExternalCollectionSecretsForActiveEnvironment({ collection });
const secretVariables = getFormattedCollectionSecretVariables({ externalSecrets });
_sendCollectionOauth2Request(collection, environment, collectionCopy.collectionVariables, itemUid, secretVariables)
.then((response) => {
if (response?.data?.error) {
toast.error(response?.data?.error);
@ -184,9 +246,26 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
const itemCopy = cloneDeep(item || {});
const collectionCopy = cloneDeep(collection);
const environment = findEnvironmentInCollection(collectionCopy, collection.activeEnvironmentUid);
const environment = findEnvironmentInCollection(collectionCopy, collectionCopy.activeEnvironmentUid);
const itemTree = retrieveDirectoriesBetween(itemCopy.pathname, collectionCopy.name, itemCopy.filename);
sendNetworkRequest(itemCopy, collection, environment, collectionCopy.collectionVariables)
const folderDatas = itemTree.reduce((acc, currentPath) => {
const folder = findItemInCollectionByPathname(collectionCopy, currentPath);
if (folder) {
acc = mergeRequests(acc, folder.root.request);
}
return acc;
}, {});
const mergeParams = mergeRequests(collectionCopy.root.request, folderDatas);
// merge collection and folder settings with request
const mergedCollection = {
...collectionCopy,
root: {
...collectionCopy.root,
request: mergeParams
}
};
sendNetworkRequest(itemCopy, mergedCollection, environment, collectionCopy.collectionVariables)
.then((response) => {
return dispatch(
responseReceived({

View File

@ -89,7 +89,7 @@ export const collectionsSlice = createSlice({
}
},
updateSettingsSelectedTab: (state, action) => {
const { collectionUid, tab } = action.payload;
const { collectionUid, folderUid, tab } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
@ -1114,6 +1114,44 @@ export const collectionsSlice = createSlice({
set(collection, 'root.docs', action.payload.docs);
}
},
addFolderHeader: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
if (folder) {
const headers = get(folder, 'root.request.headers', []);
headers.push({
uid: uuid(),
name: '',
value: '',
description: '',
enabled: true
});
set(folder, 'root.request.headers', headers);
}
},
updateFolderHeader: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
if (folder) {
const headers = get(folder, 'root.request.headers', []);
const header = find(headers, (h) => h.uid === action.payload.header.uid);
if (header) {
header.name = action.payload.header.name;
header.value = action.payload.header.value;
header.description = action.payload.header.description;
header.enabled = action.payload.header.enabled;
}
}
},
deleteFolderHeader: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
if (folder) {
let headers = get(folder, 'root.request.headers', []);
headers = filter(headers, (h) => h.uid !== action.payload.headerUid);
set(folder, 'root.request.headers', headers);
}
},
addCollectionHeader: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@ -1152,11 +1190,22 @@ export const collectionsSlice = createSlice({
set(collection, 'root.request.headers', headers);
}
},
folderAddFileEvent: (state, action) => {
const file = action.payload.file;
const isFolderRoot = file.meta.folderRoot ? true : false;
const collection = findCollectionByUid(state.collections, file.meta.collectionUid);
const folder = findItemInCollectionByPathname(collection, file.meta.pathname);
if (isFolderRoot) {
if (folder) {
folder.root = file.data;
}
return;
}
},
collectionAddFileEvent: (state, action) => {
const file = action.payload.file;
const isCollectionRoot = file.meta.collectionRoot ? true : false;
const collection = findCollectionByUid(state.collections, file.meta.collectionUid);
if (isCollectionRoot) {
if (collection) {
collection.root = file.data;
@ -1187,7 +1236,7 @@ export const collectionsSlice = createSlice({
currentSubItems = childItem.items;
}
if (!currentSubItems.find((f) => f.name === file.meta.name)) {
if (file.meta.name != 'folder.bru' && !currentSubItems.find((f) => f.name === file.meta.name)) {
// this happens when you rename a file
// the add event might get triggered first, before the unlink event
// this results in duplicate uids causing react renderer to go mad
@ -1521,6 +1570,9 @@ export const {
addVar,
updateVar,
deleteVar,
addFolderHeader,
updateFolderHeader,
deleteFolderHeader,
addCollectionHeader,
updateCollectionHeader,
deleteCollectionHeader,
@ -1537,6 +1589,7 @@ export const {
collectionUnlinkDirectoryEvent,
collectionAddEnvFileEvent,
collectionRenamedEvent,
folderAddFileEvent,
resetRunResults,
runRequestEvent,
runFolderEvent,

View File

@ -38,7 +38,8 @@ export const tabsSlice = createSlice({
requestPaneWidth: null,
requestPaneTab: action.payload.requestPaneTab || 'params',
responsePaneTab: 'response',
type: action.payload.type || 'request'
type: action.payload.type || 'request',
...(action.payload.folderUid ? { folderUid: action.payload.folderUid } : {})
});
state.activeTabUid = action.payload.uid;
},

View File

@ -179,6 +179,17 @@ const getCollectionRoot = (dir) => {
return collectionBruToJson(content);
};
const getFolderRoot = (dir) => {
const folderRootPath = path.join(dir, 'folder.bru');
const exists = fs.existsSync(folderRootPath);
if (!exists) {
return {};
}
const content = fs.readFileSync(folderRootPath, 'utf8');
return collectionBruToJson(content);
};
const builder = async (yargs) => {
yargs
.option('r', {

View File

@ -40,10 +40,42 @@ const isBruEnvironmentConfig = (pathname, collectionPath) => {
const isCollectionRootBruFile = (pathname, collectionPath) => {
const dirname = path.dirname(pathname);
const basename = path.basename(pathname);
return dirname === collectionPath && basename === 'collection.bru';
};
const isFolderRootBruFile = (pathname, folderPath) => {
const dirname = path.dirname(pathname);
const basename = path.basename(pathname);
return dirname === folderPath && basename === 'folder.bru';
};
const scanDirectory = (directoryPath, callback) => {
fs.readdir(directoryPath, (err, files) => {
if (err) {
console.error(`Error reading directory ${directoryPath}: ${err}`);
return;
}
if (files.includes('folder.bru')) {
callback(directoryPath);
}
// Iterate through each file/folder in the directory
files.forEach((file) => {
const filePath = path.join(directoryPath, file);
// Check if it's a directory
fs.stat(filePath, (err, stats) => {
if (err) {
console.error(`Error statting ${filePath}: ${err}`);
return;
}
// If it's a directory, recursively scan it
if (stats.isDirectory()) {
scanDirectory(filePath, callback);
}
});
});
});
};
const hydrateRequestWithUuid = (request, pathname) => {
request.uid = getRequestUid(pathname);
@ -225,12 +257,27 @@ const add = async (win, pathname, collectionUid, collectionPath) => {
collectionRoot: true
}
};
const folderCallback = (filePath) => {
const bruContent = fs.readFileSync(`${filePath}/folder.bru`, 'utf8');
if (bruContent) {
const folder = {
meta: {
collectionUid,
pathname: filePath,
name: path.basename(filePath),
folderRoot: true
}
};
folder.data = collectionBruToJson(bruContent);
hydrateBruCollectionFileWithUuid(folder.data);
win.webContents.send('main:collection-tree-updated', 'addFileDir', folder);
}
};
try {
scanDirectory(collectionPath, folderCallback);
let bruContent = fs.readFileSync(pathname, 'utf8');
file.data = collectionBruToJson(bruContent);
hydrateBruCollectionFileWithUuid(file.data);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
return;
@ -334,7 +381,6 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
let bruContent = fs.readFileSync(pathname, 'utf8');
file.data = collectionBruToJson(bruContent);
hydrateBruCollectionFileWithUuid(file.data);
win.webContents.send('main:collection-tree-updated', 'change', file);
return;

View File

@ -152,6 +152,16 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
});
ipcMain.handle('renderer:save-folder-root', async (event, folderPathname, folderRoot) => {
try {
const folderBruFilePath = path.join(folderPathname, 'folder.bru');
const content = jsonToBru(folderRoot);
await writeFile(folderBruFilePath, content);
} catch (error) {
return Promise.reject(error);
}
});
ipcMain.handle('renderer:save-collection-root', async (event, collectionPathname, collectionRoot) => {
try {
const collectionBruFilePath = path.join(collectionPathname, 'collection.bru');