diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js index da9c55bf6..096e13244 100644 --- a/packages/bruno-app/src/providers/App/useIpcEvents.js +++ b/packages/bruno-app/src/providers/App/useIpcEvents.js @@ -15,6 +15,7 @@ import { collectionUnlinkEnvFileEvent, collectionUnlinkFileEvent, processEnvUpdateEvent, + workspaceEnvUpdateEvent, requestCancelled, runFolderEvent, runRequestEvent, @@ -23,6 +24,7 @@ import { } from 'providers/ReduxStore/slices/collections'; import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUiStateSnapshot, mergeAndPersistEnvironment } from 'providers/ReduxStore/slices/collections/actions'; import { workspaceOpenedEvent, workspaceConfigUpdatedEvent } from 'providers/ReduxStore/slices/workspaces/actions'; +import { workspaceDotEnvUpdateEvent } from 'providers/ReduxStore/slices/workspaces'; import toast from 'react-hot-toast'; import { useDispatch } from 'react-redux'; import { isElectron } from 'utils/common/platform'; @@ -214,6 +216,11 @@ const useIpcEvents = () => { dispatch(processEnvUpdateEvent(val)); }); + const removeWorkspaceDotEnvUpdatesListener = ipcRenderer.on('main:workspace-dotenv-update', (val) => { + dispatch(workspaceDotEnvUpdateEvent(val)); + dispatch(workspaceEnvUpdateEvent({ processEnvVariables: val.processEnvVariables })); + }); + const removeConsoleLogListener = ipcRenderer.on('main:console-log', (val) => { console[val.type](...val.args); dispatch(addLog({ @@ -293,6 +300,7 @@ const useIpcEvents = () => { removeRunFolderEventListener(); removeRunRequestEventListener(); removeProcessEnvUpdatesListener(); + removeWorkspaceDotEnvUpdatesListener(); removeConsoleLogListener(); removeConfigUpdatesListener(); removeShowPreferencesListener(); 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 e5748b3ac..6b6f2b39a 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -35,6 +35,7 @@ import { sortCollections as _sortCollections, updateCollectionMountStatus, moveCollection, + workspaceEnvUpdateEvent, requestCancelled, resetRunResults, responseReceived, @@ -2255,6 +2256,7 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge return new Promise((resolve, reject) => { const state = getState(); const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid); + const workspaceProcessEnvVariables = activeWorkspace?.processEnvVariables || {}; // Check if collection already exists in Redux state const existingCollection = state.collections.collections.find( @@ -2296,6 +2298,8 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge }); } + dispatch(workspaceEnvUpdateEvent({ processEnvVariables: workspaceProcessEnvVariables })); + resolve(); return; } @@ -2308,6 +2312,7 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge pathname: pathname, items: [], runtimeVariables: {}, + workspaceProcessEnvVariables, brunoConfig: brunoConfig }; @@ -2326,6 +2331,9 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge ); if (currentWorkspace) { + // Set collection-workspace mapping for workspace env vars + ipcRenderer.invoke('renderer:set-collection-workspace', uid, currentWorkspace.pathname); + const alreadyInWorkspace = currentWorkspace.collections?.some( (c) => normalizePath(c.path) === normalizePath(pathname) ); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index 68c0e12ad..0997c83a3 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -405,6 +405,12 @@ export const collectionsSlice = createSlice({ collection.processEnvVariables = processEnvVariables; } }, + workspaceEnvUpdateEvent: (state, action) => { + const { processEnvVariables } = action.payload; + state.collections.forEach((collection) => { + collection.workspaceProcessEnvVariables = processEnvVariables; + }); + }, requestCancelled: (state, action) => { const { itemUid, collectionUid, seq, timestamp } = action.payload; const collection = findCollectionByUid(state.collections, collectionUid); @@ -3424,6 +3430,7 @@ export const { cloneItem, scriptEnvironmentUpdateEvent, processEnvUpdateEvent, + workspaceEnvUpdateEvent, requestCancelled, responseReceived, runGrpcRequestEvent, diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js index f1f35298a..1f439acbd 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js @@ -169,8 +169,8 @@ export const removeCollectionFromWorkspaceAction = (workspaceUid, collectionPath }; const loadWorkspaceCollectionsForSwitch = async (dispatch, workspace) => { - const openCollectionsFunction = (collectionPaths, workspaceId) => { - return dispatch(openMultipleCollections(collectionPaths, { workspaceId })); + const openCollectionsFunction = (collectionPaths, workspacePath) => { + return dispatch(openMultipleCollections(collectionPaths, { workspacePath })); }; try { @@ -418,7 +418,7 @@ export const workspaceConfigUpdatedEvent = (workspacePath, workspaceUid, workspa if (uniqueNewCollectionPaths.length > 0) { try { - await dispatch(openMultipleCollections(uniqueNewCollectionPaths, { workspaceId: workspace.pathname })); + await dispatch(openMultipleCollections(uniqueNewCollectionPaths, { workspacePath: workspace.pathname })); } catch (error) { } } diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/index.js index e25e3920a..d8bb63f1f 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/index.js @@ -76,6 +76,14 @@ export const workspacesSlice = createSlice({ if (workspace) { workspace.loadingState = loadingState; } + }, + + workspaceDotEnvUpdateEvent: (state, action) => { + const { workspaceUid, processEnvVariables } = action.payload; + const workspace = state.workspaces.find((w) => w.uid === workspaceUid); + if (workspace) { + workspace.processEnvVariables = processEnvVariables; + } } } }); @@ -87,7 +95,8 @@ export const { updateWorkspace, addCollectionToWorkspace, removeCollectionFromWorkspace, - updateWorkspaceLoadingState + updateWorkspaceLoadingState, + workspaceDotEnvUpdateEvent } = workspacesSlice.actions; export default workspacesSlice.reducer; diff --git a/packages/bruno-app/src/utils/collections/export.js b/packages/bruno-app/src/utils/collections/export.js index 669446a29..43b25efb2 100644 --- a/packages/bruno-app/src/utils/collections/export.js +++ b/packages/bruno-app/src/utils/collections/export.js @@ -99,6 +99,7 @@ export const exportCollection = (collection, version) => { // delete process variables delete collection.processEnvVariables; + delete collection.workspaceProcessEnvVariables; deleteUidsInItems(collection.items); deleteUidsInEnvs(collection.environments); diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index 6eb2bd78d..dcad3b168 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -1174,7 +1174,14 @@ export const getAllVariables = (collection, item) => { const pathParams = getPathParams(item); const { globalEnvironmentVariables = {} } = collection; - const { processEnvVariables = {}, runtimeVariables = {}, promptVariables = {} } = collection; + const { processEnvVariables = {}, runtimeVariables = {}, promptVariables = {}, workspaceProcessEnvVariables = {} } = collection; + + // Merge workspace and collection processEnvVariables (collection takes priority) + const mergedProcessEnvVariables = { + ...workspaceProcessEnvVariables, + ...processEnvVariables + }; + const mergedVariables = { ...folderVariables, ...requestVariables, @@ -1216,7 +1223,7 @@ export const getAllVariables = (collection, item) => { maskedEnvVariables: uniqueMaskedVariables, process: { env: { - ...processEnvVariables + ...mergedProcessEnvVariables } } }; diff --git a/packages/bruno-electron/src/app/workspace-watcher.js b/packages/bruno-electron/src/app/workspace-watcher.js index f9c3d97cc..aecaa27d2 100644 --- a/packages/bruno-electron/src/app/workspace-watcher.js +++ b/packages/bruno-electron/src/app/workspace-watcher.js @@ -5,9 +5,10 @@ const chokidar = require('chokidar'); const yaml = require('js-yaml'); const { generateUidBasedOnHash, uuid } = require('../utils/common'); const { getWorkspaceUid } = require('../utils/workspace-config'); -const { parseEnvironment } = require('@usebruno/filestore'); +const { parseEnvironment, parseDotEnv } = require('@usebruno/filestore'); const EnvironmentSecretsStore = require('../store/env-secrets'); const { decryptStringSafe } = require('../utils/encryption'); +const { setWorkspaceDotEnvVars, clearWorkspaceDotEnvVars } = require('../store/process-env'); const environmentSecretsStore = new EnvironmentSecretsStore(); @@ -122,15 +123,51 @@ const handleGlobalEnvironmentFileUnlink = async (win, pathname, workspaceUid) => } }; +const handleWorkspaceDotEnvFile = (win, workspacePath, workspaceUid) => { + try { + const dotEnvPath = path.join(workspacePath, '.env'); + if (!fs.existsSync(dotEnvPath)) { + return; + } + + const content = fs.readFileSync(dotEnvPath, 'utf8'); + const jsonData = parseDotEnv(content); + + setWorkspaceDotEnvVars(workspacePath, jsonData); + win.webContents.send('main:workspace-dotenv-update', { + workspaceUid, + workspacePath, + processEnvVariables: { ...jsonData } + }); + } catch (error) { + console.error('Error handling workspace .env file:', error); + } +}; + +const handleWorkspaceDotEnvUnlink = (win, workspacePath, workspaceUid) => { + try { + clearWorkspaceDotEnvVars(workspacePath); + win.webContents.send('main:workspace-dotenv-update', { + workspaceUid, + workspacePath, + processEnvVariables: {} + }); + } catch (error) { + console.error('Error handling workspace .env file unlink:', error); + } +}; + class WorkspaceWatcher { constructor() { this.watchers = {}; this.environmentWatchers = {}; + this.dotEnvWatchers = {}; } addWatcher(win, workspacePath) { const workspaceFilePath = path.join(workspacePath, 'workspace.yml'); const environmentsDir = path.join(workspacePath, 'environments'); + const dotEnvFilePath = path.join(workspacePath, '.env'); const workspaceUid = getWorkspaceUid(workspacePath); if (this.watchers[workspacePath]) { @@ -139,6 +176,9 @@ class WorkspaceWatcher { if (this.environmentWatchers[workspacePath]) { this.environmentWatchers[workspacePath].close(); } + if (this.dotEnvWatchers[workspacePath]) { + this.dotEnvWatchers[workspacePath].close(); + } const self = this; setTimeout(() => { @@ -146,6 +186,9 @@ class WorkspaceWatcher { return; } + // Load initial .env file if exists + handleWorkspaceDotEnvFile(win, workspacePath, workspaceUid); + const watcher = chokidar.watch(workspaceFilePath, { ignoreInitial: true, persistent: true, @@ -164,6 +207,22 @@ class WorkspaceWatcher { self.watchers[workspacePath] = watcher; + const dotEnvWatcher = chokidar.watch(dotEnvFilePath, { + ignoreInitial: true, + persistent: true, + ignorePermissionErrors: true, + awaitWriteFinish: { + stabilityThreshold: 80, + pollInterval: 250 + } + }); + + dotEnvWatcher.on('add', () => handleWorkspaceDotEnvFile(win, workspacePath, workspaceUid)); + dotEnvWatcher.on('change', () => handleWorkspaceDotEnvFile(win, workspacePath, workspaceUid)); + dotEnvWatcher.on('unlink', () => handleWorkspaceDotEnvUnlink(win, workspacePath, workspaceUid)); + + self.dotEnvWatchers[workspacePath] = dotEnvWatcher; + if (fs.existsSync(environmentsDir)) { const envWatcher = chokidar.watch(path.join(environmentsDir, `*.yml`), { ignoreInitial: true, @@ -216,6 +275,12 @@ class WorkspaceWatcher { this.environmentWatchers[workspacePath].close(); delete this.environmentWatchers[workspacePath]; } + if (this.dotEnvWatchers[workspacePath]) { + this.dotEnvWatchers[workspacePath].close(); + delete this.dotEnvWatchers[workspacePath]; + } + // Clear workspace env vars when watcher is removed + clearWorkspaceDotEnvVars(workspacePath); } catch (error) { console.error('Error removing workspace watcher:', error); } diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 5357b51c2..2df3710fb 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -823,9 +823,24 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { } }); - ipcMain.handle('renderer:open-multiple-collections', async (e, collectionPaths) => { + ipcMain.handle('renderer:open-multiple-collections', async (e, collectionPaths, options = {}) => { if (watcher && mainWindow) { await openCollectionsByPathname(mainWindow, watcher, collectionPaths); + if (options.workspacePath) { + const { setCollectionWorkspace } = require('../store/process-env'); + const { generateUidBasedOnHash } = require('../utils/common'); + for (const collectionPath of collectionPaths) { + const collectionUid = generateUidBasedOnHash(collectionPath); + setCollectionWorkspace(collectionUid, options.workspacePath); + } + } + } + }); + + ipcMain.handle('renderer:set-collection-workspace', (event, collectionUid, workspacePath) => { + if (workspacePath) { + const { setCollectionWorkspace } = require('../store/process-env'); + setCollectionWorkspace(collectionUid, workspacePath); } }); @@ -838,6 +853,10 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { } } + // Clean up + const { clearCollectionWorkspace } = require('../store/process-env'); + clearCollectionWorkspace(collectionUid); + if (workspacePath && workspacePath !== 'default') { try { const { removeCollectionFromWorkspace } = require('../utils/workspace-config'); diff --git a/packages/bruno-electron/src/store/process-env.js b/packages/bruno-electron/src/store/process-env.js index 084187d2d..3dbdd087b 100644 --- a/packages/bruno-electron/src/store/process-env.js +++ b/packages/bruno-electron/src/store/process-env.js @@ -1,28 +1,29 @@ /** - * This file stores all the process.env variables under collection scope + * This file stores all the process.env variables under collection and workspace scope * - * process.env variables are sourced from 2 places: - * 1. .env file in the root of the project - * 2. process.env variables set in the OS + * process.env variables are sourced from 3 places: + * 1. .env file in the workspace root + * 2. .env file in the collection root + * 3. process.env variables set in the OS + * + * Priority (highest to lowest): collection .env > workspace .env > OS process.env * * Multiple collections can be opened in the same electron app. * Each collection's .env file can have different values for the same process.env variable. */ const dotEnvVars = {}; +const workspaceDotEnvVars = {}; +const collectionWorkspaceMap = {}; // collectionUid is a hash based on the collection path const getProcessEnvVars = (collectionUid) => { - // if there are no .env vars for this collection, return the process.env - if (!dotEnvVars[collectionUid]) { - return { - ...process.env - }; - } + const workspacePath = collectionWorkspaceMap[collectionUid]; + const workspaceEnvVars = workspacePath ? workspaceDotEnvVars[workspacePath] : {}; - // if there are .env vars for this collection, return the process.env merged with the .env vars return { ...process.env, + ...workspaceEnvVars, ...dotEnvVars[collectionUid] }; }; @@ -31,7 +32,27 @@ const setDotEnvVars = (collectionUid, envVars) => { dotEnvVars[collectionUid] = envVars; }; +const setWorkspaceDotEnvVars = (workspacePath, envVars) => { + workspaceDotEnvVars[workspacePath] = envVars; +}; + +const clearWorkspaceDotEnvVars = (workspacePath) => { + delete workspaceDotEnvVars[workspacePath]; +}; + +const setCollectionWorkspace = (collectionUid, workspacePath) => { + collectionWorkspaceMap[collectionUid] = workspacePath; +}; + +const clearCollectionWorkspace = (collectionUid) => { + delete collectionWorkspaceMap[collectionUid]; +}; + module.exports = { getProcessEnvVars, - setDotEnvVars + setDotEnvVars, + setWorkspaceDotEnvVars, + clearWorkspaceDotEnvVars, + setCollectionWorkspace, + clearCollectionWorkspace }; diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js index 908822fd2..09b40e1ed 100644 --- a/packages/bruno-schema/src/collections/index.js +++ b/packages/bruno-schema/src/collections/index.js @@ -610,6 +610,7 @@ const collectionSchema = Yup.object({ items: Yup.array() }), runtimeVariables: Yup.object(), + workspaceProcessEnvVariables: Yup.object().default({}), brunoConfig: Yup.object(), root: folderRootSchema })