Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from 'react';
import Modal from 'components/Modal';
import { useSelector, useDispatch } from 'react-redux';
import { ignoreFolder, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { recursivelyGetAllItemUids } from 'utils/collections';
import toast from 'react-hot-toast';

const IgnoreCollectionItem = ({ onClose, item, collectionUid }) => {
const dispatch = useDispatch();
const collection = useSelector((state) => state.collections.collections?.find((c) => c.uid === collectionUid));
const isYamlCollection = collection?.format === 'yml' || Boolean(collection?.brunoConfig?.opencollection);
const configFileName = isYamlCollection ? 'opencollection.yml' : 'bruno.json';

const onConfirm = () => {
dispatch(ignoreFolder(item.uid, collectionUid))
.then(() => {
const tabUids = [...recursivelyGetAllItemUids(item.items), item.uid];
dispatch(closeTabs({ tabUids }));
toast.success('Folder ignored');
})
.catch((error) => {
console.error('Error ignoring folder', error);
toast.error(error?.message || 'Error ignoring folder');
});
onClose();
};

return (
<Modal
size="md"
title="Ignore Folder"
confirmText="Ignore"
handleConfirm={onConfirm}
handleCancel={onClose}
>
Ignoring <span className="font-medium">{item.name}</span> will hide it from this
{' '}
{isYamlCollection ? 'opencollection (YAML)' : 'Bruno (JSON)'}
{' '}
collection by adding it to the
{' '}
<span className="font-medium">ignore</span>
{' '}
list in
{' '}
<span className="font-medium">{configFileName}</span>
. The folder and its files are not deleted. To restore it later, remove the entry from the
{' '}
<span className="font-medium">ignore</span>
{' '}
list in
{' '}
<span className="font-medium">{configFileName}</span>
.
</Modal>
);
};

export default IgnoreCollectionItem;
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import {
IconSettings,
IconInfoCircle,
IconTerminal2,
IconAppWindow
IconAppWindow,
IconEyeOff
} from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
Expand All @@ -34,6 +35,7 @@ import NewApp from 'components/Sidebar/NewApp';
import RenameCollectionItem from './RenameCollectionItem';
import CloneCollectionItem from './CloneCollectionItem';
import DeleteCollectionItem from './DeleteCollectionItem';
import IgnoreCollectionItem from './IgnoreCollectionItem';
import RunCollectionItem from './RunCollectionItem';
import GenerateCodeItem from './GenerateCodeItem';
import { isItemARequest, isItemAFolder } from 'utils/tabs';
Expand Down Expand Up @@ -93,6 +95,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
const [renameItemModalOpen, setRenameItemModalOpen] = useState(false);
const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false);
const [deleteItemModalOpen, setDeleteItemModalOpen] = useState(false);
const [ignoreItemModalOpen, setIgnoreItemModalOpen] = useState(false);
const [createExampleModalOpen, setCreateExampleModalOpen] = useState(false);
const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false);
const [newRequestModalOpen, setNewRequestModalOpen] = useState(false);
Expand Down Expand Up @@ -457,6 +460,15 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
}
);

if (isFolder) {
items.push({
id: 'ignore',
leftSection: IconEyeOff,
label: 'Ignore',
onClick: () => setIgnoreItemModalOpen(true)
});
}

items.push({ id: 'separator-1', type: 'divider' });

items.push({
Expand Down Expand Up @@ -654,6 +666,9 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
{deleteItemModalOpen && (
<DeleteCollectionItem item={item} collectionUid={collectionUid} onClose={() => setDeleteItemModalOpen(false)} />
)}
{ignoreItemModalOpen && (
<IgnoreCollectionItem item={item} collectionUid={collectionUid} onClose={() => setIgnoreItemModalOpen(false)} />
)}
{newRequestModalOpen && (
<NewRequest item={item} collectionUid={collectionUid} onClose={() => setNewRequestModalOpen(false)} />
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import {
sortCollections as _sortCollections,
updateCollectionMountStatus,
moveCollection,
deleteItem as _deleteItemFromState,
brunoConfigUpdateEvent as _brunoConfigUpdateEvent,
workspaceEnvUpdateEvent,
requestCancelled,
resetRunResults,
Expand Down Expand Up @@ -2744,6 +2746,32 @@ export const updateBrunoConfig = (brunoConfig, collectionUid) => (dispatch, getS
});
};

export const ignoreFolder = (itemUid, collectionUid) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);

return new Promise((resolve, reject) => {
if (!collection) {
return reject(new Error('Collection not found'));
}

const item = findItemInCollection(collection, itemUid);
if (!item) {
return reject(new Error('Unable to locate item'));
}

const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:ignore-folder', collectionUid, collection.pathname, collection.root, collection.brunoConfig || {}, item.pathname)
.then((updatedBrunoConfig) => {
dispatch(_brunoConfigUpdateEvent({ collectionUid, brunoConfig: updatedBrunoConfig }));
dispatch(_deleteItemFromState({ itemUid, collectionUid }));
resolve();
})
.catch(reject);
});
};

/**
* Opens a scratch collection and creates it in Redux state.
* This is a simplified version of openCollectionEvent for scratch collections,
Expand Down
14 changes: 10 additions & 4 deletions packages/bruno-electron/src/app/collection-watcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const { uuid } = require('../utils/common');
const { parseValueByDataType } = require('@usebruno/common/utils');
const { getRequestUid } = require('../cache/requestUids');
const { decryptStringSafe } = require('../utils/encryption');
const { setBrunoConfig } = require('../store/bruno-config');
const { setBrunoConfig, getBrunoConfig } = require('../store/bruno-config');
const EnvironmentSecretsStore = require('../store/env-secrets');
const snapshotManager = require('../services/snapshot');
const { parseFileMeta, hydrateRequestWithUuid } = require('../utils/collection');
Expand Down Expand Up @@ -811,8 +811,6 @@ class CollectionWatcher {
// Always ignore node_modules and .git, regardless of user config
// This prevents infinite loops with symlinked directories (e.g., npm workspaces)
const defaultIgnores = ['node_modules', '.git'];
const userIgnores = brunoConfig?.ignore || [];
const ignores = [...new Set([...defaultIgnores, ...userIgnores])];

setTimeout(() => {
const watcher = chokidar.watch(watchPath, {
Expand All @@ -834,8 +832,16 @@ class CollectionWatcher {
return true;
}

const userIgnores = getBrunoConfig(collectionUid)?.ignore || [];
const ignores = [...new Set([...defaultIgnores, ...userIgnores])];
const normalizedRelativePath = relativePath.split(path.sep).join('/');

return ignores.some((ignorePattern) => {
return relativePath === ignorePattern || relativePath.startsWith(ignorePattern);
const normalizedIgnorePattern = ignorePattern.replace(/\\/g, '/');
if (!normalizedIgnorePattern) {
return false;
}
return normalizedRelativePath === normalizedIgnorePattern || normalizedRelativePath.startsWith(`${normalizedIgnorePattern}/`);
});
},
persistent: true,
Expand Down
76 changes: 50 additions & 26 deletions packages/bruno-electron/src/ipc/collection.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ const interpolateVars = require('./network/interpolate-vars');
const { interpolateString } = require('./network/interpolate-string');
const { getEnvVars, getTreePathFromCollectionToItem, mergeVars, parseBruFileMeta, hydrateRequestWithUuid, transformRequestToSaveToFilesystem } = require('../utils/collection');
const { getProcessEnvVars } = require('../store/process-env');
const { setBrunoConfig } = require('../store/bruno-config');
const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials, getOAuth2TokenUsingImplicitGrant, refreshOauth2Token } = require('../utils/oauth2');
const { getCertsAndProxyConfig } = require('./network/cert-utils');
const collectionWatcher = require('../app/collection-watcher');
Expand Down Expand Up @@ -1708,36 +1709,59 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
}
});

ipcMain.handle('renderer:update-bruno-config', async (event, brunoConfig, collectionPath, collectionRoot) => {
try {
const transformedBrunoConfig = transformBrunoConfigBeforeSave(brunoConfig);
const format = getCollectionFormat(collectionPath);
const writeBrunoConfig = async (brunoConfig, collectionPath, collectionRoot) => {
const transformedBrunoConfig = transformBrunoConfigBeforeSave(brunoConfig);
const format = getCollectionFormat(collectionPath);
Comment on lines +1712 to +1714

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Avoid mutating the config returned to Redux.

transformBrunoConfigBeforeSave mutates BRU configs (version → schema marker and collectionVersion). This handler returns that same object, so a subsequent ignore can overwrite the real collection version with '1'. Deep-clone before transforming for persistence.

Proposed fix
-    const transformedBrunoConfig = transformBrunoConfigBeforeSave(brunoConfig);
+    const transformedBrunoConfig = transformBrunoConfigBeforeSave(_.cloneDeep(brunoConfig));

Based on the supplied transformBrunoConfigBeforeSave definition, the transform mutates its argument.

Also applies to: 1760-1764

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bruno-electron/src/ipc/collection.js` around lines 1712 - 1714,
Update writeBrunoConfig to deep-clone brunoConfig before passing it to
transformBrunoConfigBeforeSave, while retaining the transformed clone for
persistence. Ensure the original config object returned to Redux is never
mutated, including the corresponding save path around the additional referenced
lines.


if (format === 'bru') {
const brunoConfigPath = path.join(collectionPath, 'bruno.json');
const content = await stringifyJson(transformedBrunoConfig);
await writeFile(brunoConfigPath, content);
} else if (format === 'yml') {
// opencollection.yml holds both config AND the collection root. If the caller
// didn't supply a root (e.g. a config-only update before the tree finished
// loading), recover it from disk so request defaults/docs/scripts aren't wiped.
let rootToWrite = collectionRoot;
if (!rootToWrite) {
const ocYmlPath = path.join(collectionPath, 'opencollection.yml');
if (fs.existsSync(ocYmlPath)) {
try {
const existing = fs.readFileSync(ocYmlPath, 'utf8');
rootToWrite = parseCollection(existing, { format }).collectionRoot;
} catch (e) {
rootToWrite = collectionRoot;
}
if (format === 'bru') {
const brunoConfigPath = path.join(collectionPath, 'bruno.json');
const content = await stringifyJson(transformedBrunoConfig);
await writeFile(brunoConfigPath, content);
} else if (format === 'yml') {
// opencollection.yml holds both config AND the collection root. If the caller
// didn't supply a root (e.g. a config-only update before the tree finished
// loading), recover it from disk so request defaults/docs/scripts aren't wiped.
let rootToWrite = collectionRoot;
if (!rootToWrite) {
const ocYmlPath = path.join(collectionPath, 'opencollection.yml');
if (fs.existsSync(ocYmlPath)) {
try {
const existing = fs.readFileSync(ocYmlPath, 'utf8');
rootToWrite = parseCollection(existing, { format }).collectionRoot;
} catch (e) {
rootToWrite = collectionRoot;
}
}
const content = await stringifyCollection(rootToWrite, transformedBrunoConfig, { format });
await writeFile(path.join(collectionPath, 'opencollection.yml'), content);
} else {
throw new Error(`Invalid collection format: ${format}`);
}
const content = await stringifyCollection(rootToWrite, transformedBrunoConfig, { format });
await writeFile(path.join(collectionPath, 'opencollection.yml'), content);
} else {
throw new Error(`Invalid collection format: ${format}`);
}
};

ipcMain.handle('renderer:update-bruno-config', async (event, brunoConfig, collectionPath, collectionRoot) => {
try {
await writeBrunoConfig(brunoConfig, collectionPath, collectionRoot);
} catch (error) {
return Promise.reject(error);
}
});

ipcMain.handle('renderer:ignore-folder', async (event, collectionUid, collectionPath, collectionRoot, brunoConfig, folderPath) => {
try {
const relativePath = path.relative(collectionPath, folderPath).replace(/\\/g, '/');
const existingIgnores = brunoConfig?.ignore || [];
const updatedBrunoConfig = {
...brunoConfig,
ignore: [...new Set([...existingIgnores, relativePath])]
};

await writeBrunoConfig(updatedBrunoConfig, collectionPath, collectionRoot);
setBrunoConfig(collectionUid, updatedBrunoConfig);
collectionWatcher.unlinkItemPathInWatcher(folderPath);

return updatedBrunoConfig;
} catch (error) {
return Promise.reject(error);
}
Expand Down
Loading