feat(workspace): move external collections into the workspace (#8196)

This commit is contained in:
gopu-bruno
2026-06-12 11:22:53 +05:30
committed by GitHub
parent 59b4a16b79
commit 1d3a412539
12 changed files with 898 additions and 8 deletions

View File

@@ -0,0 +1,216 @@
import React, { useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { flattenItems, isItemARequest, hasRequestChanges, findCollectionByUid } from 'utils/collections';
import { pluralizeWord } from 'utils/common';
import { saveRequest, saveMultipleRequests, moveCollectionToWorkspace } from 'providers/ReduxStore/slices/collections/actions';
import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
import { IconAlertTriangle, IconDeviceFloppy } from '@tabler/icons';
import Modal from 'components/Modal';
import toast from 'react-hot-toast';
import Button from 'ui/Button';
import StyledWrapper from './StyledWrapper';
const MAX_UNSAVED_REQUESTS_TO_SHOW = 5;
const ConfirmMoveDrafts = ({ onClose, collection, collectionUid }) => {
const dispatch = useDispatch();
const [isMoving, setIsMoving] = useState(false);
const latestCollection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid));
const activeCollection = latestCollection || collection;
const currentDrafts = useMemo(() => {
if (!activeCollection) return [];
const items = flattenItems(activeCollection.items);
return items
?.filter((item) => isItemARequest(item) && hasRequestChanges(item) && !item.isTransient)
.map((item) => {
return {
...item,
collectionUid: collectionUid
};
});
}, [activeCollection, collectionUid]);
const currentTransientDrafts = useMemo(() => {
if (!activeCollection) return [];
const items = flattenItems(activeCollection.items);
return items
?.filter((item) => isItemARequest(item) && hasRequestChanges(item) && item.isTransient)
.map((item) => {
return {
...item,
collectionUid: collectionUid
};
});
}, [activeCollection, collectionUid]);
const allDrafts = useMemo(() => {
return [...currentDrafts, ...currentTransientDrafts];
}, [currentDrafts, currentTransientDrafts]);
const moveAndClose = () => {
dispatch(moveCollectionToWorkspace(collectionUid))
.then(() => {
toast.success('Collection moved into workspace');
onClose();
})
.catch((err) => {
toast.error(err?.message || 'An error occurred while moving the collection');
setIsMoving(false);
});
};
const handleSaveAll = () => {
if (isMoving) {
return;
}
// If there are transient drafts, we can't proceed with batch save
if (currentTransientDrafts.length > 0) {
toast.error('Please save or discard transient requests first');
return;
}
setIsMoving(true);
// Save only non-transient drafts, then move
if (currentDrafts.length > 0) {
dispatch(saveMultipleRequests(currentDrafts))
.then(() => moveAndClose())
.catch(() => {
toast.error('Failed to save requests!');
setIsMoving(false);
});
} else {
moveAndClose();
}
};
const handleDiscardAll = () => {
if (isMoving) {
return;
}
setIsMoving(true);
// Discard all drafts (both regular and transient), then move
allDrafts.forEach((draft) => {
dispatch(deleteRequestDraft({
collectionUid: collectionUid,
itemUid: draft.uid
}));
});
moveAndClose();
};
const handleSaveTransient = (draft) => {
dispatch(saveRequest(draft.uid, collectionUid));
};
if (!currentDrafts.length && !currentTransientDrafts.length) {
return null;
}
return (
<StyledWrapper>
<Modal
size="md"
title="Move into Workspace"
handleCancel={onClose}
disableEscapeKey={true}
disableCloseOnOutsideClick={true}
closeModalFadeTimeout={150}
hideFooter={true}
>
<div className="flex items-center">
<IconAlertTriangle size={32} strokeWidth={1.5} className="warning-text" />
<h1 className="ml-2 text-lg font-medium">Hold on..</h1>
</div>
<p className="mt-4">
You have unsaved changes in <span className="font-medium">{allDrafts.length}</span>{' '}
{pluralizeWord('request', allDrafts.length)}.
</p>
{/* Regular (saved) requests with changes */}
{currentDrafts.length > 0 && (
<div className="mt-4">
<p className="text-sm font-medium mb-2">
Saved {pluralizeWord('Request', currentDrafts.length)} ({currentDrafts.length})
</p>
<ul className="ml-2">
{currentDrafts.slice(0, MAX_UNSAVED_REQUESTS_TO_SHOW).map((item) => {
return (
<li key={item.uid} className="mt-1 text-xs draft-list-item">
{item.filename || item.name}
</li>
);
})}
</ul>
{currentDrafts.length > MAX_UNSAVED_REQUESTS_TO_SHOW && (
<p className="ml-2 mt-1 text-xs draft-list-item">
...{currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW} additional{' '}
{pluralizeWord('request', currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW)} not shown
</p>
)}
</div>
)}
{/* Transient (unsaved) requests */}
{currentTransientDrafts.length > 0 && (
<div className="mt-4">
<p className="text-sm font-medium mb-2">
Transient {pluralizeWord('Request', currentTransientDrafts.length)} ({currentTransientDrafts.length})
</p>
<p className="text-xs transient-hint mb-3">
These requests need to be saved individually before moving the collection.
</p>
<div className="space-y-2 max-h-64 overflow-y-auto pr-1">
{currentTransientDrafts.map((item) => {
return (
<div
key={item.uid}
className="flex items-center justify-between py-2 px-3 transient-item"
>
<span className="text-sm transient-item-name truncate mr-3">{item.name}</span>
<Button
data-testid="move-workspace-save-transient-draft"
color="primary"
variant="ghost"
size="sm"
onClick={() => handleSaveTransient(item)}
disabled={isMoving}
icon={<IconDeviceFloppy size={14} strokeWidth={1.5} />}
>
Save
</Button>
</div>
);
})}
</div>
</div>
)}
<div className="flex justify-between mt-6">
<div>
<Button data-testid="move-workspace-discard-all" color="danger" onClick={handleDiscardAll} disabled={isMoving}>
Discard All and Move
</Button>
</div>
<div>
<Button data-testid="move-workspace-cancel" className="mr-2" color="secondary" variant="ghost" onClick={onClose} disabled={isMoving}>
Cancel
</Button>
<Button
data-testid="move-workspace-save-and-move"
onClick={handleSaveAll}
disabled={currentTransientDrafts.length > 0 || isMoving}
title={currentTransientDrafts.length > 0 ? 'Please save or discard transient requests first' : ''}
>
{isMoving ? 'Moving...' : currentDrafts.length > 1 ? 'Save All and Move' : 'Save and Move'}
</Button>
</div>
</div>
</Modal>
</StyledWrapper>
);
};
export default ConfirmMoveDrafts;

View File

@@ -0,0 +1,54 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.collection-info-card {
background-color: ${(props) => props.theme.modal.title.bg};
border-radius: 4px;
padding: 12px;
}
.collection-name {
font-weight: 500;
padding-left: 0 !important;
color: ${(props) => props.theme.text};
margin-bottom: 4px;
cursor: default !important;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
background: none !important;
}
}
.collection-label {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
margin-bottom: 4px;
}
.collection-path {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
word-break: break-all;
}
.warning-icon {
color: ${(props) => props.theme.status.warning.text};
}
.warning-text {
color: ${(props) => props.theme.status.warning.text};
}
.draft-list-item {
color: ${(props) => props.theme.colors.text.muted};
}
.transient-hint {
color: ${(props) => props.theme.colors.text.warning};
}
.transient-item {
background-color: ${(props) => props.theme.background.surface0};
border: 1px solid ${(props) => props.theme.border.border0};
border-radius: 4px;
}
.transient-item-name {
color: ${(props) => props.theme.text};
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,92 @@
import React, { useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import Modal from 'components/Modal';
import { useDispatch, useSelector } from 'react-redux';
import { moveCollectionToWorkspace } from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid, flattenItems, isItemARequest, hasRequestChanges } from 'utils/collections/index';
import filter from 'lodash/filter';
import brunoPath from 'utils/common/path';
import ConfirmMoveDrafts from './ConfirmMoveDrafts';
import StyledWrapper from './StyledWrapper';
const MoveToWorkspace = ({ onClose, collectionUid }) => {
const dispatch = useDispatch();
const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid));
const activeWorkspace = useSelector((state) =>
state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid)
);
const [isMoving, setIsMoving] = useState(false);
// Detect unsaved drafts in the collection
const drafts = useMemo(() => {
if (!collection) return [];
const items = flattenItems(collection.items);
return filter(items, (item) => isItemARequest(item) && hasRequestChanges(item));
}, [collection]);
const onConfirm = () => {
if (!collection) {
toast.error('Collection not found');
onClose();
return;
}
if (isMoving) {
return;
}
setIsMoving(true);
dispatch(moveCollectionToWorkspace(collection.uid))
.then(() => {
toast.success('Collection moved into workspace');
onClose();
})
.catch((err) => {
toast.error(err?.message || 'An error occurred while moving the collection');
setIsMoving(false);
});
};
if (!collection) {
return <div>Collection not found</div>;
}
if (!activeWorkspace?.pathname) {
return null;
}
// Save or discard unsaved drafts before moving
if (drafts.length > 0) {
return <ConfirmMoveDrafts onClose={onClose} collection={collection} collectionUid={collectionUid} />;
}
const targetLocation = brunoPath.join(activeWorkspace.pathname, 'collections');
return (
<StyledWrapper>
<Modal
size="sm"
title="Move into Workspace"
confirmText={isMoving ? 'Moving...' : 'Move'}
confirmDisabled={isMoving}
handleConfirm={onConfirm}
handleCancel={onClose}
>
<p className="mb-4">
This will move the following collection's files into {activeWorkspace?.name} workspace.
</p>
<div className="collection-info-card">
<div className="collection-name">{collection.name}</div>
<div className="collection-path">{collection.pathname}</div>
</div>
<div className="mt-3 collection-info-card">
<div className="collection-label">Destination</div>
<div className="collection-path">{targetLocation}</div>
</div>
<p className="mt-4 text-muted text-sm">
The collection reloads from its new location, so any open request tabs will close.
</p>
</Modal>
</StyledWrapper>
);
};
export default MoveToWorkspace;

View File

@@ -20,7 +20,8 @@ import {
IconSettings,
IconTerminal2,
IconFolder,
IconBook
IconBook,
IconFileArrowRight
} from '@tabler/icons';
import OpenAPISyncIcon from 'components/Icons/OpenAPISync';
import { toggleCollection, collapseFullCollection } from 'providers/ReduxStore/slices/collections';
@@ -33,6 +34,8 @@ import NewRequest from 'components/Sidebar/NewRequest';
import NewFolder from 'components/Sidebar/NewFolder';
import CollectionItem from './CollectionItem';
import RemoveCollection from './RemoveCollection';
import MoveToWorkspace from './MoveToWorkspace';
import { isPathExternalToBasePath } from 'utils/common/path';
import { doesCollectionHaveItemsMatchingSearchText } from 'utils/collections/search';
import { isItemAFolder, isItemARequest, areItemsLoading } from 'utils/collections';
import { isTabForItemActive } from 'src/selectors/tab';
@@ -69,6 +72,7 @@ const Collection = ({ collection, searchText }) => {
const [showShareCollectionModal, setShowShareCollectionModal] = useState(false);
const [showGenerateDocumentationModal, setShowGenerateDocumentationModal] = useState(false);
const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false);
const [showMoveToWorkspaceModal, setShowMoveToWorkspaceModal] = useState(false);
const [dropType, setDropType] = useState(null);
const [isKeyboardFocused, setIsKeyboardFocused] = useState(false);
const [showEmptyState, setShowEmptyState] = useState(false);
@@ -83,6 +87,12 @@ const Collection = ({ collection, searchText }) => {
const { hasCopiedItems } = useSelector((state) => state.app.clipboard);
const menuDropdownRef = useRef(null);
// 'Move into Workspace' is available for collections opened from outside the current workspace.
const activeWorkspace = useSelector((state) =>
state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid)
);
const isMoveToWorkspaceVisible = isPathExternalToBasePath(activeWorkspace?.pathname, collection.pathname);
// Open the OpenAPI Sync tab
const openOpenAPISyncTab = () => {
ensureCollectionIsMounted();
@@ -439,6 +449,19 @@ const Collection = ({ collection, searchText }) => {
await openDevtoolsAndSwitchToTerminal(dispatch, collectionCwd);
}
},
...(isMoveToWorkspaceVisible
? [
{
id: 'move-to-workspace',
leftSection: IconFileArrowRight,
label: 'Move into Workspace',
testId: 'move-collection-to-workspace',
onClick: () => {
setShowMoveToWorkspaceModal(true);
}
}
]
: []),
{
id: 'remove',
leftSection: IconX,
@@ -459,6 +482,9 @@ const Collection = ({ collection, searchText }) => {
{showRemoveCollectionModal && (
<RemoveCollection collectionUid={collection.uid} onClose={() => setShowRemoveCollectionModal(false)} />
)}
{showMoveToWorkspaceModal && (
<MoveToWorkspace collectionUid={collection.uid} onClose={() => setShowMoveToWorkspaceModal(false)} />
)}
{showShareCollectionModal && (
<ShareCollection collectionUid={collection.uid} onClose={() => setShowShareCollectionModal(false)} />
)}

View File

@@ -7,7 +7,7 @@ import find from 'lodash/find';
import get from 'lodash/get';
import set from 'lodash/set';
import trim from 'lodash/trim';
import path, { normalizePath } from 'utils/common/path';
import path, { normalizePath, isPathExternalToBasePath } from 'utils/common/path';
import { insertTaskIntoQueue, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import IpcErrorModal from 'components/Errors/IpcErrorModal/index';
@@ -2425,6 +2425,60 @@ export const removeCollection = (collectionUid) => (dispatch, getState) => {
});
};
// Move an external collection into the workspace's collections directory
export const moveCollectionToWorkspace = (collectionUid) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
if (!collection) {
return reject(new Error('Collection not found'));
}
const { workspaces } = state;
const activeWorkspace = workspaces.workspaces.find((w) => w.uid === workspaces.activeWorkspaceUid);
if (!activeWorkspace || !activeWorkspace.pathname) {
return reject(new Error('No active workspace found'));
}
if (!isPathExternalToBasePath(activeWorkspace.pathname, collection.pathname)) {
return reject(new Error('Collection is already inside the workspace'));
}
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:move-collection-to-workspace', {
workspacePath: activeWorkspace.pathname,
collectionPath: collection.pathname,
collectionUid,
collectionName: collection.name
})
.then(async (result) => {
dispatch(closeAllCollectionTabs({ collectionUid }));
dispatch(removeCollectionFromWorkspace({
workspaceUid: activeWorkspace.uid,
collectionLocation: collection.pathname
}));
await waitForNextTick();
dispatch(_removeCollection({ collectionUid }));
if (result?.newPath) {
const openResult = await dispatch(openMultipleCollections([result.newPath], { workspacePath: activeWorkspace.pathname }));
const reopened = (openResult?.opened || []).some(
(openedPath) => normalizePath(openedPath) === normalizePath(result.newPath)
);
if (!reopened) {
throw new Error('Collection was moved into the workspace but could not be re-opened. Reload the workspace to access it.');
}
}
dispatch(ensureActiveTabInCurrentWorkspace());
})
.then(resolve)
.catch(reject);
});
};
export const browseDirectory = () => (dispatch, getState) => {
const { ipcRenderer } = window;

View File

@@ -218,5 +218,11 @@ const normalizePath = (p) => {
return p.replace(/\\/g, '/').replace(/\/+$/, '');
};
// Returns true when `targetPath` is located outside `basePath`.
const isPathExternalToBasePath = (basePath, targetPath) => {
if (!basePath || !targetPath) return false;
return brunoPath.isAbsolute(getRelativePathWithinBasePath(basePath, targetPath, true));
};
export default brunoPath;
export { getRelativePath, getBasename, getAbsoluteFilePath, getRelativePathWithinBasePath, normalizePath };
export { getRelativePath, getBasename, getAbsoluteFilePath, getRelativePathWithinBasePath, normalizePath, isPathExternalToBasePath };

View File

@@ -6,7 +6,7 @@ jest.mock('platform', () => ({
}));
import path from 'path';
import { getRelativePath, getBasename, getAbsoluteFilePath, getRelativePathWithinBasePath } from './path';
import { getRelativePath, getBasename, getAbsoluteFilePath, getRelativePathWithinBasePath, isPathExternalToBasePath } from './path';
describe('Path Utilities - Unix Platform', () => {
describe('getRelativePath', () => {
@@ -190,6 +190,42 @@ describe('Path Utilities - Unix Platform', () => {
});
});
describe('isPathExternalToBasePath', () => {
it('should return false for a collection directly inside the base path', () => {
expect(isPathExternalToBasePath('/users/john/workspace', '/users/john/workspace/collections/api')).toBe(false);
});
it('should return false for a deeply nested collection', () => {
expect(isPathExternalToBasePath('/users/john/workspace', '/users/john/workspace/a/b/c')).toBe(false);
});
it('should return true for a collection outside the base path', () => {
expect(isPathExternalToBasePath('/users/john/workspace', '/users/john/downloads/api')).toBe(true);
});
it('should return true for a sibling whose name is a prefix of the base path', () => {
// /workspace-other must not be treated as inside /workspace
expect(isPathExternalToBasePath('/users/john/workspace', '/users/john/workspace-other/api')).toBe(true);
});
it('should resolve dot segments before deciding', () => {
// resolves to /users/john/workspace/b — inside
expect(isPathExternalToBasePath('/users/john/workspace', '/users/john/workspace/a/../b')).toBe(false);
// resolves to /users/john/b — outside
expect(isPathExternalToBasePath('/users/john/workspace/a', '/users/john/workspace/a/../../b')).toBe(true);
});
it('should ignore a trailing separator on the base path', () => {
expect(isPathExternalToBasePath('/users/john/workspace/', '/users/john/workspace/collections/api')).toBe(false);
});
it('should return false when either input is missing', () => {
expect(isPathExternalToBasePath('', '/users/john/workspace/api')).toBe(false);
expect(isPathExternalToBasePath('/users/john/workspace', '')).toBe(false);
expect(isPathExternalToBasePath(undefined, undefined)).toBe(false);
});
});
describe('Edge cases', () => {
it('should handle very long paths', () => {
const longPath = '/users/john/projects/' + 'a'.repeat(100);

View File

@@ -5,7 +5,7 @@ jest.mock('platform', () => ({
}
}));
import { getRelativePath, getBasename, getAbsoluteFilePath, getRelativePathWithinBasePath } from './path';
import { getRelativePath, getBasename, getAbsoluteFilePath, getRelativePathWithinBasePath, isPathExternalToBasePath } from './path';
describe('Path Utilities - Windows Platform', () => {
describe('getRelativePath', () => {
@@ -422,4 +422,31 @@ describe('Path Utilities - Windows Platform', () => {
});
});
});
describe('isPathExternalToBasePath', () => {
it('should return false for a collection inside the base path', () => {
expect(isPathExternalToBasePath('C:\\Users\\John\\Workspace', 'C:\\Users\\John\\Workspace\\collections\\api')).toBe(false);
});
it('should be case-insensitive when matching the base path', () => {
expect(isPathExternalToBasePath('C:\\Users\\John\\Workspace', 'C:\\users\\john\\workspace\\collections\\api')).toBe(false);
});
it('should return true for a collection outside the base path', () => {
expect(isPathExternalToBasePath('C:\\Users\\John\\Workspace', 'C:\\Users\\John\\Downloads\\api')).toBe(true);
});
it('should return true for collections on a different drive', () => {
expect(isPathExternalToBasePath('C:\\Users\\John\\Workspace', 'D:\\api')).toBe(true);
});
it('should return true for a sibling whose name is a prefix of the base path', () => {
expect(isPathExternalToBasePath('C:\\Users\\John\\Workspace', 'C:\\Users\\John\\Workspace-other\\api')).toBe(true);
});
it('should return false when either input is missing', () => {
expect(isPathExternalToBasePath('', 'C:\\Users\\John\\Workspace\\api')).toBe(false);
expect(isPathExternalToBasePath('C:\\Users\\John\\Workspace', '')).toBe(false);
});
});
});

View File

@@ -49,6 +49,7 @@ const {
safeWriteFileSync,
copyPath,
removePath,
moveCollectionDirectory,
getPaths,
generateUniqueName,
isDotEnvFile,
@@ -58,7 +59,7 @@ const {
isCollectionRootBruFile,
scanForBrunoFiles
} = require('../utils/filesystem');
const { getCollectionConfigFile, openCollectionDialog, openCollectionsByPathname, registerScratchCollectionPath } = require('../app/collections');
const { getCollectionConfigFile, openCollection, openCollectionDialog, openCollectionsByPathname, registerScratchCollectionPath } = require('../app/collections');
const { generateUidBasedOnHash, stringifyJson, safeStringifyJSON, safeParseJSON } = require('../utils/common');
const { isValidNpmPackageName, runNpmInstall } = require('../utils/install-packages');
const { moveRequestUid, deleteRequestUid, syncExampleUidsCache } = require('../cache/requestUids');
@@ -78,6 +79,12 @@ const { REQUEST_TYPES } = require('../utils/constants');
const { cancelOAuth2AuthorizationRequest, isOauth2AuthorizationRequestInProgress } = require('../utils/oauth2-protocol-handler');
const { findUniqueFolderName } = require('../utils/collection-import');
const { saveSpecAndUpdateMetadata, cleanupSpecFilesForCollection } = require('./openapi-sync');
const {
validateWorkspacePath,
normalizeCollectionEntry,
addCollectionToWorkspace,
removeCollectionFromWorkspace
} = require('../utils/workspace-config');
const environmentSecretsStore = new EnvironmentSecretsStore();
const collectionSecurityStore = new CollectionSecurityStore();
@@ -306,6 +313,132 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
ipcMain.emit('main:collection-opened', mainWindow, dirPath, uid);
}
);
// move an external collection into the workspace's directory
ipcMain.handle(
'renderer:move-collection-to-workspace',
async (event, { workspacePath, collectionPath, collectionUid, collectionName }) => {
validateWorkspacePath(workspacePath);
if (!collectionPath || !isDirectory(collectionPath)) {
throw new Error(`Collection: ${collectionPath} does not exist`);
}
// resolve a collision-free target folder inside `<workspace>/collections`
const collectionsDir = path.join(workspacePath, 'collections');
if (!fs.existsSync(collectionsDir)) {
await createDirectory(collectionsDir);
}
let folderName = sanitizeName(path.basename(collectionPath));
if (fs.existsSync(path.join(collectionsDir, folderName))) {
const uniqueName = await findUniqueFolderName(folderName, collectionsDir);
folderName = sanitizeName(uniqueName);
}
const newPath = path.join(collectionsDir, folderName);
if (path.normalize(collectionPath) === path.normalize(newPath)) {
throw new Error('Collection is already inside the workspace');
}
// tear down the old watcher before moving the collection directory
if (watcher && mainWindow) {
watcher.removeWatcher(collectionPath, mainWindow, collectionUid);
if (wsClient) {
wsClient.closeForCollection(collectionUid);
}
}
// move the collection directory into the workspace
try {
await moveCollectionDirectory(collectionPath, newPath);
} catch (error) {
// reopen the collection at the original path
if (watcher && mainWindow && fs.existsSync(collectionPath)) {
try {
await openCollection(mainWindow, watcher, collectionPath);
} catch (err) {
console.error('Failed to restore collection after move failure:', err);
}
}
throw error;
}
// remap request uids so request identity survives the move
try {
const movedRequestFiles = searchForRequestFiles(newPath);
for (const newFilePath of movedRequestFiles) {
const oldFilePath = newFilePath.replace(newPath, collectionPath);
moveRequestUid(oldFilePath, newFilePath);
}
} catch (error) {
console.error('Error remapping request uids after move:', error);
}
// Register the collection at its new path in workspace.yml
try {
await addCollectionToWorkspace(
workspacePath,
normalizeCollectionEntry(workspacePath, {
name: collectionName,
path: newPath
})
);
} catch (error) {
console.error('Failed to register collection in workspace.yml:', error);
try {
await moveCollectionDirectory(newPath, collectionPath);
const restoredRequestFiles = searchForRequestFiles(collectionPath);
for (const restoredFilePath of restoredRequestFiles) {
const movedFilePath = restoredFilePath.replace(collectionPath, newPath);
moveRequestUid(movedFilePath, restoredFilePath);
}
} catch (rollbackError) {
console.error('Failed to roll back collection move:', rollbackError);
}
if (watcher && mainWindow && fs.existsSync(collectionPath)) {
try {
await openCollection(mainWindow, watcher, collectionPath);
} catch (err) {
console.error('Failed to restore collection after rollback:', err);
}
}
throw error;
}
// remove the stale workspace.yml entry
try {
await removeCollectionFromWorkspace(workspacePath, collectionPath);
} catch (error) {
console.error('Error cleaning up old workspace.yml entry after move:', error);
}
// update the recently opened collections store to point at the new location
try {
const LastOpenedCollections = require('../store/last-opened-collections');
const lastOpenedCollections = new LastOpenedCollections();
lastOpenedCollections.remove(collectionPath);
lastOpenedCollections.add(newPath);
} catch (error) {
console.error('Error updating last opened collections after move:', error);
}
// process env cleanup for the old uid
try {
const { clearCollectionWorkspace } = require('../store/process-env');
clearCollectionWorkspace(collectionUid);
} catch (error) {
console.error('Error clearing collection workspace mapping after move:', error);
}
return {
newPath,
newUid: generateUidBasedOnHash(newPath),
folderName
};
}
);
// rename collection
ipcMain.handle('renderer:rename-collection', async (event, newName, collectionPathname) => {
try {
@@ -1119,7 +1252,6 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
if (workspacePath && workspacePath !== 'default') {
try {
const { removeCollectionFromWorkspace } = require('../utils/workspace-config');
await removeCollectionFromWorkspace(workspacePath, collectionPath);
} catch (error) {
console.error('Error removing collection from workspace.yml:', error);

View File

@@ -404,6 +404,37 @@ const removePath = async (source) => {
}
};
/**
* Move a collection directory from source to destination.
* Uses fs-extra's move for cross-device compatibility.
*/
const moveCollectionDirectory = async (source, destination) => {
const needsWindowsSafeMove = isWindowsOS() && !isWSLPath(source) && hasSubDirectories(source);
if (!needsWindowsSafeMove) {
await fs.move(source, destination, { overwrite: false });
return;
}
const tempDir = path.join(os.tmpdir(), `temp-collection-${Date.now()}`);
try {
await fs.copy(source, tempDir);
await fs.remove(source);
await fs.move(tempDir, destination, { overwrite: false });
await fs.remove(tempDir);
} catch (error) {
// restore the source if the windows safe move left files in the temp dir
if (fs.pathExistsSync(tempDir) && !fs.pathExistsSync(source)) {
try {
await fs.copy(tempDir, source);
await fs.remove(tempDir);
} catch (err) {
console.error('Failed to restore collection directory to its original path:', err);
}
}
throw error;
}
};
// Recursively gets paths.
const getPaths = async (source) => {
let paths = [];
@@ -536,6 +567,7 @@ module.exports = {
safeWriteFileSync,
copyPath,
removePath,
moveCollectionDirectory,
getPaths,
isLargeFile,
generateUniqueName,

View File

@@ -1,5 +1,7 @@
const { sanitizeName, isWSLPath, normalizeWSLPath, normalizeAndResolvePath, isLargeFile } = require('./filesystem.js');
const { sanitizeName, isWSLPath, normalizeWSLPath, normalizeAndResolvePath, isLargeFile, moveCollectionDirectory } = require('./filesystem.js');
const fs = require('fs-extra');
const os = require('os');
const path = require('path');
describe('sanitizeName', () => {
it('should replace invalid characters with hyphens', () => {
@@ -135,3 +137,42 @@ describe('WSL Path Utilities', () => {
});
});
});
describe('moveCollectionDirectory', () => {
let workDir;
beforeEach(async () => {
workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bruno-move-test-'));
});
afterEach(async () => {
await fs.remove(workDir);
});
it('moves a collection directory with its nested contents to a new location', async () => {
const source = path.join(workDir, 'source-collection');
const destination = path.join(workDir, 'collections', 'source-collection');
await fs.ensureDir(path.join(source, 'folder'));
await fs.writeFile(path.join(source, 'bruno.json'), '{"name":"test"}');
await fs.writeFile(path.join(source, 'folder', 'request.bru'), 'meta {}');
await moveCollectionDirectory(source, destination);
expect(fs.existsSync(source)).toBe(false);
expect(fs.readFileSync(path.join(destination, 'bruno.json'), 'utf8')).toBe('{"name":"test"}');
expect(fs.readFileSync(path.join(destination, 'folder', 'request.bru'), 'utf8')).toBe('meta {}');
});
it('throws and leaves the source intact when the destination already exists', async () => {
const source = path.join(workDir, 'source-collection');
const destination = path.join(workDir, 'existing-collection');
await fs.ensureDir(source);
await fs.writeFile(path.join(source, 'bruno.json'), '{"name":"test"}');
await fs.ensureDir(destination);
await expect(moveCollectionDirectory(source, destination)).rejects.toThrow();
// source must remain untouched so the caller can safely retry / roll back
expect(fs.existsSync(path.join(source, 'bruno.json'))).toBe(true);
});
});

View File

@@ -0,0 +1,174 @@
import path from 'path';
import fs from 'fs';
import { test, expect, Page, ElectronApplication, closeElectronApp } from '../../playwright';
import {
createCollection,
createRequest,
openRequest,
openWorkspaceFromDialog,
waitForReadyPage
} from '../utils/page';
import { buildCommonLocators } from '../utils/page/locators';
const workspaceYml = (name: string) =>
['opencollection: 1.0.0', 'info:', ` name: ${name}`, ' type: workspace', 'collections:', 'specs: []', 'docs: \'\'', ''].join(
'\n'
);
// Open a collection's context menu in the sidebar.
const openCollectionMenu = async (page: Page, collectionName: string) => {
const locators = buildCommonLocators(page);
await locators.sidebar.collection(collectionName).hover();
const collectionAction = locators.actions.collectionActions(collectionName);
await expect(collectionAction).toBeVisible({ timeout: 2000 });
await collectionAction.click();
};
test.describe('Move collection into workspace', () => {
test('moves an external collection into workspace/collections directory and reopens it there', async ({
launchElectronApp,
createTmpDir
}) => {
const userDataPath = await createTmpDir('move-ws-userdata');
const workspacePath = await createTmpDir('move-ws-workspace');
const externalLocation = await createTmpDir('move-ws-external');
fs.writeFileSync(path.join(workspacePath, 'workspace.yml'), workspaceYml('MoveWS'));
const collectionName = 'ExternalCol';
const sourcePath = path.join(externalLocation, collectionName);
const destinationPath = path.join(workspacePath, 'collections', collectionName);
let app: ElectronApplication | undefined;
try {
app = await launchElectronApp({ userDataPath });
const page = await waitForReadyPage(app);
const locators = buildCommonLocators(page);
await test.step('Open MoveWS workspace', async () => {
await openWorkspaceFromDialog(app, page, workspacePath);
await expect(page.getByTestId('workspace-name')).toHaveText('MoveWS', { timeout: 10000 });
});
await test.step('Create an external collection with a request', async () => {
await createCollection(page, collectionName, externalLocation);
await createRequest(page, 'ping', collectionName, { url: 'https://echo.usebruno.com', method: 'GET' });
expect(fs.existsSync(sourcePath)).toBe(true);
expect(fs.existsSync(destinationPath)).toBe(false);
});
await test.step('Move the collection into the workspace', async () => {
await openCollectionMenu(page, collectionName);
await page.getByTestId('collection-actions-move-to-workspace').click();
const moveModal = locators.modal.byTitle('Move into Workspace');
await expect(moveModal).toBeVisible({ timeout: 5000 });
await locators.modal.button('Move').click();
await expect(page.getByText('Collection moved into workspace')).toBeVisible({ timeout: 10000 });
});
await test.step('Files moved on disk and collection reopened from the new path', async () => {
// Source directory is gone, destination exists with the request preserved
expect(fs.existsSync(sourcePath)).toBe(false);
expect(fs.existsSync(destinationPath)).toBe(true);
expect(fs.readdirSync(destinationPath).some((entry) => entry.startsWith('ping'))).toBe(true);
// Collection is still listed in the sidebar
await expect(locators.sidebar.collection(collectionName)).toBeVisible({ timeout: 10000 });
// workspace.yml now references the collection at its new location
const workspaceConfig = fs.readFileSync(path.join(workspacePath, 'workspace.yml'), 'utf-8');
expect(workspaceConfig).toContain(collectionName);
});
} finally {
if (app) await closeElectronApp(app);
}
});
test('action is hidden for collections already inside the workspace', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('move-ws-internal-userdata');
const workspacePath = await createTmpDir('move-ws-internal-workspace');
fs.writeFileSync(path.join(workspacePath, 'workspace.yml'), workspaceYml('InternalWS'));
// The internal collection must live under the workspace root, so ensure the
// collections directory exists so the Create Collection modal accepts the location.
const internalCollectionsDir = path.join(workspacePath, 'collections');
fs.mkdirSync(internalCollectionsDir, { recursive: true });
const internalName = 'InternalCol';
let app: ElectronApplication | undefined;
try {
app = await launchElectronApp({ userDataPath });
const page = await waitForReadyPage(app);
await openWorkspaceFromDialog(app, page, workspacePath);
await expect(page.getByTestId('workspace-name')).toHaveText('InternalWS', { timeout: 10000 });
await createCollection(page, internalName, path.join(workspacePath, 'collections'));
await openCollectionMenu(page, internalName);
// The remove action confirms the menu is open
await expect(page.getByTestId('collection-actions-remove')).toBeVisible({ timeout: 5000 });
await expect(page.getByTestId('collection-actions-move-to-workspace')).toHaveCount(0);
} finally {
if (app) await closeElectronApp(app);
}
});
test('prompts to save or discard drafts before moving', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('move-ws-drafts-userdata');
const workspacePath = await createTmpDir('move-ws-drafts-workspace');
const externalLocation = await createTmpDir('move-ws-drafts-external');
fs.writeFileSync(path.join(workspacePath, 'workspace.yml'), workspaceYml('DraftsWS'));
const collectionName = 'DraftCol';
const sourcePath = path.join(externalLocation, collectionName);
const destinationPath = path.join(workspacePath, 'collections', collectionName);
let app: ElectronApplication | undefined;
try {
app = await launchElectronApp({ userDataPath });
const page = await waitForReadyPage(app);
const locators = buildCommonLocators(page);
await openWorkspaceFromDialog(app, page, workspacePath);
await expect(page.getByTestId('workspace-name')).toHaveText('DraftsWS', { timeout: 10000 });
await createCollection(page, collectionName, externalLocation);
await test.step('Create a saved request, then dirty it to produce an unsaved draft', async () => {
await createRequest(page, 'ping', collectionName, { url: 'https://echo.usebruno.com', method: 'GET' });
await openRequest(page, collectionName, 'ping');
await locators.request.urlInput().click();
await page.locator('#request-url textarea').fill('https://echo.usebruno.com/changed');
await expect(locators.tabs.draftIndicator()).toBeVisible({ timeout: 5000 });
});
await test.step('Move shows the drafts confirmation, then discard and move', async () => {
await openCollectionMenu(page, collectionName);
await page.getByTestId('collection-actions-move-to-workspace').click();
// Drafts confirmation modal appears instead of the plain move modal
await expect(page.getByText('You have unsaved changes')).toBeVisible({ timeout: 5000 });
await page.getByTestId('move-workspace-discard-all').click();
await expect(page.getByText('Collection moved into workspace')).toBeVisible({ timeout: 10000 });
});
expect(fs.existsSync(sourcePath)).toBe(false);
expect(fs.existsSync(destinationPath)).toBe(true);
// The discarded draft change must not have been saved
const requestFiles = fs.readdirSync(destinationPath).filter((entry) => entry.startsWith('ping'));
expect(requestFiles.length).toBeGreaterThan(0);
const movedRequestContent = fs.readFileSync(path.join(destinationPath, requestFiles[0]), 'utf-8');
expect(movedRequestContent).toContain('https://echo.usebruno.com');
expect(movedRequestContent).not.toContain('https://echo.usebruno.com/changed');
await expect(locators.sidebar.collection(collectionName)).toBeVisible({ timeout: 10000 });
} finally {
if (app) await closeElectronApp(app);
}
});
});