From 71227224dd804cd5b54a50364bfff279e47d8665 Mon Sep 17 00:00:00 2001 From: Sanjai Kumar <161328623+sanjaikumar-bruno@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:00:44 +0530 Subject: [PATCH] fix: collection reorder not persisting after restart (#7093) * feat: implement workspace collection reordering functionality feat: add tests and improve collection reordering functionality fix: handle IPC errors during collection reordering and update tests for persistence validation refactor: enhance collection reordering logic and update related tests for path consistency fix: prevent unnecessary promise resolution in collection reordering when no collections are present * fix: ensure consistent path normalization for workspace collections during reordering --- .../ReduxStore/slices/collections/actions.js | 36 ++++++- packages/bruno-electron/src/ipc/workspace.js | 13 +++ .../src/utils/workspace-config.js | 44 +++++++++ .../tests/utils/workspace-config.spec.js | 76 +++++++++++++++ .../collection-reorder-persistence.spec.ts | 93 +++++++++++++++++++ 5 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 packages/bruno-electron/tests/utils/workspace-config.spec.js create mode 100644 tests/workspace/collection-reorder-persistence.spec.ts 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 27508f61b..c6e65ad17 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -2739,11 +2739,43 @@ export const importCollectionFromZip = (zipFilePath, collectionLocation) => asyn return collectionPath; }; +/** + * Updates Redux collection order and persists it to the active workspace's workspace.yml. + */ export const moveCollectionAndPersist = ({ draggedItem, targetItem }) => (dispatch, getState) => { - dispatch(moveCollection({ draggedItem, targetItem })); - return Promise.resolve(); + const state = getState(); + const activeWorkspace = state.workspaces.workspaces.find( + (w) => w.uid === state.workspaces.activeWorkspaceUid + ); + if (!activeWorkspace?.pathname || !activeWorkspace.collections?.length) { + return Promise.resolve(); + } + + const workspacePathSet = new Set( + activeWorkspace.collections.map((wc) => normalizePath(wc.path)) + ); + const collectionsInWorkspace = state.collections.collections + .filter((c) => workspacePathSet.has(normalizePath(c.pathname))); + if (collectionsInWorkspace.length === 0) { + return Promise.resolve(); + } + + const reordered = collectionsInWorkspace.filter((i) => i.uid !== draggedItem.uid); + const targetIndex = reordered.findIndex((i) => i.uid === targetItem.uid); + reordered.splice(targetIndex, 0, draggedItem); + const collectionPaths = reordered.map((c) => c.pathname); + + return window.ipcRenderer + .invoke('renderer:reorder-workspace-collections', activeWorkspace.pathname, collectionPaths) + .then(() => { + dispatch(moveCollection({ draggedItem, targetItem })); + }) + .catch((err) => { + console.error('Failed to reorder workspace collections', err); + return Promise.reject(err); + }); }; export const saveCollectionSecurityConfig = (collectionUid, securityConfig) => (dispatch, getState) => { diff --git a/packages/bruno-electron/src/ipc/workspace.js b/packages/bruno-electron/src/ipc/workspace.js index 908300bf9..a896a2a09 100644 --- a/packages/bruno-electron/src/ipc/workspace.js +++ b/packages/bruno-electron/src/ipc/workspace.js @@ -20,6 +20,7 @@ const { updateWorkspaceDocs, addCollectionToWorkspace, removeCollectionFromWorkspace, + reorderWorkspaceCollections, getWorkspaceCollections, normalizeCollectionEntry, validateWorkspacePath, @@ -190,6 +191,18 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => { } }); + ipcMain.handle('renderer:reorder-workspace-collections', async (event, workspacePath, collectionPaths) => { + try { + if (!workspacePath) { + throw new Error('Workspace path is undefined'); + } + validateWorkspacePath(workspacePath); + await reorderWorkspaceCollections(workspacePath, collectionPaths); + } catch (error) { + throw error; + } + }); + ipcMain.handle('renderer:load-workspace-apispecs', async (event, workspacePath) => { try { if (!workspacePath) { diff --git a/packages/bruno-electron/src/utils/workspace-config.js b/packages/bruno-electron/src/utils/workspace-config.js index d48d01f20..51d095176 100644 --- a/packages/bruno-electron/src/utils/workspace-config.js +++ b/packages/bruno-electron/src/utils/workspace-config.js @@ -141,6 +141,12 @@ const makeRelativePath = (workspacePath, absolutePath) => { } }; +const getNormalizedAbsoluteCollectionPath = (workspacePath, collection) => { + if (!collection?.path) return null; + const resolved = path.isAbsolute(collection.path) ? collection.path : path.resolve(workspacePath, collection.path); + return path.normalize(resolved); +}; + const normalizeCollectionEntry = (workspacePath, collection) => { const relativePath = makeRelativePath(workspacePath, collection.path); @@ -394,6 +400,43 @@ const removeCollectionFromWorkspace = async (workspacePath, collectionPath) => { }); }; +/** + * Reorders the collections array in the workspace's workspace.yml to match the given path list. + * Entries not in the list are appended at the end. + * @param {string} workspacePath - Absolute path to the workspace directory + * @param {string[]} collectionPaths - Absolute collection pathnames in the desired order + */ +const reorderWorkspaceCollections = async (workspacePath, collectionPaths) => { + if (!Array.isArray(collectionPaths)) { + throw new Error('collectionPaths must be an array'); + } + + return withLock(getWorkspaceLockKey(workspacePath), async () => { + const config = readWorkspaceConfig(workspacePath); + const existing = config.collections || []; + + const inNewOrder = []; + const matched = new Set(); + + for (const absolutePath of collectionPaths) { + const targetPath = posixifyPath(path.normalize(absolutePath)); + const entry = existing.find( + (c) => posixifyPath(getNormalizedAbsoluteCollectionPath(workspacePath, c)) === targetPath + ); + if (entry && !matched.has(entry)) { + inNewOrder.push(entry); + matched.add(entry); + } + } + + const notInList = existing.filter((c) => !matched.has(c)); + config.collections = [...inNewOrder, ...notInList]; + + const yamlContent = generateYamlContent(config); + await writeWorkspaceFileAtomic(workspacePath, yamlContent); + }); +}; + const getWorkspaceCollections = (workspacePath) => { const config = readWorkspaceConfig(workspacePath); const collections = config.collections || []; @@ -533,6 +576,7 @@ module.exports = { updateWorkspaceDocs, addCollectionToWorkspace, removeCollectionFromWorkspace, + reorderWorkspaceCollections, getWorkspaceCollections, getWorkspaceApiSpecs, addApiSpecToWorkspace, diff --git a/packages/bruno-electron/tests/utils/workspace-config.spec.js b/packages/bruno-electron/tests/utils/workspace-config.spec.js new file mode 100644 index 000000000..e70637214 --- /dev/null +++ b/packages/bruno-electron/tests/utils/workspace-config.spec.js @@ -0,0 +1,76 @@ +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 collection = (name, pathSegment) => ({ name, path: pathSegment }); + +describe('reorderWorkspaceCollections', () => { + let workspacePath; + + /** Writes workspace.yml with the given collections (relative paths). */ + const writeWorkspaceYml = (collections) => { + const content = [ + 'opencollection: 1.0.0', + 'info:', + ' name: Test', + ' type: workspace', + 'collections:', + ...collections.flatMap((c) => [` - name: ${c.name}`, ` path: ${c.path}`]), + 'specs: []', + 'docs: \'\'' + ].join('\n'); + fs.writeFileSync(path.join(workspacePath, 'workspace.yml'), content); + }; + + /** Returns collection paths (relative) in order as stored in workspace.yml. */ + const getCollectionPathsFromYml = () => { + const raw = fs.readFileSync(path.join(workspacePath, 'workspace.yml'), 'utf8'); + const config = yaml.load(raw); + return (config.collections || []).map((c) => c.path); + }; + + /** Resolves a relative collection path segment to an absolute path under the current workspace. */ + const absPath = (relativePath) => path.resolve(workspacePath, relativePath); + + beforeEach(() => { + workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-ws-')); + }); + + afterEach(() => { + fs.rmSync(workspacePath, { recursive: true, force: true }); + }); + + test('reorders collections to match given path list', async () => { + writeWorkspaceYml([ + collection('API', 'collections/api'), + collection('Backend', 'collections/backend'), + collection('Frontend', 'collections/frontend') + ]); + + await reorderWorkspaceCollections(workspacePath, [ + absPath('collections/frontend'), + absPath('collections/api'), + absPath('collections/backend') + ]); + + expect(getCollectionPathsFromYml()).toEqual(['collections/frontend', 'collections/api', 'collections/backend']); + }); + + test('deduplicates when reorder list contains duplicate paths', async () => { + writeWorkspaceYml([ + collection('API', 'collections/api'), + collection('Backend', 'collections/backend') + ]); + + await reorderWorkspaceCollections(workspacePath, [ + absPath('collections/api'), + absPath('collections/backend'), + absPath('collections/api'), + absPath('collections/api') + ]); + + expect(getCollectionPathsFromYml()).toEqual(['collections/api', 'collections/backend']); + }); +}); diff --git a/tests/workspace/collection-reorder-persistence.spec.ts b/tests/workspace/collection-reorder-persistence.spec.ts new file mode 100644 index 000000000..763ab3378 --- /dev/null +++ b/tests/workspace/collection-reorder-persistence.spec.ts @@ -0,0 +1,93 @@ +import path from 'path'; +import fs from 'fs'; +import yaml from 'js-yaml'; +import { test, expect } from '../../playwright'; +import { createCollection } from '../utils/page'; + +type WorkspaceConfig = { collections?: { name: string }[] }; + +test.describe('Collection reorder persistence', () => { + test('reordered collection order persists after app restart', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('collection-reorder-persistence'); + const colAPath = await createTmpDir('col-a'); + const colBPath = await createTmpDir('col-b'); + + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Create two collections', async () => { + await createCollection(page, 'ColA', colAPath); + await createCollection(page, 'ColB', colBPath); + }); + + await test.step('Verify initial order is ColA then ColB', async () => { + const rows = page.getByTestId('sidebar-collection-row'); + await expect(rows.nth(0)).toContainText('ColA'); + await expect(rows.nth(1)).toContainText('ColB'); + }); + + await test.step('Drag ColB above ColA', async () => { + const rows = page.getByTestId('sidebar-collection-row'); + await rows.nth(1).dragTo(rows.nth(0), { targetPosition: { x: 5, y: 5 } }); + }); + + await test.step('Verify order is ColB then ColA', async () => { + const rows = page.getByTestId('sidebar-collection-row'); + await expect(rows.nth(0)).toContainText('ColB'); + await expect(rows.nth(1)).toContainText('ColA'); + }); + + await test.step('Close app', async () => { + await app.context().close(); + await app.close(); + }); + + await test.step('Restart app and verify order persisted', async () => { + const app2 = await launchElectronApp({ userDataPath }); + const page2 = await app2.firstWindow(); + await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + const rows2 = page2.getByTestId('sidebar-collection-row'); + await expect(rows2.nth(0)).toContainText('ColB'); + await expect(rows2.nth(1)).toContainText('ColA'); + + await app2.context().close(); + await app2.close(); + }); + }); + + test('workspace.yml reflects reordered collection order', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('collection-reorder-yml'); + const colAPath = await createTmpDir('col-a'); + const colBPath = await createTmpDir('col-b'); + + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Create two collections', async () => { + await createCollection(page, 'ColA', colAPath); + await createCollection(page, 'ColB', colBPath); + }); + + await test.step('Drag ColB above ColA', async () => { + const rows = page.getByTestId('sidebar-collection-row'); + await rows.nth(1).dragTo(rows.nth(0), { targetPosition: { x: 5, y: 5 } }); + }); + + await test.step('Close app', async () => { + await app.context().close(); + await app.close(); + }); + + await test.step('Verify workspace.yml has ColB before ColA', async () => { + const workspacePath = path.join(userDataPath, 'default-workspace'); + const ymlPath = path.join(workspacePath, 'workspace.yml'); + expect(fs.existsSync(ymlPath)).toBe(true); + const config = yaml.load(fs.readFileSync(ymlPath, 'utf8')) as WorkspaceConfig | undefined; + const names = (config?.collections ?? []).map((c) => c.name); + expect(names).toEqual(['ColB', 'ColA']); + }); + }); +});