diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/IgnoreCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/IgnoreCollectionItem/index.js new file mode 100644 index 00000000000..c07af7053f5 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/IgnoreCollectionItem/index.js @@ -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 ( + + Ignoring {item.name} will hide it from this + {' '} + {isYamlCollection ? 'opencollection (YAML)' : 'Bruno (JSON)'} + {' '} + collection by adding it to the + {' '} + ignore + {' '} + list in + {' '} + {configFileName} + . The folder and its files are not deleted. To restore it later, remove the entry from the + {' '} + ignore + {' '} + list in + {' '} + {configFileName} + . + + ); +}; + +export default IgnoreCollectionItem; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js index 5ffbd533c1a..d57afafb1ca 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js @@ -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'; @@ -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'; @@ -98,6 +100,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); @@ -448,6 +451,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({ @@ -645,6 +657,9 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) {deleteItemModalOpen && ( setDeleteItemModalOpen(false)} /> )} + {ignoreItemModalOpen && ( + setIgnoreItemModalOpen(false)} /> + )} {newRequestModalOpen && ( setNewRequestModalOpen(false)} /> )} diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index 8b551e8080b..ca8facfef03 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -39,6 +39,8 @@ import { sortCollections as _sortCollections, updateCollectionMountStatus, moveCollection, + deleteItem as _deleteItemFromState, + brunoConfigUpdateEvent as _brunoConfigUpdateEvent, workspaceEnvUpdateEvent, requestCancelled, resetRunResults, @@ -2767,6 +2769,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, diff --git a/packages/bruno-electron/src/app/collection-watcher.js b/packages/bruno-electron/src/app/collection-watcher.js index 4fab1c211a8..d1d75807849 100644 --- a/packages/bruno-electron/src/app/collection-watcher.js +++ b/packages/bruno-electron/src/app/collection-watcher.js @@ -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'); @@ -820,8 +820,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, { @@ -843,8 +841,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, diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 595994fc208..183ac678b64 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -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'); @@ -1702,36 +1703,55 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { } }); + const writeBrunoConfig = async (brunoConfig, collectionPath, collectionRoot) => { + const transformedBrunoConfig = transformBrunoConfigBeforeSave(_.cloneDeep(brunoConfig)); + const format = getCollectionFormat(collectionPath); + + 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)) { + const existing = fs.readFileSync(ocYmlPath, 'utf8'); + rootToWrite = parseCollection(existing, { format }).collectionRoot; + } + } + 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 { - const transformedBrunoConfig = transformBrunoConfigBeforeSave(brunoConfig); - const format = getCollectionFormat(collectionPath); + await writeBrunoConfig(brunoConfig, collectionPath, collectionRoot); + } catch (error) { + return Promise.reject(error); + } + }); - 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}`); - } + 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); }