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
This commit is contained in:
Sanjai Kumar
2026-02-20 14:00:44 +05:30
committed by GitHub
parent dfa1533b72
commit 71227224dd
5 changed files with 260 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

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