From 38c307d6f10a477dd2b60f4ee7079cc0b0bf4b72 Mon Sep 17 00:00:00 2001 From: lohit Date: Mon, 5 May 2025 16:52:00 +0530 Subject: [PATCH] feat: folder sequencing (#4595) Co-authored-by: Pooja Belaramani Co-authored-by: naman-bruno Co-authored-by: lohit --- package-lock.json | 62 +--- .../CollectionSettings/Overview/Info/index.js | 2 +- .../src/components/FolderSettings/index.js | 2 +- .../components/RequestPane/QueryUrl/index.js | 2 +- .../RequestTabs/RequestTab/index.js | 5 +- .../src/components/RequestTabs/index.js | 2 +- .../src/components/ShareCollection/index.js | 5 +- .../Collection/CloneCollection/index.js | 8 +- .../CloneCollectionItem/index.js | 7 +- .../DeleteCollectionItem/index.js | 4 +- .../CollectionItem/GenerateCodeItem/index.js | 4 +- .../RenameCollectionItem/index.js | 9 +- .../CollectionItem/RunCollectionItem/index.js | 8 +- .../CollectionItem/StyledWrapper.js | 73 ++++ .../Collection/CollectionItem/index.js | 243 ++++++------ .../Collection/RemoveCollection/index.js | 6 +- .../Collection/RenameCollection/index.js | 6 +- .../Collections/Collection/StyledWrapper.js | 30 ++ .../Sidebar/Collections/Collection/index.js | 45 +-- .../src/components/Sidebar/NewFolder/index.js | 4 +- .../components/Sidebar/NewRequest/index.js | 16 +- .../ReduxStore/slices/collections/actions.js | 345 +++++++++--------- .../ReduxStore/slices/collections/index.js | 7 + packages/bruno-app/src/selectors/tab.js | 9 + packages/bruno-app/src/themes/dark.js | 6 + packages/bruno-app/src/themes/light.js | 6 + .../bruno-app/src/utils/collections/index.js | 152 ++++---- .../collections/items-sequencing.spec.js | 126 +++++++ .../src/postman/postman-to-bruno.js | 4 +- .../postman-to-bruno/postman-to-bruno.spec.js | 169 ++++----- packages/bruno-electron/src/app/watcher.js | 14 +- packages/bruno-electron/src/bru/index.js | 10 +- packages/bruno-electron/src/ipc/collection.js | 99 +++-- .../bruno-electron/src/ipc/network/index.js | 1 + .../bruno-electron/src/utils/collection.js | 81 +++- .../bruno-electron/src/utils/filesystem.js | 46 ++- .../src/utils/tests/filesystem/index.spec.js | 116 ++++++ .../filesystem/copypath-removepath.js | 155 ++++++++ .../bruno-schema/src/collections/index.js | 3 +- 39 files changed, 1280 insertions(+), 612 deletions(-) create mode 100644 packages/bruno-app/src/selectors/tab.js create mode 100644 packages/bruno-app/src/utils/tests/collections/items-sequencing.spec.js create mode 100644 packages/bruno-electron/src/utils/tests/filesystem/index.spec.js create mode 100644 packages/bruno-electron/src/utils/tests/fixtures/filesystem/copypath-removepath.js diff --git a/package-lock.json b/package-lock.json index ff174d7e6..08f122dc9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -1474,6 +1475,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", + "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", @@ -1504,6 +1506,7 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1521,6 +1524,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/@babel/generator": { @@ -1800,6 +1804,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.25.9", @@ -7784,6 +7789,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, "license": "MIT" }, "node_modules/@types/lodash": { @@ -7806,6 +7812,7 @@ "version": "12.2.3", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/linkify-it": "*", @@ -7816,6 +7823,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, "license": "MIT" }, "node_modules/@types/ms": { @@ -11060,6 +11068,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, "license": "MIT" }, "node_modules/cookie": { @@ -12670,6 +12679,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -13637,6 +13647,7 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -24196,7 +24207,7 @@ "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -26303,9 +26314,7 @@ "@rollup/plugin-typescript": "^12.1.2", "@types/jest": "^29.5.14", "babel-jest": "^29.7.0", - "cheerio": "^1.0.0", "moment": "^2.29.4", - "playwright": "^1.52.0", "rollup": "3.29.5", "rollup-plugin-dts": "^5.0.0", "rollup-plugin-peer-deps-external": "^2.2.4", @@ -26815,21 +26824,6 @@ } } }, - "packages/bruno-common/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "packages/bruno-common/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -26837,38 +26831,6 @@ "dev": true, "license": "MIT" }, - "packages/bruno-common/node_modules/playwright": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", - "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.52.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "packages/bruno-common/node_modules/playwright-core": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", - "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "packages/bruno-common/node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", diff --git a/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js b/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js index 751919cde..e08866ccf 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js @@ -72,7 +72,7 @@ const Info = ({ collection }) => { - {showShareCollectionModal && } + {showShareCollectionModal && } diff --git a/packages/bruno-app/src/components/FolderSettings/index.js b/packages/bruno-app/src/components/FolderSettings/index.js index f9e34fa33..621ae6815 100644 --- a/packages/bruno-app/src/components/FolderSettings/index.js +++ b/packages/bruno-app/src/components/FolderSettings/index.js @@ -28,7 +28,7 @@ const FolderSettings = ({ collection, folder }) => { tab = folderLevelSettingsSelectedTab[folder?.uid]; } - const folderRoot = collection?.items.find((item) => item.uid === folder?.uid)?.root; + const folderRoot = folder?.root; const hasScripts = folderRoot?.request?.script?.res || folderRoot?.request?.script?.req; const hasTests = folderRoot?.request?.tests; diff --git a/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js b/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js index 9f3e600d0..321ed4fd5 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js +++ b/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js @@ -140,7 +140,7 @@ const QueryUrl = ({ item, collection, handleRun }) => { {generateCodeItemModalOpen && ( - setGenerateCodeItemModalOpen(false)} /> + setGenerateCodeItemModalOpen(false)} /> )} ); diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js index 562fc319f..072394b7f 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js @@ -261,13 +261,14 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col return ( {showAddNewRequestModal && ( - setShowAddNewRequestModal(false)} /> + setShowAddNewRequestModal(false)} /> )} {showCloneRequestModal && ( setShowCloneRequestModal(false)} /> )} diff --git a/packages/bruno-app/src/components/RequestTabs/index.js b/packages/bruno-app/src/components/RequestTabs/index.js index d0cd0b459..1e1503a85 100644 --- a/packages/bruno-app/src/components/RequestTabs/index.js +++ b/packages/bruno-app/src/components/RequestTabs/index.js @@ -79,7 +79,7 @@ const RequestTabs = () => { return ( {newRequestModalOpen && ( - setNewRequestModalOpen(false)} /> + setNewRequestModalOpen(false)} /> )} {collectionRequestTabs && collectionRequestTabs.length ? ( <> diff --git a/packages/bruno-app/src/components/ShareCollection/index.js b/packages/bruno-app/src/components/ShareCollection/index.js index 19f5f00be..d0db00905 100644 --- a/packages/bruno-app/src/components/ShareCollection/index.js +++ b/packages/bruno-app/src/components/ShareCollection/index.js @@ -7,8 +7,11 @@ import exportBrunoCollection from 'utils/collections/export'; import exportPostmanCollection from 'utils/exporters/postman-collection'; import { cloneDeep } from 'lodash'; import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index'; +import { useSelector } from 'react-redux'; +import { findCollectionByUid } from 'utils/collections/index'; -const ShareCollection = ({ onClose, collection }) => { +const ShareCollection = ({ onClose, collectionUid }) => { + const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid)); const handleExportBrunoCollection = () => { const collectionCopy = cloneDeep(collection); exportBrunoCollection(transformCollectionToSaveToExportAsFile(collectionCopy)); diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js index ab9fc1a7f..a0a1e6c09 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js @@ -1,5 +1,5 @@ import React, { useRef, useEffect } from 'react'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useFormik } from 'formik'; import * as Yup from 'yup'; import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions'; @@ -11,11 +11,13 @@ import Help from 'components/Help'; import PathDisplay from 'components/PathDisplay'; import { useState } from 'react'; import { IconArrowBackUp, IconEdit } from "@tabler/icons"; +import { findCollectionByUid } from 'utils/collections/index'; -const CloneCollection = ({ onClose, collection }) => { +const CloneCollection = ({ onClose, collectionUid }) => { const inputRef = useRef(); const dispatch = useDispatch(); const [isEditing, toggleEditing] = useState(false); + const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid)); const { name } = collection; const formik = useFormik({ @@ -46,7 +48,7 @@ const CloneCollection = ({ onClose, collection }) => { values.collectionName, values.collectionFolderName, values.collectionLocation, - collection.pathname + collection?.pathname ) ) .then(() => { diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index.js index 9194e8a64..31a58c2dd 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index.js @@ -15,7 +15,7 @@ import Portal from 'components/Portal'; import Dropdown from 'components/Dropdown'; import StyledWrapper from './StyledWrapper'; -const CloneCollectionItem = ({ collection, item, onClose }) => { +const CloneCollectionItem = ({ collectionUid, collectionPathname, item, onClose }) => { const dispatch = useDispatch(); const isFolder = isItemAFolder(item); const inputRef = useRef(); @@ -49,7 +49,7 @@ const CloneCollectionItem = ({ collection, item, onClose }) => { .test('not-reserved', `The file names "collection" and "folder" are reserved in bruno`, value => !['collection', 'folder'].includes(value)) }), onSubmit: (values) => { - dispatch(cloneItem(values.name, values.filename, item.uid, collection.uid)) + dispatch(cloneItem(values.name, values.filename, item.uid, collectionUid)) .then(() => { toast.success('Request cloned!'); onClose(); @@ -172,8 +172,7 @@ const CloneCollectionItem = ({ collection, item, onClose }) => { ) : (
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/DeleteCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/DeleteCollectionItem/index.js index 2646bf676..3f397c78c 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/DeleteCollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/DeleteCollectionItem/index.js @@ -7,11 +7,11 @@ import { deleteItem } from 'providers/ReduxStore/slices/collections/actions'; import { recursivelyGetAllItemUids } from 'utils/collections'; import StyledWrapper from './StyledWrapper'; -const DeleteCollectionItem = ({ onClose, item, collection }) => { +const DeleteCollectionItem = ({ onClose, item, collectionUid }) => { const dispatch = useDispatch(); const isFolder = isItemAFolder(item); const onConfirm = () => { - dispatch(deleteItem(item.uid, collection.uid)).then(() => { + dispatch(deleteItem(item.uid, collectionUid)).then(() => { if (isFolder) { // close all tabs that belong to the folder diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js index 792736b12..42f0bc8ca 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js @@ -10,9 +10,11 @@ import { getLanguages } from 'utils/codegenerator/targets'; import { useSelector } from 'react-redux'; import { getGlobalEnvironmentVariables } from 'utils/collections/index'; -const GenerateCodeItem = ({ collection, item, onClose }) => { +const GenerateCodeItem = ({ collectionUid, item, onClose }) => { const languages = getLanguages(); + const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid)); + const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments); const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid }); diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RenameCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RenameCollectionItem/index.js index 705c45c79..583a914b0 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RenameCollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RenameCollectionItem/index.js @@ -16,7 +16,7 @@ import Portal from 'components/Portal'; import Dropdown from 'components/Dropdown'; import StyledWrapper from './StyledWrapper'; -const RenameCollectionItem = ({ collection, item, onClose }) => { +const RenameCollectionItem = ({ collectionUid, collectionPathname, item, onClose }) => { const dispatch = useDispatch(); const isFolder = isItemAFolder(item); const inputRef = useRef(); @@ -57,13 +57,13 @@ const RenameCollectionItem = ({ collection, item, onClose }) => { return; } if (!isFolder && item.draft) { - await dispatch(saveRequest(item.uid, collection.uid, true)); + await dispatch(saveRequest(item.uid, collectionUid, true)); } const { name: newName, filename: newFilename } = values; try { let renameConfig = { itemUid: item.uid, - collectionUid: collection.uid, + collectionUid, }; renameConfig['newName'] = newName; if (itemFilename !== newFilename) { @@ -191,8 +191,7 @@ const RenameCollectionItem = ({ collection, item, onClose }) => { ) : (
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js index cfd236f8c..8aaaa749c 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js @@ -2,16 +2,18 @@ import React from 'react'; import get from 'lodash/get'; import { uuid } from 'utils/common'; import Modal from 'components/Modal'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { addTab } from 'providers/ReduxStore/slices/tabs'; import { runCollectionFolder } from 'providers/ReduxStore/slices/collections/actions'; import { flattenItems } from 'utils/collections'; import StyledWrapper from './StyledWrapper'; import { areItemsLoading } from 'utils/collections'; -const RunCollectionItem = ({ collection, item, onClose }) => { +const RunCollectionItem = ({ collectionUid, item, onClose }) => { const dispatch = useDispatch(); + const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid)); + const onSubmit = (recursive) => { dispatch( addTab({ @@ -34,8 +36,6 @@ const RunCollectionItem = ({ collection, item, onClose }) => { const recursiveRunLength = getRequestsCount(flattenedItems); const isFolderLoading = areItemsLoading(item); - console.log(item); - console.log(isFolderLoading); return ( diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js index 8d61203e1..d47c820c3 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js @@ -22,6 +22,65 @@ const Wrapper = styled.div` height: 1.875rem; cursor: pointer; user-select: none; + position: relative; + + /* Common styles for drop indicators */ + &::before, + &::after { + content: ''; + position: absolute; + left: 0; + right: 0; + height: 2px; + background: ${(props) => props.theme.dragAndDrop.border}; + opacity: 0; + pointer-events: none; + } + + &::before { + top: 0; + } + + &::after { + bottom: 0; + } + + /* Drop target styles */ + &.drop-target { + background-color: ${(props) => props.theme.dragAndDrop.hoverBg}; + + &::before, + &::after { + opacity: 0; + } + } + + &.drop-target-above { + &::before { + opacity: 1; + height: 2px; + } + } + + &.drop-target-below { + &::after { + opacity: 1; + height: 2px; + } + } + + /* Inside drop target style */ + &.drop-target { + &::before { + top: 0; + bottom: 0; + height: 100%; + opacity: 1; + background: ${(props) => props.theme.dragAndDrop.hoverBg}; + border: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border}; + // border-radius: 4px; + } + } .rotate-90 { transform: rotateZ(90deg); @@ -45,6 +104,20 @@ const Wrapper = styled.div` } } + &.item-target { + background: #ccc3; + } + + &.item-seperator { + .seperator { + bottom: 0px; + position: absolute; + height: 3px; + width: 100%; + background: #ccc3; + } + } + &.item-focused-in-tab { background: ${(props) => props.theme.sidebar.collection.item.bg}; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js index 9b520f4a5..92e025405 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js @@ -1,4 +1,4 @@ -import React, { useState, useRef, forwardRef, useEffect } from 'react'; +import React, { useState, useRef, forwardRef } from 'react'; import range from 'lodash/range'; import filter from 'lodash/filter'; import classnames from 'classnames'; @@ -6,7 +6,7 @@ import { useDrag, useDrop } from 'react-dnd'; import { IconChevronRight, IconDots } from '@tabler/icons'; import { useSelector, useDispatch } from 'react-redux'; import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs'; -import { moveItem, showInFolder, sendRequest } from 'providers/ReduxStore/slices/collections/actions'; +import { handleCollectionItemDrop, moveItem, sendRequest, showInFolder, updateItemsSequences } from 'providers/ReduxStore/slices/collections/actions'; import { collectionFolderClicked } from 'providers/ReduxStore/slices/collections'; import Dropdown from 'components/Dropdown'; import NewRequest from 'components/Sidebar/NewRequest'; @@ -26,13 +26,21 @@ import NetworkError from 'components/ResponsePane/NetworkError/index'; import CollectionItemInfo from './CollectionItemInfo/index'; import CollectionItemIcon from './CollectionItemIcon'; import { scrollToTheActiveTab } from 'utils/tabs'; +import { isTabForItemActive as isTabForItemActiveSelector, isTabForItemPresent as isTabForItemPresentSelector } from 'src/selectors/tab'; +import { isEqual } from 'lodash'; -const CollectionItem = ({ item, collection, searchText }) => { - const tabs = useSelector((state) => state.tabs.tabs); - const activeTabUid = useSelector((state) => state.tabs.activeTabUid); +const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) => { + const _isTabForItemActiveSelector = isTabForItemActiveSelector({ itemUid: item.uid }); + const isTabForItemActive = useSelector(_isTabForItemActiveSelector, isEqual); + + const _isTabForItemPresentSelector = isTabForItemPresentSelector({ itemUid: item.uid }); + const isTabForItemPresent = useSelector(_isTabForItemPresentSelector, isEqual); + const isSidebarDragging = useSelector((state) => state.app.isDragging); const dispatch = useDispatch(); - const collectionItemRef = useRef(null); + + // We use a single ref for drag and drop. + const ref = useRef(null); const [renameItemModalOpen, setRenameItemModalOpen] = useState(false); const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false); @@ -44,9 +52,12 @@ const CollectionItem = ({ item, collection, searchText }) => { const [itemInfoModalOpen, setItemInfoModalOpen] = useState(false); const hasSearchText = searchText && searchText?.trim()?.length; const itemIsCollapsed = hasSearchText ? false : item.collapsed; + const isFolder = isItemAFolder(item); + + const [dropType, setDropType] = useState(null); // 'adjacent' or 'inside' const [{ isDragging }, drag] = useDrag({ - type: `collection-item-${collection.uid}`, + type: `collection-item-${collectionUid}`, item: item, collect: (monitor) => ({ isDragging: monitor.isDragging() @@ -56,21 +67,51 @@ const CollectionItem = ({ item, collection, searchText }) => { } }); - const [{ isOver }, drop] = useDrop({ - accept: `collection-item-${collection.uid}`, - drop: (draggedItem) => { - dispatch(moveItem(collection.uid, draggedItem.uid, item.uid)); + const determineDropType = (monitor) => { + const hoverBoundingRect = ref.current?.getBoundingClientRect(); + const clientOffset = monitor.getClientOffset(); + if (!hoverBoundingRect || !clientOffset) return null; + + const clientY = clientOffset.y - hoverBoundingRect.top; + const folderUpperThreshold = hoverBoundingRect.height * 0.35; + const fileUpperThreshold = hoverBoundingRect.height * 0.5; + + if (isItemAFolder(item)) { + return clientY < folderUpperThreshold ? 'adjacent' : 'inside'; + } else { + return clientY < fileUpperThreshold ? 'adjacent' : null; + } + }; + + const [{ isOver, canDrop }, drop] = useDrop({ + accept: `collection-item-${collectionUid}`, + hover: (draggedItem, monitor) => { + const { uid: targetItemUid } = item; + const { uid: draggedItemUid } = draggedItem; + + if (draggedItemUid === targetItemUid) return; + + const dropType = determineDropType(monitor); + setDropType(dropType); }, - canDrop: (draggedItem) => { - return draggedItem.uid !== item.uid; + drop: async (draggedItem, monitor) => { + const { uid: targetItemUid } = item; + const { uid: draggedItemUid } = draggedItem; + + if (draggedItemUid === targetItemUid) return; + + const dropType = determineDropType(monitor); + if (!dropType) return; + + await dispatch(handleCollectionItemDrop({ targetItem: item, draggedItem, dropType, collectionUid })) + setDropType(null); }, + canDrop: (draggedItem) => draggedItem.uid !== item.uid, collect: (monitor) => ({ - isOver: monitor.isOver(), + isOver: monitor.isOver() }), }); - drag(drop(collectionItemRef)); - const dropdownTippyRef = useRef(); const MenuIcon = forwardRef((props, ref) => { return ( @@ -84,13 +125,15 @@ const CollectionItem = ({ item, collection, searchText }) => { 'rotate-90': !itemIsCollapsed }); - const itemRowClassName = classnames('flex collection-item-name items-center', { - 'item-focused-in-tab': item.uid == activeTabUid, - 'item-hovered': isOver + const itemRowClassName = classnames('flex collection-item-name relative items-center', { + 'item-focused-in-tab': isTabForItemActive, + 'item-hovered': isOver && canDrop, + 'drop-target': isOver && dropType === 'inside', + 'drop-target-above': isOver && dropType === 'adjacent' }); const handleRun = async () => { - dispatch(sendRequest(item, collection.uid)).catch((err) => + dispatch(sendRequest(item, collectionUid)).catch((err) => toast.custom((t) => toast.dismiss(t.id)} />, { duration: 5000 }) @@ -101,12 +144,10 @@ const CollectionItem = ({ item, collection, searchText }) => { if (event && event.detail != 1) return; //scroll to the active tab setTimeout(scrollToTheActiveTab, 50); - const isRequest = isItemARequest(item); - if (isRequest) { dispatch(hideHomePage()); - if (itemIsOpenedInTabs(item, tabs)) { + if (isTabForItemPresent) { dispatch( focusTab({ uid: item.uid @@ -114,11 +155,10 @@ const CollectionItem = ({ item, collection, searchText }) => { ); return; } - dispatch( addTab({ uid: item.uid, - collectionUid: collection.uid, + collectionUid: collectionUid, requestPaneTab: getDefaultRequestPaneTab(item), type: 'request', }) @@ -127,14 +167,14 @@ const CollectionItem = ({ item, collection, searchText }) => { dispatch( addTab({ uid: item.uid, - collectionUid: collection.uid, + collectionUid: collectionUid, type: 'folder-settings', }) ); dispatch( collectionFolderClicked({ itemUid: item.uid, - collectionUid: collection.uid + collectionUid: collectionUid }) ); } @@ -146,10 +186,10 @@ const CollectionItem = ({ item, collection, searchText }) => { dispatch( collectionFolderClicked({ itemUid: item.uid, - collectionUid: collection.uid + collectionUid: collectionUid }) ); - } + }; const handleRightClick = (event) => { const _menuDropdown = dropdownTippyRef.current; @@ -164,7 +204,6 @@ const CollectionItem = ({ item, collection, searchText }) => { let indents = range(item.depth); const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref); - const isFolder = isItemAFolder(item); const className = classnames('flex flex-col w-full', { 'is-sidebar-dragging': isSidebarDragging @@ -183,49 +222,14 @@ const CollectionItem = ({ item, collection, searchText }) => { } const handleDoubleClick = (event) => { - dispatch(makeTabPermanent({ uid: item.uid })) + dispatch(makeTabPermanent({ uid: item.uid })); }; - // we need to sort request items by seq property - const sortRequestItems = (items = []) => { + // Sort items by their "seq" property. + const sortItemsBySequence = (items = []) => { return items.sort((a, b) => a.seq - b.seq); }; - // we need to sort folder items by name alphabetically - const sortFolderItems = (items = []) => { - return items.sort((a, b) => a.name.localeCompare(b.name)); - }; - const handleGenerateCode = (e) => { - e.stopPropagation(); - dropdownTippyRef.current.hide(); - if (item?.request?.url !== '' || (item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '')) { - setGenerateCodeItemModalOpen(true); - } else { - toast.error('URL is required'); - } - }; - - const viewFolderSettings = () => { - if (isItemAFolder(item)) { - if (itemIsOpenedInTabs(item, tabs)) { - dispatch( - focusTab({ - uid: item.uid - }) - ); - return; - } - dispatch( - addTab({ - uid: item.uid, - collectionUid: collection.uid, - type: 'folder-settings' - }) - ); - return; - } - }; - const handleShowInFolder = () => { dispatch(showInFolder(item.pathname)).catch((error) => { console.error('Error opening the folder', error); @@ -233,62 +237,89 @@ const CollectionItem = ({ item, collection, searchText }) => { }); }; - const requestItems = sortRequestItems(filter(item.items, (i) => isItemARequest(i))); - const folderItems = sortFolderItems(filter(item.items, (i) => isItemAFolder(i))); + const folderItems = sortItemsBySequence(filter(item.items, (i) => isItemAFolder(i))); + const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i))); + + const handleGenerateCode = (e) => { + e.stopPropagation(); + dropdownTippyRef.current.hide(); + if ( + (item?.request?.url !== '') || + (item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '') + ) { + setGenerateCodeItemModalOpen(true); + } else { + toast.error('URL is required'); + } + }; + + const viewFolderSettings = () => { + if (isItemAFolder(item)) { + if (itemIsOpenedInTabs(item, tabs)) { + dispatch(focusTab({ uid: item.uid })); + return; + } + dispatch( + addTab({ + uid: item.uid, + collectionUid, + type: 'folder-settings' + }) + ); + } + }; return ( {renameItemModalOpen && ( - setRenameItemModalOpen(false)} /> + setRenameItemModalOpen(false)} /> )} {cloneItemModalOpen && ( - setCloneItemModalOpen(false)} /> + setCloneItemModalOpen(false)} /> )} {deleteItemModalOpen && ( - setDeleteItemModalOpen(false)} /> + setDeleteItemModalOpen(false)} /> )} {newRequestModalOpen && ( - setNewRequestModalOpen(false)} /> + setNewRequestModalOpen(false)} /> )} {newFolderModalOpen && ( - setNewFolderModalOpen(false)} /> + setNewFolderModalOpen(false)} /> )} {runCollectionModalOpen && ( - setRunCollectionModalOpen(false)} /> + setRunCollectionModalOpen(false)} /> )} {generateCodeItemModalOpen && ( - setGenerateCodeItemModalOpen(false)} /> + setGenerateCodeItemModalOpen(false)} /> )} {itemInfoModalOpen && ( - setItemInfoModalOpen(false)} /> + setItemInfoModalOpen(false)} /> )} -
+
{ + ref.current = node; + drag(drop(node)); + }} + >
{indents && indents.length - ? indents.map((i) => { - return ( -
-  {/* Indent */} -
- ); - }) + ? indents.map((i) => ( +
+  {/* Indent */} +
+ )) : null}
{ /> ) : null}
- -
+
{item.name} @@ -429,17 +457,16 @@ const CollectionItem = ({ item, collection, searchText }) => {
- {!itemIsCollapsed ? (
{folderItems && folderItems.length ? folderItems.map((i) => { - return ; + return ; }) : null} {requestItems && requestItems.length ? requestItems.map((i) => { - return ; + return ; }) : null}
@@ -448,4 +475,4 @@ const CollectionItem = ({ item, collection, searchText }) => { ); }; -export default CollectionItem; \ No newline at end of file +export default React.memo(CollectionItem); \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/index.js index 9cba09179..17b6dc007 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/index.js @@ -1,12 +1,14 @@ import React from 'react'; import toast from 'react-hot-toast'; import Modal from 'components/Modal'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { IconFiles } from '@tabler/icons'; import { removeCollection } from 'providers/ReduxStore/slices/collections/actions'; +import { findCollectionByUid } from 'utils/collections/index'; -const RemoveCollection = ({ onClose, collection }) => { +const RemoveCollection = ({ onClose, collectionUid }) => { const dispatch = useDispatch(); + const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid)); const onConfirm = () => { dispatch(removeCollection(collection.uid)) diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RenameCollection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/RenameCollection/index.js index a6e11051e..0d3a4c34a 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RenameCollection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/RenameCollection/index.js @@ -2,13 +2,15 @@ import React, { useRef, useEffect } from 'react'; import { useFormik } from 'formik'; import * as Yup from 'yup'; import Modal from 'components/Modal'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import toast from 'react-hot-toast'; import { renameCollection } from 'providers/ReduxStore/slices/collections/actions'; +import { findCollectionByUid } from 'utils/collections/index'; -const RenameCollection = ({ collection, onClose }) => { +const RenameCollection = ({ collectionUid, onClose }) => { const dispatch = useDispatch(); const inputRef = useRef(); + const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid)); const formik = useFormik({ enableReinitialize: true, initialValues: { diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js index 5c06cc42a..0378d9ad9 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js @@ -62,6 +62,36 @@ const Wrapper = styled.div` color: white; } } + + &.drop-target { + background-color: ${(props) => props.theme.dragAndDrop.hoverBg}; + transition: ${(props) => props.theme.dragAndDrop.transition}; + } + + &.drop-target-above { + border: none; + border-top: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border}; + margin-top: -2px; + background: transparent; + transition: ${(props) => props.theme.dragAndDrop.transition}; + } + + &.drop-target-below { + border: none; + border-bottom: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border}; + margin-bottom: -2px; + background: transparent; + transition: ${(props) => props.theme.dragAndDrop.transition}; + } + } + + .collection-name.drop-target { + border: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border}; + border-radius: 4px; + background-color: ${(props) => props.theme.dragAndDrop.hoverBg}; + margin: -2px; + transition: ${(props) => props.theme.dragAndDrop.transition}; + box-shadow: 0 0 0 2px ${(props) => props.theme.dragAndDrop.hoverBg}; } #sidebar-collection-name { diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js index e81b43a1f..aebea5092 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js @@ -6,7 +6,7 @@ import { useDrop, useDrag } from 'react-dnd'; import { IconChevronRight, IconDots, IconLoader2 } from '@tabler/icons'; import Dropdown from 'components/Dropdown'; import { collapseCollection } from 'providers/ReduxStore/slices/collections'; -import { mountCollection, moveItemToRootOfCollection, moveCollectionAndPersist } from 'providers/ReduxStore/slices/collections/actions'; +import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop } from 'providers/ReduxStore/slices/collections/actions'; import { useDispatch, useSelector } from 'react-redux'; import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs'; import NewRequest from 'components/Sidebar/NewRequest'; @@ -33,7 +33,7 @@ const Collection = ({ collection, searchText }) => { const dispatch = useDispatch(); const isLoading = areItemsLoading(collection); const collectionRef = useRef(null); - + const menuDropdownTippyRef = useRef(); const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref); const MenuIcon = forwardRef((props, ref) => { @@ -144,7 +144,7 @@ const Collection = ({ collection, searchText }) => { drop: (draggedItem, monitor) => { const itemType = monitor.getItemType(); if (isCollectionItem(itemType)) { - dispatch(moveItemToRootOfCollection(collection.uid, draggedItem.uid)) + dispatch(handleCollectionItemDrop({ targetItem: collection, draggedItem, dropType: 'inside', collectionUid: collection.uid })) } else { dispatch(moveCollectionAndPersist({draggedItem, targetItem: collection})); } @@ -170,33 +170,28 @@ const Collection = ({ collection, searchText }) => { }); // we need to sort request items by seq property - const sortRequestItems = (items = []) => { + const sortItemsBySequence = (items = []) => { return items.sort((a, b) => a.seq - b.seq); }; - // we need to sort folder items by name alphabetically - const sortFolderItems = (items = []) => { - return items.sort((a, b) => a.name.localeCompare(b.name)); - }; - - const requestItems = sortRequestItems(filter(collection.items, (i) => isItemARequest(i))); - const folderItems = sortFolderItems(filter(collection.items, (i) => isItemAFolder(i))); + const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i))); + const folderItems = sortItemsBySequence(filter(collection.items, (i) => isItemAFolder(i))); return ( - {showNewRequestModal && setShowNewRequestModal(false)} />} - {showNewFolderModal && setShowNewFolderModal(false)} />} + {showNewRequestModal && setShowNewRequestModal(false)} />} + {showNewFolderModal && setShowNewFolderModal(false)} />} {showRenameCollectionModal && ( - setShowRenameCollectionModal(false)} /> + setShowRenameCollectionModal(false)} /> )} {showRemoveCollectionModal && ( - setShowRemoveCollectionModal(false)} /> + setShowRemoveCollectionModal(false)} /> )} {showShareCollectionModal && ( - setShowShareCollectionModal(false)} /> + setShowShareCollectionModal(false)} /> )} {showCloneCollectionModalOpen && ( - setShowCloneCollectionModalOpen(false)} /> + setShowCloneCollectionModalOpen(false)} /> )}
{
{!collectionIsCollapsed ? (
- {folderItems && folderItems.length - ? folderItems.map((i) => { - return ; - }) - : null} - {requestItems && requestItems.length - ? requestItems.map((i) => { - return ; - }) - : null} + {folderItems?.map?.((i) => { + return ; + })} + {requestItems?.map?.((i) => { + return ; + })}
) : null}
diff --git a/packages/bruno-app/src/components/Sidebar/NewFolder/index.js b/packages/bruno-app/src/components/Sidebar/NewFolder/index.js index c0b39b727..83c243653 100644 --- a/packages/bruno-app/src/components/Sidebar/NewFolder/index.js +++ b/packages/bruno-app/src/components/Sidebar/NewFolder/index.js @@ -14,7 +14,7 @@ import Dropdown from "components/Dropdown"; import { IconCaretDown } from "@tabler/icons"; import StyledWrapper from './StyledWrapper'; -const NewFolder = ({ collection, item, onClose }) => { +const NewFolder = ({ collectionUid, item, onClose }) => { const dispatch = useDispatch(); const inputRef = useRef(); const [isEditing, toggleEditing] = useState(false); @@ -52,7 +52,7 @@ const NewFolder = ({ collection, item, onClose }) => { }) }), onSubmit: (values) => { - dispatch(newFolder(values.folderName, values.directoryName, collection.uid, item ? item.uid : null)) + dispatch(newFolder(values.folderName, values.directoryName, collectionUid, item ? item.uid : null)) .then(() => { toast.success('New folder created!'); onClose(); diff --git a/packages/bruno-app/src/components/Sidebar/NewRequest/index.js b/packages/bruno-app/src/components/Sidebar/NewRequest/index.js index 61fdcd22a..ec8f5dfda 100644 --- a/packages/bruno-app/src/components/Sidebar/NewRequest/index.js +++ b/packages/bruno-app/src/components/Sidebar/NewRequest/index.js @@ -5,7 +5,7 @@ import toast from 'react-hot-toast'; import path from 'utils/common/path'; import { uuid } from 'utils/common'; import Modal from 'components/Modal'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { newEphemeralHttpRequest } from 'providers/ReduxStore/slices/collections'; import { newHttpRequest } from 'providers/ReduxStore/slices/collections/actions'; import { addTab } from 'providers/ReduxStore/slices/tabs'; @@ -20,9 +20,11 @@ import Portal from 'components/Portal'; import Help from 'components/Help'; import StyledWrapper from './StyledWrapper'; -const NewRequest = ({ collection, item, isEphemeral, onClose }) => { +const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => { const dispatch = useDispatch(); const inputRef = useRef(); + + const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid)); const { brunoConfig: { presets: collectionPresets = {} } } = collection; @@ -135,14 +137,14 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => { requestType: values.requestType, requestUrl: values.requestUrl, requestMethod: values.requestMethod, - collectionUid: collection.uid + collectionUid: collectionUid }) ) .then(() => { dispatch( addTab({ uid: uid, - collectionUid: collection.uid, + collectionUid: collectionUid, requestPaneTab: getDefaultRequestPaneTab({ type: values.requestType }) }) ); @@ -158,7 +160,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => { requestType: curlRequestTypeDetected, requestUrl: request.url, requestMethod: request.method, - collectionUid: collection.uid, + collectionUid: collectionUid, itemUid: item ? item.uid : null, headers: request.headers, body: request.body, @@ -178,7 +180,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => { requestType: values.requestType, requestUrl: values.requestUrl, requestMethod: values.requestMethod, - collectionUid: collection.uid, + collectionUid: collectionUid, itemUid: item ? item.uid : null }) ) @@ -389,8 +391,6 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => { ) : (
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index f6ac62bc9..86ad618aa 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -13,12 +13,9 @@ import { findEnvironmentInCollection, findItemInCollection, findParentItemInCollection, - getItemsToResequence, isItemAFolder, refreshUidsInItem, isItemARequest, - moveCollectionItem, - moveCollectionItemToRootOfCollection, transformRequestToSaveToFilesystem } from 'utils/collections'; import { uuid, waitForNextTick } from 'utils/common'; @@ -47,8 +44,7 @@ import { closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs'; import { resolveRequestFilename } from 'utils/common/platform'; import { parsePathParams, parseQueryParams, splitOnFirst } from 'utils/url/index'; import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index'; -import { getGlobalEnvironmentVariables } from 'utils/collections/index'; -import { findCollectionByPathname, findEnvironmentInCollectionByName } from 'utils/collections/index'; +import { getGlobalEnvironmentVariables, findCollectionByPathname, findEnvironmentInCollectionByName, getReorderedItemsInTargetDirectory, resetSequencesInFolder, getReorderedItemsInSourceDirectory } from 'utils/collections/index'; import { sanitizeName } from 'utils/common/regex'; import { safeParseJSON, safeStringifyJSON } from 'utils/common/index'; @@ -358,6 +354,8 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive, delay) export const newFolder = (folderName, directoryName, collectionUid, itemUid) => (dispatch, getState) => { const state = getState(); const collection = findCollectionByUid(state.collections.collections, collectionUid); + const parentItem = itemUid ? findItemInCollection(collection, itemUid) : collection; + const items = filter(parentItem.items, (i) => isItemAFolder(i) || isItemARequest(i)); return new Promise((resolve, reject) => { if (!collection) { @@ -372,10 +370,27 @@ export const newFolder = (folderName, directoryName, collectionUid, itemUid) => if (!folderWithSameNameExists) { const fullName = path.join(collection.pathname, directoryName); const { ipcRenderer } = window; - ipcRenderer - .invoke('renderer:new-folder', fullName, folderName) - .then(() => resolve()) + .invoke('renderer:new-folder', fullName) + .then(async () => { + const folderData = { + name: folderName, + pathname: fullName, + root: { + meta: { + name: folderName, + seq: items?.length + 1 + } + } + }; + ipcRenderer + .invoke('renderer:save-folder-root', folderData) + .then(resolve) + .catch((err) => { + toast.error('Failed to save folder settings!'); + reject(err); + }); + }) .catch((error) => reject(error)); } else { return reject(new Error('Duplicate folder names under same parent folder are not allowed')); @@ -392,8 +407,26 @@ export const newFolder = (folderName, directoryName, collectionUid, itemUid) => const { ipcRenderer } = window; ipcRenderer - .invoke('renderer:new-folder', fullName, folderName) - .then(() => resolve()) + .invoke('renderer:new-folder', fullName) + .then(async () => { + const folderData = { + name: folderName, + pathname: fullName, + root: { + meta: { + name: folderName, + seq: items?.length + 1 + } + } + }; + ipcRenderer + .invoke('renderer:save-folder-root', folderData) + .then(resolve) + .catch((err) => { + toast.error('Failed to save folder settings!'); + reject(err); + }); + }) .catch((error) => reject(error)); } else { return reject(new Error('Duplicate folder names under same parent folder are not allowed')); @@ -495,7 +528,8 @@ export const cloneItem = (newName, newFilename, itemUid, collectionUid) => (disp set(item, 'name', newName); set(item, 'filename', newFilename); set(item, 'root.meta.name', newName); - + set(item, 'root.meta.seq', parentFolder?.items?.length + 1); + const collectionPath = path.join(parentFolder.pathname, newFilename); ipcRenderer.invoke('renderer:clone-folder', item, collectionPath).then(resolve).catch(reject); return; @@ -594,176 +628,129 @@ export const deleteItem = (itemUid, collectionUid) => (dispatch, getState) => { export const sortCollections = (payload) => (dispatch) => { dispatch(_sortCollections(payload)); }; -export const moveItem = (collectionUid, draggedItemUid, targetItemUid) => (dispatch, getState) => { + +export const moveItem = ({ targetDirname, sourcePathname }) => (dispatch, getState) => { + return new Promise((resolve, reject) => { + const { ipcRenderer } = window; + + ipcRenderer.invoke('renderer:move-item', { targetDirname, sourcePathname }) + .then(resolve) + .catch(reject); + }); +} + +export const handleCollectionItemDrop = ({ targetItem, draggedItem, dropType, collectionUid }) => (dispatch, getState) => { const state = getState(); const collection = findCollectionByUid(state.collections.collections, collectionUid); + const { uid: draggedItemUid, pathname: draggedItemPathname } = draggedItem; + const { uid: targetItemUid, pathname: targetItemPathname } = targetItem; + const targetItemDirectory = findParentItemInCollection(collection, targetItemUid) || collection; + const targetItemDirectoryItems = cloneDeep(targetItemDirectory.items); + const draggedItemDirectory = findParentItemInCollection(collection, draggedItemUid) || collection; + const draggedItemDirectoryItems = cloneDeep(draggedItemDirectory.items); + const calculateDraggedItemNewPathname = ({ draggedItem, targetItem, dropType }) => { + const { pathname: targetItemPathname } = targetItem; + const { filename: draggedItemFilename } = draggedItem; + const targetItemDirname = path.dirname(targetItemPathname); + const isTargetTheCollection = targetItemPathname === collection.pathname; + const isTargetItemAFolder = isItemAFolder(targetItem); + + if (dropType === 'inside' && (isTargetItemAFolder || isTargetTheCollection)) { + return path.join(targetItemPathname, draggedItemFilename) + } else if (dropType === 'adjacent') { + return path.join(targetItemDirname, draggedItemFilename) + } + return null; + }; + + const handleMoveToNewLocation = async ({ draggedItem, draggedItemDirectoryItems, targetItem, targetItemDirectoryItems, newPathname, dropType }) => { + const { uid: targetItemUid } = targetItem; + const { pathname: draggedItemPathname, uid: draggedItemUid } = draggedItem; + + const newDirname = path.dirname(newPathname); + await dispatch(moveItem({ + targetDirname: newDirname, + sourcePathname: draggedItemPathname + })); + + // Update sequences in the source directory + if (draggedItemDirectoryItems?.length) { + // reorder items in the source directory + const draggedItemDirectoryItemsWithoutDraggedItem = draggedItemDirectoryItems.filter(i => i.uid !== draggedItemUid); + const reorderedSourceItems = getReorderedItemsInSourceDirectory({ items: draggedItemDirectoryItemsWithoutDraggedItem }); + if (reorderedSourceItems?.length) { + await dispatch(updateItemsSequences({ itemsToResequence: reorderedSourceItems })); + } + } + + // Update sequences in the target directory (if dropping adjacent) + if (dropType === 'adjacent') { + const targetItemSequence = targetItemDirectoryItems.findIndex(i => i.uid === targetItemUid)?.seq; + + const draggedItemWithNewPathAndSequence = { + ...draggedItem, + pathname: newPathname, + seq: targetItemSequence + }; + + // draggedItem is added to the targetItem's directory + const reorderedTargetItems = getReorderedItemsInTargetDirectory({ + items: [ ...targetItemDirectoryItems, draggedItemWithNewPathAndSequence ], + targetItemUid, + draggedItemUid + }); + + if (reorderedTargetItems?.length) { + await dispatch(updateItemsSequences({ itemsToResequence: reorderedTargetItems })); + } + } + }; + + const handleReorderInSameLocation = async ({ draggedItem, targetItem, targetItemDirectoryItems }) => { + const { uid: targetItemUid } = targetItem; + const { uid: draggedItemUid } = draggedItem; + + // reorder items in the targetItem's directory + const reorderedItems = getReorderedItemsInTargetDirectory({ + items: targetItemDirectoryItems, + targetItemUid, + draggedItemUid + }); + + if (reorderedItems?.length) { + await dispatch(updateItemsSequences({ itemsToResequence: reorderedItems })); + } + }; + + return new Promise(async (resolve, reject) => { + try { + const newPathname = calculateDraggedItemNewPathname({ draggedItem, targetItem, dropType }); + if (!newPathname) return; + if (targetItemPathname?.startsWith(draggedItemPathname)) return; + if (newPathname !== draggedItemPathname) { + await handleMoveToNewLocation({ targetItem, targetItemDirectoryItems, draggedItem, draggedItemDirectoryItems, newPathname, dropType }); + } else { + await handleReorderInSameLocation({ draggedItem, targetItemDirectoryItems, targetItem }); + } + resolve(); + } catch (error) { + console.error(error); + toast.error(error?.message); + reject(error); + } + }) +} + +export const updateItemsSequences = ({ itemsToResequence }) => (dispatch, getState) => { return new Promise((resolve, reject) => { - if (!collection) { - return reject(new Error('Collection not found')); - } + const { ipcRenderer } = window; - const collectionCopy = cloneDeep(collection); - const draggedItem = findItemInCollection(collectionCopy, draggedItemUid); - const targetItem = findItemInCollection(collectionCopy, targetItemUid); - - if (!draggedItem) { - return reject(new Error('Dragged item not found')); - } - - if (!targetItem) { - return reject(new Error('Target item not found')); - } - - const draggedItemParent = findParentItemInCollection(collectionCopy, draggedItemUid); - const targetItemParent = findParentItemInCollection(collectionCopy, targetItemUid); - const sameParent = draggedItemParent === targetItemParent; - - // file item dragged onto another file item and both are in the same folder - // this is also true when both items are at the root level - if (isItemARequest(draggedItem) && isItemARequest(targetItem) && sameParent) { - moveCollectionItem(collectionCopy, draggedItem, targetItem); - const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy); - - return ipcRenderer - .invoke('renderer:resequence-items', itemsToResequence) - .then(resolve) - .catch((error) => reject(error)); - } - - // file item dragged onto another file item which is at the root level - if (isItemARequest(draggedItem) && isItemARequest(targetItem) && !targetItemParent) { - const draggedItemPathname = draggedItem.pathname; - moveCollectionItem(collectionCopy, draggedItem, targetItem); - const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy); - const itemsToResequence2 = getItemsToResequence(targetItemParent, collectionCopy); - - return ipcRenderer - .invoke('renderer:move-file-item', draggedItemPathname, collectionCopy.pathname) - .then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence)) - .then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2)) - .then(resolve) - .catch((error) => reject(error)); - } - - // file item dragged onto another file item and both are in different folders - if (isItemARequest(draggedItem) && isItemARequest(targetItem) && !sameParent) { - const draggedItemPathname = draggedItem.pathname; - moveCollectionItem(collectionCopy, draggedItem, targetItem); - const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy); - const itemsToResequence2 = getItemsToResequence(targetItemParent, collectionCopy); - - return ipcRenderer - .invoke('renderer:move-file-item', draggedItemPathname, targetItemParent.pathname) - .then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence)) - .then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2)) - .then(resolve) - .catch((error) => reject(error)); - } - - // file item dragged into its own folder - if (isItemARequest(draggedItem) && isItemAFolder(targetItem) && draggedItemParent === targetItem) { - return resolve(); - } - - // file item dragged into another folder - if (isItemARequest(draggedItem) && isItemAFolder(targetItem) && draggedItemParent !== targetItem) { - const draggedItemPathname = draggedItem.pathname; - moveCollectionItem(collectionCopy, draggedItem, targetItem); - const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy); - const itemsToResequence2 = getItemsToResequence(targetItem, collectionCopy); - - return ipcRenderer - .invoke('renderer:move-file-item', draggedItemPathname, targetItem.pathname) - .then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence)) - .then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2)) - .then(resolve) - .catch((error) => reject(error)); - } - - // end of the file drags, now let's handle folder drags - // folder drags are simpler since we don't allow ordering of folders - - // folder dragged into its own folder - if (isItemAFolder(draggedItem) && isItemAFolder(targetItem) && draggedItemParent === targetItem) { - return resolve(); - } - - // folder dragged into a file which is at the same level - // this is also true when both items are at the root level - if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && sameParent) { - return resolve(); - } - - // folder dragged into a file which is a child of the folder - if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && draggedItem === targetItemParent) { - return resolve(); - } - - // folder dragged into a file which is at the root level - if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && !targetItemParent) { - const draggedItemPathname = draggedItem.pathname; - - return ipcRenderer - .invoke('renderer:move-folder-item', draggedItemPathname, collectionCopy.pathname) - .then(resolve) - .catch((error) => reject(error)); - } - - // folder dragged into another folder - if (isItemAFolder(draggedItem) && isItemAFolder(targetItem) && draggedItemParent !== targetItem) { - const draggedItemPathname = draggedItem.pathname; - - return ipcRenderer - .invoke('renderer:move-folder-item', draggedItemPathname, targetItem.pathname) - .then(resolve) - .catch((error) => reject(error)); - } + ipcRenderer.invoke('renderer:resequence-items', itemsToResequence) + .then(resolve) + .catch(reject); }); -}; - -export const moveItemToRootOfCollection = (collectionUid, draggedItemUid) => (dispatch, getState) => { - const state = getState(); - const collection = findCollectionByUid(state.collections.collections, collectionUid); - - return new Promise((resolve, reject) => { - if (!collection) { - return reject(new Error('Collection not found')); - } - - const collectionCopy = cloneDeep(collection); - const draggedItem = findItemInCollection(collectionCopy, draggedItemUid); - if (!draggedItem) { - return reject(new Error('Dragged item not found')); - } - - const draggedItemParent = findParentItemInCollection(collectionCopy, draggedItemUid); - // file item is already at the root level - if (!draggedItemParent) { - return resolve(); - } - - const draggedItemPathname = draggedItem.pathname; - moveCollectionItemToRootOfCollection(collectionCopy, draggedItem); - - if (isItemAFolder(draggedItem)) { - return ipcRenderer - .invoke('renderer:move-folder-item', draggedItemPathname, collectionCopy.pathname) - .then(resolve) - .catch((error) => reject(error)); - } else { - const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy); - const itemsToResequence2 = getItemsToResequence(collectionCopy, collectionCopy); - - return ipcRenderer - .invoke('renderer:move-file-item', draggedItemPathname, collectionCopy.pathname) - .then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence)) - .then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2)) - .then(resolve) - .catch((error) => reject(error)); - } - }); -}; +} export const newHttpRequest = (params) => (dispatch, getState) => { const { requestName, filename, requestType, requestUrl, requestMethod, collectionUid, itemUid, headers, body, auth } = params; @@ -823,8 +810,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => { collection.items, (i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename) ); - const requestItems = filter(collection.items, (i) => i.type !== 'folder'); - item.seq = requestItems.length + 1; + const items = filter(collection.items, (i) => isItemAFolder(i) || isItemARequest(i)); + item.seq = items.length + 1; if (!reqWithSameNameExists) { const fullName = path.join(collection.pathname, resolvedFilename); @@ -852,8 +839,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => { currentItem.items, (i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename) ); - const requestItems = filter(currentItem.items, (i) => i.type !== 'folder'); - item.seq = requestItems.length + 1; + const items = filter(currentItem.items, (i) => isItemAFolder(i) || isItemARequest(i)); + item.seq = items.length + 1; if (!reqWithSameNameExists) { const fullName = path.join(currentItem.pathname, resolvedFilename); const { ipcRenderer } = window; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index 5e8275ba1..d3098a936 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -1719,6 +1719,9 @@ export const collectionsSlice = createSlice({ folderItem.name = file?.data?.meta?.name; } folderItem.root = file.data; + if (file?.data?.meta?.seq) { + folderItem.seq = file.data?.meta?.seq; + } } return; } @@ -1798,6 +1801,7 @@ export const collectionsSlice = createSlice({ uid: uuid(), pathname: currentPath, name: dir?.meta?.name || directoryName, + seq: dir?.meta?.seq || 1, filename: directoryName, collapsed: true, type: 'folder', @@ -1829,6 +1833,9 @@ export const collectionsSlice = createSlice({ if (file?.data?.meta?.name) { folderItem.name = file?.data?.meta?.name; } + if (file?.data?.meta?.seq) { + folderItem.seq = file?.data?.meta?.seq; + } folderItem.root = file.data; } return; diff --git a/packages/bruno-app/src/selectors/tab.js b/packages/bruno-app/src/selectors/tab.js new file mode 100644 index 000000000..76aa67365 --- /dev/null +++ b/packages/bruno-app/src/selectors/tab.js @@ -0,0 +1,9 @@ +import { createSelector } from '@reduxjs/toolkit'; + +export const isTabForItemActive = ({ itemUid }) => createSelector([ + (state) => state.tabs?.activeTabUid +], (activeTabUid) => activeTabUid === itemUid); + +export const isTabForItemPresent = ({ itemUid }) => createSelector([ + (state) => state.tabs.tabs, +], (tabs) => tabs.some((tab) => tab.uid === itemUid)); \ No newline at end of file diff --git a/packages/bruno-app/src/themes/dark.js b/packages/bruno-app/src/themes/dark.js index 861290981..04ee6134e 100644 --- a/packages/bruno-app/src/themes/dark.js +++ b/packages/bruno-app/src/themes/dark.js @@ -281,6 +281,12 @@ const darkTheme = { color: 'rgb(52 51 49)' }, + dragAndDrop: { + border: '#666666', + borderStyle: '2px solid', + hoverBg: 'rgba(102, 102, 102, 0.08)', + transition: 'all 0.1s ease' + }, infoTip: { bg: '#1f1f1f', border: '#333333', diff --git a/packages/bruno-app/src/themes/light.js b/packages/bruno-app/src/themes/light.js index 6ce9fa583..e95b0e45e 100644 --- a/packages/bruno-app/src/themes/light.js +++ b/packages/bruno-app/src/themes/light.js @@ -282,6 +282,12 @@ const lightTheme = { color: 'rgb(152 151 149)' }, + dragAndDrop: { + border: '#8b8b8b', // Using the same gray as focusBorder from input + borderStyle: '2px solid', + hoverBg: 'rgba(139, 139, 139, 0.05)', // Matching the border color with reduced opacity + transition: 'all 0.1s ease' + }, infoTip: { bg: 'white', border: '#e0e0e0', diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index e258c80ba..add047119 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -98,6 +98,14 @@ export const findItemInCollectionByPathname = (collection, pathname) => { return findItemByPathname(flattenedItems, pathname); }; +export const findParentItemInCollectionByPathname = (collection, pathname) => { + let flattenedItems = flattenItems(collection.items); + + return find(flattenedItems, (item) => { + return item.items && find(item.items, (i) => i.pathname === pathname); + }); +}; + export const findItemInCollection = (collection, itemUid) => { let flattenedItems = flattenItems(collection.items); @@ -150,90 +158,6 @@ export const getItemsLoadStats = (folder) => { }; } -export const moveCollectionItem = (collection, draggedItem, targetItem) => { - let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid); - - if (draggedItemParent) { - draggedItemParent.items = sortBy(draggedItemParent.items, (item) => item.seq); - draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid); - draggedItem.pathname = path.join(draggedItemParent.pathname, draggedItem.filename); - } else { - collection.items = sortBy(collection.items, (item) => item.seq); - collection.items = filter(collection.items, (i) => i.uid !== draggedItem.uid); - } - - if (targetItem.type === 'folder') { - targetItem.items = sortBy(targetItem.items || [], (item) => item.seq); - targetItem.items.push(draggedItem); - draggedItem.pathname = path.join(targetItem.pathname, draggedItem.filename); - } else { - let targetItemParent = findParentItemInCollection(collection, targetItem.uid); - - if (targetItemParent) { - targetItemParent.items = sortBy(targetItemParent.items, (item) => item.seq); - let targetItemIndex = findIndex(targetItemParent.items, (i) => i.uid === targetItem.uid); - targetItemParent.items.splice(targetItemIndex + 1, 0, draggedItem); - draggedItem.pathname = path.join(targetItemParent.pathname, draggedItem.filename); - } else { - collection.items = sortBy(collection.items, (item) => item.seq); - let targetItemIndex = findIndex(collection.items, (i) => i.uid === targetItem.uid); - collection.items.splice(targetItemIndex + 1, 0, draggedItem); - draggedItem.pathname = path.join(collection.pathname, draggedItem.filename); - } - } -}; - -export const moveCollectionItemToRootOfCollection = (collection, draggedItem) => { - let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid); - - // If the dragged item is already at the root of the collection, do nothing - if (!draggedItemParent) { - return; - } - - draggedItemParent.items = sortBy(draggedItemParent.items, (item) => item.seq); - draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid); - collection.items = sortBy(collection.items, (item) => item.seq); - collection.items.push(draggedItem); - if (draggedItem.type == 'folder') { - draggedItem.pathname = path.join(collection.pathname, draggedItem.name); - } else { - draggedItem.pathname = path.join(collection.pathname, draggedItem.filename); - } -}; - -export const getItemsToResequence = (parent, collection) => { - let itemsToResequence = []; - - if (!parent) { - let index = 1; - each(collection.items, (item) => { - if (isItemARequest(item)) { - itemsToResequence.push({ - pathname: item.pathname, - seq: index++ - }); - } - }); - return itemsToResequence; - } - - if (parent.items && parent.items.length) { - let index = 1; - each(parent.items, (item) => { - if (isItemARequest(item)) { - itemsToResequence.push({ - pathname: item.pathname, - seq: index++ - }); - } - }); - return itemsToResequence; - } - - return itemsToResequence; -}; - export const transformCollectionToSaveToExportAsFile = (collection, options = {}) => { const copyHeaders = (headers) => { return map(headers, (header) => { @@ -502,6 +426,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} if (meta?.name) { di.root.meta = {}; di.root.meta.name = meta?.name; + di.root.meta.seq = meta?.seq; } if (!Object.keys(di.root.request)?.length) { delete di.root.request; @@ -1086,3 +1011,62 @@ export const getFormattedCollectionOauth2Credentials = ({ oauth2Credentials = [] }); return credentialsVariables; }; + + +// item sequence utils - START + +export const resetSequencesInFolder = (folderItems) => { + const items = folderItems; + const sortedItems = items.sort((a, b) => a.seq - b.seq); + return sortedItems.map((item, index) => { + item.seq = index + 1; + return item; + }); +}; + +export const isItemBetweenSequences = (itemSequence, sourceItemSequence, targetItemSequence) => { + if (targetItemSequence > sourceItemSequence) { + return itemSequence > sourceItemSequence && itemSequence < targetItemSequence; + } + return itemSequence < sourceItemSequence && itemSequence >= targetItemSequence; +}; + +export const calculateNewSequence = (isDraggedItem, targetSequence, draggedSequence) => { + if (!isDraggedItem) { + return null; + } + return targetSequence > draggedSequence ? targetSequence - 1 : targetSequence; +}; + +export const getReorderedItemsInTargetDirectory = ({ items, targetItemUid, draggedItemUid }) => { + const itemsWithFixedSequences = resetSequencesInFolder(cloneDeep(items)); + const targetItem = findItem(itemsWithFixedSequences, targetItemUid); + const draggedItem = findItem(itemsWithFixedSequences, draggedItemUid); + const targetSequence = targetItem?.seq; + const draggedSequence = draggedItem?.seq; + itemsWithFixedSequences?.forEach(item => { + const isDraggedItem = item?.uid === draggedItemUid; + const isBetween = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence); + if (isBetween) { + item.seq += targetSequence > draggedSequence ? -1 : 1; + } + const newSequence = calculateNewSequence(isDraggedItem, targetSequence, draggedSequence); + if (newSequence !== null) { + item.seq = newSequence; + } + }); + // only return items that have been reordered + return itemsWithFixedSequences.filter(item => + items?.find(originalItem => originalItem?.uid === item?.uid)?.seq !== item?.seq + ); +}; + +export const getReorderedItemsInSourceDirectory = ({ items }) => { + const itemsWithFixedSequences = resetSequencesInFolder(cloneDeep(items)); + return itemsWithFixedSequences.filter(item => + items?.find(originalItem => originalItem?.uid === item?.uid)?.seq !== item?.seq + ); +}; + +// item sequence utils - END + diff --git a/packages/bruno-app/src/utils/tests/collections/items-sequencing.spec.js b/packages/bruno-app/src/utils/tests/collections/items-sequencing.spec.js new file mode 100644 index 000000000..adfb5dab9 --- /dev/null +++ b/packages/bruno-app/src/utils/tests/collections/items-sequencing.spec.js @@ -0,0 +1,126 @@ +import { resetSequencesInFolder, isItemBetweenSequences } from 'utils/collections/index'; + +describe('resetSequencesInFolder', () => { + it('should fix the sequences in the folder 1', () => { + const folder = { + items: [ + { uid: '1', seq: 1 }, + { uid: '2', seq: 3 }, + { uid: '3', seq: 6 }, + ], + }; + + const fixedFolder = resetSequencesInFolder(folder.items); + expect(fixedFolder).toEqual([ + { uid: '1', seq: 1 }, + { uid: '2', seq: 2 }, + { uid: '3', seq: 3 }, + ]); + }); + + + it('should fix the sequences in the folder 2', () => { + const folder = { + items: [ + { uid: '1', seq: 3 }, + { uid: '2', seq: 1 }, + { uid: '3', seq: 2 }, + ], + }; + + const fixedFolder = resetSequencesInFolder(folder.items); + expect(fixedFolder).toEqual([ + { uid: '2', seq: 1 }, + { uid: '3', seq: 2 }, + { uid: '1', seq: 3 }, + ]); + }); + + it('should fix the sequences in the folder with missing sequences', () => { + const folder = { + items: [ + { uid: '1', seq: 1 }, + { uid: '2', type: 'folder' }, + { uid: '3', type: 'folder' }, + { uid: '4', seq: 7 }, + ] + }; + + const fixedFolder = resetSequencesInFolder(folder.items); + expect(fixedFolder).toEqual([ + { uid: '1', seq: 1 }, + { uid: '2', seq: 2, type: 'folder' }, + { uid: '3', seq: 3, type: 'folder' }, + { uid: '4', seq: 4 }, + ]); + }); + + it('should fix the sequences in the folder with same sequences', () => { + const folder = { + items: [ + { uid: '1', seq: 2 }, + { uid: '2', seq: 2 }, + { uid: '3', seq: 3 }, + { uid: '4', seq: 1 }, + ], + }; + + const fixedFolder = resetSequencesInFolder(folder.items); + expect(fixedFolder).toEqual([ + { uid: '4', seq: 1 }, + { uid: '1', seq: 2 }, + { uid: '2', seq: 3 }, + { uid: '3', seq: 4 }, + ]); + }); +}); + +describe('isItemBetweenSequences', () => { + it('should return true if the item is between the sequences 1', () => { + const item = { uid: '1', seq: 2 }; + const draggedSequence = 1; + const targetSequence = 5; + const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence); + expect(result).toBe(true); + }); + + it('should return true if the item is between the sequences 2', () => { + const item = { uid: '1', seq: 2 }; + const draggedSequence = 1; + const targetSequence = 5; + const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence); + expect(result).toBe(true); + }); + + it('should return true if the item is between the sequences 3', () => { + const item = { uid: '1', seq: 4 }; + const draggedSequence = 1; + const targetSequence = 5; + const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence); + expect(result).toBe(true); + }); + + it('should return true if the item is between the sequences 4', () => { + const item = { uid: '1', seq: 1 }; + const draggedSequence = 5; + const targetSequence = 1; + const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence); + expect(result).toBe(true); + }); + + it('should return false if the item is between the sequences 1', () => { + const item = { uid: '1', seq: 1 }; + const draggedSequence = 1; + const targetSequence = 5; + const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence); + expect(result).toBe(false); + }); + + it('should return false if the item is between the sequences 2', () => { + const item = { uid: '1', seq: 5 }; + const draggedSequence = 1; + const targetSequence = 5; + const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence); + expect(result).toBe(false); + }); +}); diff --git a/packages/bruno-converters/src/postman/postman-to-bruno.js b/packages/bruno-converters/src/postman/postman-to-bruno.js index 31b2f3929..6eb832c24 100644 --- a/packages/bruno-converters/src/postman/postman-to-bruno.js +++ b/packages/bruno-converters/src/postman/postman-to-bruno.js @@ -252,7 +252,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth) => { const requestMap = {}; const requestMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE'] - each(item, (i) => { + each(item, (i, index) => { if (isItemAFolder(i)) { const baseFolderName = i.name || 'Untitled Folder'; let folderName = baseFolderName; @@ -268,6 +268,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth) => { name: folderName, type: 'folder', items: [], + seq: index + 1, root: { docs: i.description || '', meta: { @@ -332,6 +333,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth) => { uid: uuid(), name: requestName, type: 'http-request', + seq: index + 1, request: { url: url, method: i?.request?.method?.toUpperCase(), diff --git a/packages/bruno-converters/tests/postman/postman-to-bruno/postman-to-bruno.spec.js b/packages/bruno-converters/tests/postman/postman-to-bruno/postman-to-bruno.spec.js index 7eac3906c..f8f52538e 100644 --- a/packages/bruno-converters/tests/postman/postman-to-bruno/postman-to-bruno.spec.js +++ b/packages/bruno-converters/tests/postman/postman-to-bruno/postman-to-bruno.spec.js @@ -73,92 +73,93 @@ const expectedOutput = { "version": "1", "items": [ { - "uid": "mockeduuidvalue123456", - "name": "folder", - "type": "folder", - "items": [ - { - "uid": "mockeduuidvalue123456", - "name": "request", - "type": "http-request", - "request": { - "url": "https://usebruno.com", - "method": "GET", - "auth": { - "mode": "none", - "basic": null, - "bearer": null, - "awsv4": null, - "apikey": null, - "oauth2": null, - "digest": null - }, - "headers": [], - "params": [], - "body": { - "mode": "none", - "json": null, - "text": null, - "xml": null, - "formUrlEncoded": [], - "multipartForm": [] - }, - "docs": "" - }, - "seq": 1 - } - ], - "root": { - "docs": "", - "meta": { - "name": "folder" - }, - "request": { - "auth": { - "mode": "none", - "basic": null, - "bearer": null, - "awsv4": null, - "apikey": null, - "oauth2": null, - "digest": null - }, - "headers": [], - "script": {}, - "tests": "", - "vars": {} - } - } + "uid": "mockeduuidvalue123456", + "name": "folder", + "type": "folder", + "seq": 1, + "items": [ + { + "uid": "mockeduuidvalue123456", + "name": "request", + "type": "http-request", + "seq": 1, + "request": { + "url": "https://usebruno.com", + "method": "GET", + "auth": { + "mode": "none", + "basic": null, + "bearer": null, + "awsv4": null, + "apikey": null, + "oauth2": null, + "digest": null + }, + "headers": [], + "params": [], + "body": { + "mode": "none", + "json": null, + "text": null, + "xml": null, + "formUrlEncoded": [], + "multipartForm": [] + }, + "docs": "" + } + } + ], + "root": { + "docs": "", + "meta": { + "name": "folder" + }, + "request": { + "auth": { + "mode": "none", + "basic": null, + "bearer": null, + "awsv4": null, + "apikey": null, + "oauth2": null, + "digest": null + }, + "headers": [], + "script": {}, + "tests": "", + "vars": {} + } + } }, { - "uid": "mockeduuidvalue123456", - "name": "request", - "type": "http-request", - "request": { - "url": "https://usebruno.com", - "method": "GET", - "auth": { - "mode": "none", - "basic": null, - "bearer": null, - "awsv4": null, - "apikey": null, - "oauth2": null, - "digest": null - }, - "headers": [], - "params": [], - "body": { - "mode": "none", - "json": null, - "text": null, - "xml": null, - "formUrlEncoded": [], - "multipartForm": [] - }, - "docs": "" - }, - "seq": 1 + "uid": "mockeduuidvalue123456", + "name": "request", + "type": "http-request", + "seq": 2, + "request": { + "url": "https://usebruno.com", + "method": "GET", + "auth": { + "mode": "none", + "basic": null, + "bearer": null, + "awsv4": null, + "apikey": null, + "oauth2": null, + "digest": null + }, + "headers": [], + "params": [], + "body": { + "mode": "none", + "json": null, + "text": null, + "xml": null, + "formUrlEncoded": [], + "multipartForm": [] + }, + "docs": "" + }, } ], "environments": [], diff --git a/packages/bruno-electron/src/app/watcher.js b/packages/bruno-electron/src/app/watcher.js index 3ee646e81..8cd3db22d 100644 --- a/packages/bruno-electron/src/app/watcher.js +++ b/packages/bruno-electron/src/app/watcher.js @@ -220,7 +220,6 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread } } - // Is this a folder.bru file? if (path.basename(pathname) === 'folder.bru') { const file = { meta: { @@ -327,16 +326,25 @@ const addDirectory = async (win, pathname, collectionUid, collectionPath) => { } let name = path.basename(pathname); + let seq = 1; + const folderBruFilePath = path.join(pathname, `folder.bru`); + + if (fs.existsSync(folderBruFilePath)) { + let folderBruFileContent = fs.readFileSync(folderBruFilePath, 'utf8'); + let folderBruData = await collectionBruToJson(folderBruFileContent); + name = folderBruData?.meta?.name || name; + seq = folderBruData?.meta?.seq || seq; + } const directory = { meta: { collectionUid, pathname, - name + name, + seq } }; - win.webContents.send('main:collection-tree-updated', 'addDir', directory); }; diff --git a/packages/bruno-electron/src/bru/index.js b/packages/bruno-electron/src/bru/index.js index d41c980d7..946d95519 100644 --- a/packages/bruno-electron/src/bru/index.js +++ b/packages/bruno-electron/src/bru/index.js @@ -29,9 +29,11 @@ const collectionBruToJson = async (data, parsed = false) => { // add meta if it exists // this is only for folder bru file // in the future, all of this will be replaced by standard bru lang - if (json.meta) { + const sequence = _.get(json, 'meta.seq'); + if (json?.meta) { transformedJson.meta = { - name: json.meta.name + name: json.meta.name, + seq: !isNaN(sequence) ? Number(sequence) : 1 }; } @@ -61,9 +63,11 @@ const jsonToCollectionBru = async (json, isFolder) => { // add meta if it exists // this is only for folder bru file // in the future, all of this will be replaced by standard bru lang + const sequence = _.get(json, 'meta.seq'); if (json?.meta) { collectionBruJson.meta = { - name: json.meta.name + name: json.meta.name, + seq: !isNaN(sequence) ? Number(sequence) : 1 }; } diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 0aaf87d55..97742928c 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -1,5 +1,6 @@ const _ = require('lodash'); const fs = require('fs'); +const fsPromises = require('fs/promises'); const fsExtra = require('fs-extra'); const os = require('os'); const path = require('path'); @@ -22,7 +23,9 @@ const { hasSubDirectories, getCollectionStats, sizeInMB, - safeWriteFileSync + safeWriteFileSync, + copyPath, + removePath } = require('../utils/filesystem'); const { openCollectionDialog } = require('../app/collections'); const { generateUidBasedOnHash, stringifyJson, safeParseJSON, safeStringifyJSON } = require('../utils/common'); @@ -32,11 +35,10 @@ const EnvironmentSecretsStore = require('../store/env-secrets'); const CollectionSecurityStore = require('../store/collection-security'); const UiStateSnapshotStore = require('../store/ui-state-snapshot'); const interpolateVars = require('./network/interpolate-vars'); -const { getEnvVars, getTreePathFromCollectionToItem, mergeVars } = require('../utils/collection'); +const { getEnvVars, getTreePathFromCollectionToItem, mergeVars, parseBruFileMeta, hydrateRequestWithUuid, transformRequestToSaveToFilesystem } = require('../utils/collection'); const { getProcessEnvVars } = require('../store/process-env'); const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials, refreshOauth2Token } = require('../utils/oauth2'); const { getCertsAndProxyConfig } = require('./network'); -const { parseBruFileMeta, hydrateRequestWithUuid } = require('../utils/collection'); const environmentSecretsStore = new EnvironmentSecretsStore(); const collectionSecurityStore = new CollectionSecurityStore(); @@ -192,12 +194,15 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection ipcMain.handle('renderer:save-folder-root', async (event, folder) => { try { - const { name: folderName, root: folderRoot, pathname: folderPathname } = folder; + const { name: folderName, root: folderRoot = {}, pathname: folderPathname } = folder; const folderBruFilePath = path.join(folderPathname, 'folder.bru'); - folderRoot.meta = { - name: folderName - }; + if (!folderRoot.meta) { + folderRoot.meta = { + name: folderName, + seq: 1 + }; + } const content = await jsonToCollectionBru( folderRoot, @@ -376,14 +381,16 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection if (fs.existsSync(folderBruFilePath)) { const oldFolderBruFileContent = await fs.promises.readFile(folderBruFilePath, 'utf8'); folderBruFileJsonContent = await collectionBruToJson(oldFolderBruFileContent); + folderBruFileJsonContent.meta.name = newName; } else { - folderBruFileJsonContent = {}; + folderBruFileJsonContent = { + meta: { + name: newName, + seq: 1 + } + }; } - - folderBruFileJsonContent.meta = { - name: newName, - }; - + const folderBruFileContent = await jsonToCollectionBru(folderBruFileJsonContent, true); await writeFile(folderBruFilePath, folderBruFileContent); @@ -425,14 +432,16 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection if (fs.existsSync(folderBruFilePath)) { const oldFolderBruFileContent = await fs.promises.readFile(folderBruFilePath, 'utf8'); folderBruFileJsonContent = await collectionBruToJson(oldFolderBruFileContent); + folderBruFileJsonContent.meta.name = newName; } else { - folderBruFileJsonContent = {}; + folderBruFileJsonContent = { + meta: { + name: newName, + seq: 1 + } + }; } - folderBruFileJsonContent.meta = { - name: newName, - }; - const folderBruFileContent = await jsonToCollectionBru(folderBruFileJsonContent, true); await writeFile(folderBruFilePath, folderBruFileContent); @@ -512,6 +521,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection let data = { meta: { name: folderName, + seq: 1 } }; const content = await jsonToCollectionBru(data, true); // isFolder flag @@ -598,6 +608,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection if (item?.root?.meta?.name) { const folderBruFilePath = path.join(folderPath, 'folder.bru'); + item.root.meta.seq = item.seq; const folderContent = await jsonToCollectionBru( item.root, true // isFolder @@ -731,17 +742,42 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection ipcMain.handle('renderer:resequence-items', async (event, itemsToResequence) => { try { - for await (let item of itemsToResequence) { - const bru = fs.readFileSync(item.pathname, 'utf8'); - const jsonData = await bruToJsonViaWorker(bru); - - if (jsonData.seq !== item.seq) { - jsonData.seq = item.seq; - const content = await jsonToBruViaWorker(jsonData); - await writeFile(item.pathname, content); + for (let item of itemsToResequence) { + if (item?.type === 'folder') { + const folderRootPath = path.join(item.pathname, 'folder.bru'); + let folderBruJsonData = { + meta: { + name: path.basename(item?.pathname), + seq: item?.seq || 1 + } + }; + if (fs.existsSync(folderRootPath)) { + const bru = fs.readFileSync(folderRootPath, 'utf8'); + folderBruJsonData = await collectionBruToJson(bru); + if (!folderBruJsonData?.meta) { + folderBruJsonData.meta = { + name: path.basename(item?.pathname), + seq: item?.seq || 1 + }; + } + if (folderBruJsonData?.meta?.seq === item.seq) { + continue; + } + folderBruJsonData.meta.seq = item.seq; + } + const content = await jsonToCollectionBru(folderBruJsonData); + await writeFile(folderRootPath, content); + } else { + if (fs.existsSync(item.pathname)) { + const itemToSave = transformRequestToSaveToFilesystem(item); + const content = await jsonToBruViaWorker(itemToSave); + await writeFile(item.pathname, content); + } } } + return true; } catch (error) { + console.error('Error in resequence-items:', error); return Promise.reject(error); } }); @@ -760,6 +796,17 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection } }); + ipcMain.handle('renderer:move-item', async (event, { targetDirname, sourcePathname }) => { + try { + if (fs.existsSync(targetDirname)) { + await copyPath(sourcePathname, targetDirname); + await removePath(sourcePathname); + } + } catch (error) { + return Promise.reject(error); + } + }); + ipcMain.handle('renderer:move-folder-item', async (event, folderPath, destinationPath) => { try { const folderName = path.basename(folderPath); diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 8fddd2d98..28a49e80f 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -1255,6 +1255,7 @@ const registerNetworkIpc = (mainWindow) => { folderUid }); } catch (error) { + console.log("error", error); deleteCancelToken(cancelTokenUid); mainWindow.webContents.send('main:run-folder-event', { type: 'testrun-ended', diff --git a/packages/bruno-electron/src/utils/collection.js b/packages/bruno-electron/src/utils/collection.js index d6fed9da6..94fa30ec8 100644 --- a/packages/bruno-electron/src/utils/collection.js +++ b/packages/bruno-electron/src/utils/collection.js @@ -1,4 +1,4 @@ -const { get, each, find, compact, filter } = require('lodash'); +const { get, each, find, compact, isString, filter } = require('lodash'); const fs = require('fs'); const { getRequestUid } = require('../cache/requestUids'); const { uuid } = require('./common'); @@ -205,6 +205,14 @@ const findParentItemInCollection = (collection, itemUid) => { }); }; +const findParentItemInCollectionByPathname = (collection, pathname) => { + let flattenedItems = flattenItems(collection.items); + + return find(flattenedItems, (item) => { + return item.items && find(item.items, (i) => i.pathname === pathname); + }); +}; + const getTreePathFromCollectionToItem = (collection, _item) => { let path = []; let item = findItemInCollection(collection, _item.uid); @@ -272,12 +280,73 @@ const findItemInCollectionByPathname = (collection, pathname) => { return findItemByPathname(flattenedItems, pathname); }; +const replaceTabsWithSpaces = (str, numSpaces = 2) => { + if (!str || !str.length || !isString(str)) { + return ''; + } + + return str.replaceAll('\t', ' '.repeat(numSpaces)); +}; + +const transformRequestToSaveToFilesystem = (item) => { + const _item = item.draft ? item.draft : item; + const itemToSave = { + uid: _item.uid, + type: _item.type, + name: _item.name, + seq: _item.seq, + request: { + method: _item.request.method, + url: _item.request.url, + params: [], + headers: [], + auth: _item.request.auth, + body: _item.request.body, + script: _item.request.script, + vars: _item.request.vars, + assertions: _item.request.assertions, + tests: _item.request.tests, + docs: _item.request.docs + } + }; + + each(_item.request.params, (param) => { + itemToSave.request.params.push({ + uid: param.uid, + name: param.name, + value: param.value, + description: param.description, + type: param.type, + enabled: param.enabled + }); + }); + + each(_item.request.headers, (header) => { + itemToSave.request.headers.push({ + uid: header.uid, + name: header.name, + value: header.value, + description: header.description, + enabled: header.enabled + }); + }); + + if (itemToSave.request.body.mode === 'json') { + itemToSave.request.body = { + ...itemToSave.request.body, + json: replaceTabsWithSpaces(itemToSave.request.body.json) + }; + } + + return itemToSave; +} + const sortCollection = (collection) => { const items = collection.items || []; let folderItems = filter(items, (item) => item.type === 'folder'); let requestItems = filter(items, (item) => item.type !== 'folder'); - folderItems = folderItems.sort((a, b) => a.name.localeCompare(b.name)); + folderItems = folderItems.sort((a, b) => a.seq - b.seq); requestItems = requestItems.sort((a, b) => a.seq - b.seq); collection.items = folderItems.concat(requestItems); @@ -292,7 +361,7 @@ const sortFolder = (folder = {}) => { let folderItems = filter(items, (item) => item.type === 'folder'); let requestItems = filter(items, (item) => item.type !== 'folder'); - folderItems = folderItems.sort((a, b) => a.name.localeCompare(b.name)); + folderItems = folderItems.sort((a, b) => a.seq - b.seq); requestItems = requestItems.sort((a, b) => a.seq - b.seq); folder.items = folderItems.concat(requestItems); @@ -410,11 +479,13 @@ module.exports = { findItemByPathname, findItemInCollectionByPathname, findParentItemInCollection, + findParentItemInCollectionByPathname, parseBruFileMeta, + hydrateRequestWithUuid, + transformRequestToSaveToFilesystem, sortCollection, sortFolder, getAllRequestsInFolderRecursively, getEnvVars, - getFormattedCollectionOauth2Credentials, - hydrateRequestWithUuid + getFormattedCollectionOauth2Credentials }; \ No newline at end of file diff --git a/packages/bruno-electron/src/utils/filesystem.js b/packages/bruno-electron/src/utils/filesystem.js index a95c116eb..f00734c7e 100644 --- a/packages/bruno-electron/src/utils/filesystem.js +++ b/packages/bruno-electron/src/utils/filesystem.js @@ -282,6 +282,48 @@ function safeWriteFileSync(filePath, data) { fs.writeFileSync(safePath, data); } +// Recursively copies a source to a destination . +const copyPath = async (source, destination) => { + let targetPath = `${destination}/${path.basename(source)}`; + + const targetPathExists = await fsPromises.access(targetPath).then(() => true).catch(() => false); + if (targetPathExists) { + throw new Error(`Cannot copy, ${path.basename(source)} already exists in ${path.basename(destination)}`); + } + + const copy = async (source, destination) => { + const stat = await fsPromises.lstat(source); + if (stat.isDirectory()) { + await fsPromises.mkdir(destination, { recursive: true }); + const entries = await fsPromises.readdir(source); + for (const entry of entries) { + const srcPath = path.join(source, entry); + const destPath = path.join(destination, entry); + await copy(srcPath, destPath); + } + } else { + await fsPromises.copyFile(source, destination); + } + } + + await copy(source, targetPath); +} + +// Recursively removes a source . +const removePath = async (source) => { + const stat = await fsPromises.lstat(source); + if (stat.isDirectory()) { + const entries = await fsPromises.readdir(source); + for (const entry of entries) { + const entryPath = path.join(source, entry); + await removePath(entryPath); + } + await fsPromises.rmdir(source); + } else { + await fsPromises.unlink(source); + } +} + module.exports = { isValidPathname, exists, @@ -308,5 +350,7 @@ module.exports = { getCollectionStats, sizeInMB, safeWriteFile, - safeWriteFileSync + safeWriteFileSync, + copyPath, + removePath }; diff --git a/packages/bruno-electron/src/utils/tests/filesystem/index.spec.js b/packages/bruno-electron/src/utils/tests/filesystem/index.spec.js new file mode 100644 index 000000000..60add1b57 --- /dev/null +++ b/packages/bruno-electron/src/utils/tests/filesystem/index.spec.js @@ -0,0 +1,116 @@ +const path = require('path'); +const fs = require('fs/promises'); +const os = require('os'); +const { copyPath, removePath } = require('../../filesystem'); +const { initialCollectionStructure, finalCollectionStructure } = require('../fixtures/filesystem/copypath-removepath'); + +describe('File System Operations', () => { + let tempDir; + + beforeAll(async () => { + // Create a temporary directory for each test + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bruno-test-')); + await createFilesAndFolders(tempDir, initialCollectionStructure); + const result = await verifyFilesAndFolders(tempDir, initialCollectionStructure); + expect(result).toBe(true); + }); + + afterAll(async () => { + // clean up after each test + await fs.rm(tempDir, { recursive: true, force: true }); + // confirm the temp directory is deleted + expect(await fs.access(tempDir).then(() => true).catch(() => false)).toBe(false); + }); + + describe('copyPath and removePath', () => { + it('should move files and folder items multiple times', async () => { + + { + const sourcePath = path.join(tempDir, 'folder_1', 'file_2.bru'); + const destDir = path.join(tempDir, 'folder_1', 'folder_1_1'); + await copyPath(sourcePath, destDir); + await removePath(sourcePath); + } + + { + const sourcePath = path.join(tempDir, 'folder_2'); + const destDir = path.join(tempDir, 'folder_1', 'folder_1_1'); + await copyPath(sourcePath, destDir); + await removePath(sourcePath); + } + + { + const sourcePath = path.join(tempDir, 'folder_1', 'folder_1_1', 'folder_2', 'file_2_2.bru'); + const destDir = path.join(tempDir, 'folder_1'); + await copyPath(sourcePath, destDir); + await removePath(sourcePath); + } + + { + const sourcePath = path.join(tempDir, 'folder_1', 'folder_1_1', 'folder_2', 'folder_2_1'); + const destDir = path.join(tempDir); + await copyPath(sourcePath, destDir); + await removePath(sourcePath); + } + + const result = await verifyFilesAndFolders(tempDir, finalCollectionStructure); + expect(result).toBe(true); + }); + + + it('should throw an error move file/folder if the destination has the same filename', async () => { + { + const sourcePath = path.join(tempDir, 'folder_1', 'file_dup.bru'); + const destDir = path.join(tempDir, 'folder_1'); + await expect(copyPath(sourcePath, destDir)).rejects.toThrow(); + } + }); + + }); +}); + + +// create folders and files recursively based on the defined json structure +const createFilesAndFolders = async (dir, filesAndFolders) => { + for (const item of filesAndFolders) { + const itemPath = path.join(dir, item.name); + if (item.type === 'folder') { + await fs.mkdir(itemPath, { recursive: true }); + await createFilesAndFolders(itemPath, item.files); + } else { + await fs.writeFile(itemPath, item.content); + } + } +} + +// if a file/folder doesnt exist, return false +// should only contain files and folders that are defined in the json structure +const verifyFilesAndFolders = async (dir, filesAndFolders) => { + const verify = async (dir, filesAndFolders) => { + const files = await fs.readdir(dir); + if (files.length !== filesAndFolders.length) { + return false; + } + for (const file of files) { + const itemPath = path.join(dir, file); + const item = filesAndFolders.find(f => f.name === file); + if (!item) { + return false; + } + if (item.type === 'folder') { + return await verify(itemPath, item.files); + } else { + return await fs.readFile(itemPath, 'utf8').then(content => content === item.content); + } + } + return true; + } + + try { + const verified = await verify(dir, filesAndFolders); + return verified; + } catch (error) { + console.error(error); + return false; + } +} \ No newline at end of file diff --git a/packages/bruno-electron/src/utils/tests/fixtures/filesystem/copypath-removepath.js b/packages/bruno-electron/src/utils/tests/fixtures/filesystem/copypath-removepath.js new file mode 100644 index 000000000..ea08f8d25 --- /dev/null +++ b/packages/bruno-electron/src/utils/tests/fixtures/filesystem/copypath-removepath.js @@ -0,0 +1,155 @@ +const initialCollectionStructure = [ + { + "name": "folder_1", + "type": "folder", + "files": [ + { + "name": "file_1.bru", + "type": "file", + "content": "file_1_content" + }, + { + "name": "file_2.bru", + "type": "file", + "content": "file_2_content" + }, + { + "name": "folder_1_1", + "type": "folder", + "files": [ + { + "name": "file_1_1.bru", + "type": "file", + "content": "file_1_1_content" + }, + { + "name": "file_1_2.bru", + "type": "file", + "content": "file_1_2_content" + } + ] + }, + { + "name": "file_1_3.bru", + "type": "file", + "content": "file_1_3_content" + }, + { + "name": "file_dup.bru", + "type": "file", + "content": "file_dup_content" + } + ], + }, + { + "name": "folder_2", + "type": "folder", + "files": [ + { + "name": "file_2_1.bru", + "type": "file", + "content": "file_2_1_content" + }, + { + "name": "file_2_2.bru", + "type": "file", + "content": "file_2_2_content" + }, + { + "name": "folder_2_1", + "type": "folder", + "files": [ + { + "name": "file_2_1_1.bru", + "type": "file", + "content": "file_2_1_1_content" + } + ] + } + ] + }, + { + "name": "file_dup.bru", + "type": "file", + "content": "file_dup_content" + } +]; + +const finalCollectionStructure = [ + { + "name": "folder_1", + "type": "folder", + "files": [ + { + "name": "file_1.bru", + "type": "file", + "content": "file_1_content" + }, + { + "name": "folder_1_1", + "type": "folder", + "files": [ + { + "name": "file_1_1.bru", + "type": "file", + "content": "file_1_1_content" + }, + { + "name": "file_1_2.bru", + "type": "file", + "content": "file_1_2_content" + }, + { + "name": "file_2.bru", + "type": "file", + "content": "file_2_content" + }, + { + "name": "folder_2", + "type": "folder", + "files": [ + { + "name": "file_2_1.bru", + "type": "file", + "content": "file_2_1_content" + } + ] + } + ] + }, + { + "name": "file_1_3.bru", + "type": "file", + "content": "file_1_3_content" + }, + { + "name": "file_2_2.bru", + "type": "file", + "content": "file_2_2_content" + }, + { + "name": "file_dup.bru", + "type": "file", + "content": "file_dup_content" + } + ], + }, + { + "name": "folder_2_1", + "type": "folder", + "files": [ + { + "name": "file_2_1_1.bru", + "type": "file", + "content": "file_2_1_1_content" + } + ] + }, + { + "name": "file_dup.bru", + "type": "file", + "content": "file_dup_content" + } +]; + +module.exports = { initialCollectionStructure, finalCollectionStructure }; \ No newline at end of file diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js index 3914e6bfa..af4b13434 100644 --- a/packages/bruno-schema/src/collections/index.js +++ b/packages/bruno-schema/src/collections/index.js @@ -340,7 +340,8 @@ const folderRootSchema = Yup.object({ .nullable(), docs: Yup.string().nullable(), meta: Yup.object({ - name: Yup.string().nullable() + name: Yup.string().nullable(), + seq: Yup.number().min(1).nullable() }) .noUnknown(true) .strict()