From e7c2c7c8722169407d98b8152ed55b3d13f85376 Mon Sep 17 00:00:00 2001 From: naman-bruno Date: Wed, 11 Mar 2026 19:07:38 +0530 Subject: [PATCH] workspace renaming with path update (#7437) * workspace renaming with path update * fixes * update: test * fix: test --- .../ReduxStore/slices/workspaces/actions.js | 33 +++-- packages/bruno-electron/src/ipc/workspace.js | 19 ++- .../src/utils/workspace-config.js | 43 +++++- .../tests/utils/workspace-config.spec.js | 135 +++++++++++++++++- 4 files changed, 216 insertions(+), 14 deletions(-) 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 5761d605d..15dd0adf7 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js @@ -524,21 +524,38 @@ const handleWorkspaceAction = async (action, workspaceUid, ...args) => { export const renameWorkspaceAction = (workspaceUid, newName) => { return async (dispatch, getState) => { try { - const { workspaces } = getState().workspaces; + const { workspaces, activeWorkspaceUid } = getState().workspaces; const workspace = workspaces.find((w) => w.uid === workspaceUid); if (!workspace) { throw new Error('Workspace not found'); } - await handleWorkspaceAction((...args) => ipcRenderer.invoke('renderer:rename-workspace', ...args), - workspace.pathname, - newName); + const result = await ipcRenderer.invoke('renderer:rename-workspace', workspace.pathname, newName); - dispatch(updateWorkspace({ - uid: workspaceUid, - name: newName - })); + if (result.newWorkspacePath) { + const { generateUidBasedOnHash } = await import('utils/common'); + const newWorkspaceUid = generateUidBasedOnHash(result.newWorkspacePath); + const wasActive = activeWorkspaceUid === workspaceUid; + + dispatch(removeWorkspace(workspaceUid)); + + dispatch(createWorkspace({ + ...workspace, + uid: newWorkspaceUid, + name: newName, + pathname: result.newWorkspacePath + })); + + if (wasActive) { + dispatch(setActiveWorkspace(newWorkspaceUid)); + } + } else { + dispatch(updateWorkspace({ + uid: workspaceUid, + name: newName + })); + } } catch (error) { throw error; } diff --git a/packages/bruno-electron/src/ipc/workspace.js b/packages/bruno-electron/src/ipc/workspace.js index a896a2a09..130c1234d 100644 --- a/packages/bruno-electron/src/ipc/workspace.js +++ b/packages/bruno-electron/src/ipc/workspace.js @@ -16,7 +16,6 @@ const { readWorkspaceConfig, writeWorkspaceConfig, validateWorkspaceConfig, - updateWorkspaceName, updateWorkspaceDocs, addCollectionToWorkspace, removeCollectionFromWorkspace, @@ -25,7 +24,8 @@ const { normalizeCollectionEntry, validateWorkspacePath, validateWorkspaceDirectory, - getWorkspaceUid + getWorkspaceUid, + renameWorkspace } = require('../utils/workspace-config'); const { isValidCollectionDirectory } = require('../utils/filesystem'); @@ -270,7 +270,20 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => { ipcMain.handle('renderer:rename-workspace', async (event, workspacePath, newName) => { try { - await updateWorkspaceName(workspacePath, newName); + const result = await renameWorkspace(workspacePath, newName); + + if (result.newWorkspacePath) { + if (workspaceWatcher) { + workspaceWatcher.removeWatcher(workspacePath); + workspaceWatcher.addWatcher(mainWindow, result.newWorkspacePath); + } + + lastOpenedWorkspaces.remove(workspacePath); + lastOpenedWorkspaces.add(result.newWorkspacePath); + + return { success: true, newWorkspacePath: result.newWorkspacePath }; + } + return { success: true }; } catch (error) { throw error; diff --git a/packages/bruno-electron/src/utils/workspace-config.js b/packages/bruno-electron/src/utils/workspace-config.js index 7e0c6d900..165cab409 100644 --- a/packages/bruno-electron/src/utils/workspace-config.js +++ b/packages/bruno-electron/src/utils/workspace-config.js @@ -1,7 +1,7 @@ const fs = require('fs'); const path = require('path'); const yaml = require('js-yaml'); -const { writeFile, validateName, isValidCollectionDirectory } = require('./filesystem'); +const { writeFile, validateName, isValidCollectionDirectory, sanitizeName } = require('./filesystem'); const { generateUidBasedOnHash } = require('./common'); const { withLock, getWorkspaceLockKey } = require('./workspace-lock'); @@ -563,6 +563,44 @@ const getWorkspaceUid = (workspacePath) => { return generateUidBasedOnHash(workspacePath); }; +/** + * Renames a workspace folder and updates the workspace.yml config. + * @param {string} workspacePath - Current absolute path to the workspace folder + * @param {string} newName - New name for the workspace + * @returns {Promise<{newWorkspacePath: string|null}>} - New path if folder was renamed, null otherwise + */ +const renameWorkspace = async (workspacePath, newName) => { + const parentDir = path.dirname(workspacePath); + const newFolderName = sanitizeName(newName); + const newWorkspacePath = path.join(parentDir, newFolderName); + + const pathsAreSame = path.normalize(workspacePath).toLowerCase() === path.normalize(newWorkspacePath).toLowerCase(); + + if (!pathsAreSame) { + if (fs.existsSync(newWorkspacePath)) { + throw new Error(`A folder named "${newFolderName}" already exists at this location`); + } + + fs.renameSync(workspacePath, newWorkspacePath); + + try { + await updateWorkspaceName(newWorkspacePath, newName); + } catch (error) { + try { + fs.renameSync(newWorkspacePath, workspacePath); + } catch (rollbackError) { + console.error('Failed to rollback workspace folder rename:', rollbackError); + } + throw error; + } + + return { newWorkspacePath }; + } + + await updateWorkspaceName(workspacePath, newName); + return { newWorkspacePath: null }; +}; + module.exports = { makeRelativePath, normalizeCollectionEntry, @@ -585,5 +623,6 @@ module.exports = { getWorkspaceUid, writeWorkspaceFileAtomic, isValidCollectionEntry, - isValidSpecEntry + isValidSpecEntry, + renameWorkspace }; diff --git a/packages/bruno-electron/tests/utils/workspace-config.spec.js b/packages/bruno-electron/tests/utils/workspace-config.spec.js index e70637214..55851070c 100644 --- a/packages/bruno-electron/tests/utils/workspace-config.spec.js +++ b/packages/bruno-electron/tests/utils/workspace-config.spec.js @@ -2,7 +2,7 @@ const path = require('path'); const fs = require('fs'); const os = require('os'); const yaml = require('js-yaml'); -const { reorderWorkspaceCollections } = require('../../src/utils/workspace-config'); +const { reorderWorkspaceCollections, renameWorkspace } = require('../../src/utils/workspace-config'); const collection = (name, pathSegment) => ({ name, path: pathSegment }); @@ -74,3 +74,136 @@ describe('reorderWorkspaceCollections', () => { expect(getCollectionPathsFromYml()).toEqual(['collections/api', 'collections/backend']); }); }); + +describe('renameWorkspace', () => { + let parentDir; + let workspacePath; + + /** Creates a workspace directory with workspace.yml */ + const createWorkspace = (folderName, workspaceName) => { + const wsPath = path.join(parentDir, folderName); + fs.mkdirSync(wsPath, { recursive: true }); + const content = [ + 'opencollection: 1.0.0', + 'info:', + ` name: "${workspaceName}"`, + ' type: workspace', + 'collections:', + 'specs:', + 'docs: \'\'' + ].join('\n'); + fs.writeFileSync(path.join(wsPath, 'workspace.yml'), content); + return wsPath; + }; + + /** Gets the workspace name from workspace.yml */ + const getWorkspaceName = (wsPath) => { + const raw = fs.readFileSync(path.join(wsPath, 'workspace.yml'), 'utf8'); + const config = yaml.load(raw); + return config.info?.name; + }; + + beforeEach(() => { + parentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-ws-parent-')); + workspacePath = createWorkspace('Untitled Workspace', 'Untitled Workspace'); + }); + + afterEach(() => { + fs.rmSync(parentDir, { recursive: true, force: true }); + }); + + test('renames workspace folder and updates config', async () => { + const result = await renameWorkspace(workspacePath, 'My API Project'); + + expect(result.newWorkspacePath).toBe(path.join(parentDir, 'My API Project')); + expect(fs.existsSync(path.join(parentDir, 'Untitled Workspace'))).toBe(false); + expect(fs.existsSync(path.join(parentDir, 'My API Project'))).toBe(true); + expect(getWorkspaceName(result.newWorkspacePath)).toBe('My API Project'); + }); + + test('only updates config when folder name stays the same', async () => { + // Create workspace where folder name matches sanitized target but display name differs + const sameFolderPath = createWorkspace('My-Project', 'Old Name'); + + // Rename to name that sanitizes to same folder name + const result = await renameWorkspace(sameFolderPath, 'My:Project'); + + expect(result.newWorkspacePath).toBeNull(); + expect(fs.existsSync(sameFolderPath)).toBe(true); + // Verify config was actually updated + expect(getWorkspaceName(sameFolderPath)).toBe('My:Project'); + }); + + test('sanitizes special characters in folder name', async () => { + const result = await renameWorkspace(workspacePath, 'My:API/Project'); + + expect(result.newWorkspacePath).toBe(path.join(parentDir, 'My-API-Project-Test-')); + expect(fs.existsSync(result.newWorkspacePath)).toBe(true); + expect(getWorkspaceName(result.newWorkspacePath)).toBe('My:API/Project'); + }); + + test('throws error when target folder already exists', async () => { + // Create another workspace with the target name + createWorkspace('Existing Project', 'Existing Project'); + + await expect(renameWorkspace(workspacePath, 'Existing Project')) + .rejects.toThrow('A folder named "Existing Project" already exists at this location'); + + // Original workspace should still exist + expect(fs.existsSync(workspacePath)).toBe(true); + }); + + test('handles case-only rename by updating config without renaming folder', async () => { + // Create workspace with lowercase name + const lowerPath = createWorkspace('myworkspace', 'myworkspace'); + + // Rename to different case - code uses case-insensitive comparison + // so this only updates config, doesn't rename folder (cross-platform safety) + const result = await renameWorkspace(lowerPath, 'MyWorkspace'); + + expect(result.newWorkspacePath).toBeNull(); + expect(fs.existsSync(lowerPath)).toBe(true); + expect(getWorkspaceName(lowerPath)).toBe('MyWorkspace'); + }); + + test('preserves workspace.yml content after rename', async () => { + // Add collections, specs, and other fields to the workspace + const configPath = path.join(workspacePath, 'workspace.yml'); + const content = [ + 'opencollection: 1.0.0', + 'info:', + ' name: "Untitled Workspace"', + ' type: workspace', + 'collections:', + ' - name: "API"', + ' path: "collections/api"', + ' remote: "https://github.com/example/api"', + 'specs:', + ' - name: "OpenAPI"', + ' path: "specs/openapi.yaml"', + 'docs: \'Some documentation\'', + '', + 'activeEnvironmentUid: env_123' + ].join('\n'); + fs.writeFileSync(configPath, content); + + const result = await renameWorkspace(workspacePath, 'My Project'); + + const newConfigPath = path.join(result.newWorkspacePath, 'workspace.yml'); + const newContent = fs.readFileSync(newConfigPath, 'utf8'); + const config = yaml.load(newContent); + + // Verify all fields are preserved + expect(config.opencollection).toBe('1.0.0'); + expect(config.info.name).toBe('My Project'); + expect(config.info.type).toBe('workspace'); + expect(config.collections).toHaveLength(1); + expect(config.collections[0].name).toBe('API'); + expect(config.collections[0].path).toBe('collections/api'); + expect(config.collections[0].remote).toBe('https://github.com/example/api'); + expect(config.specs).toHaveLength(1); + expect(config.specs[0]).toEqual({ name: 'OpenAPI', path: 'specs/openapi.yaml' }); + expect(config.docs).toBe('Some documentation'); + expect(config.activeEnvironmentUid).toBe('env_123'); + }); +});