feat: workspace .env file support (#6777)

This commit is contained in:
naman-bruno
2026-01-12 13:40:38 +05:30
committed by GitHub
parent 176646f983
commit 071ee9ab2e
11 changed files with 166 additions and 20 deletions

View File

@@ -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();

View File

@@ -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)
);

View File

@@ -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,

View File

@@ -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) {
}
}

View File

@@ -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;

View File

@@ -99,6 +99,7 @@ export const exportCollection = (collection, version) => {
// delete process variables
delete collection.processEnvVariables;
delete collection.workspaceProcessEnvVariables;
deleteUidsInItems(collection.items);
deleteUidsInEnvs(collection.environments);

View File

@@ -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
}
}
};

View File

@@ -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);
}

View File

@@ -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');

View File

@@ -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
};

View File

@@ -610,6 +610,7 @@ const collectionSchema = Yup.object({
items: Yup.array()
}),
runtimeVariables: Yup.object(),
workspaceProcessEnvVariables: Yup.object().default({}),
brunoConfig: Yup.object(),
root: folderRootSchema
})