mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-01 16:44:16 +00:00
feat(workspace): move external collections into the workspace (#8196)
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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)} />
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
174
tests/workspace/move-collection-to-workspace.spec.ts
Normal file
174
tests/workspace/move-collection-to-workspace.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user