mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-27 14:44:07 +00:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
76
packages/bruno-electron/tests/utils/workspace-config.spec.js
Normal file
76
packages/bruno-electron/tests/utils/workspace-config.spec.js
Normal 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']);
|
||||
});
|
||||
});
|
||||
93
tests/workspace/collection-reorder-persistence.spec.ts
Normal file
93
tests/workspace/collection-reorder-persistence.spec.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user