From 21c9c8b4fbfacadd6147e946086c1d7f630ed714 Mon Sep 17 00:00:00 2001 From: Anoop M D Date: Fri, 20 Jan 2023 00:45:07 +0530 Subject: [PATCH] feat: drag and drop for files and folders --- .../RequestTabPanel/RequestNotFound/index.js | 17 +- .../RequestTab/RequestTabNotFound.js | 42 ++++ .../RequestTabs/RequestTab/index.js | 15 +- .../Collection/CollectionItem/index.js | 66 +++--- .../Sidebar/Collections/Collection/index.js | 54 ++--- .../ReduxStore/slices/collections/actions.js | 190 +++++++++++------- .../ReduxStore/slices/collections/index.js | 69 +++---- .../bruno-app/src/utils/collections/index.js | 103 +++++++++- packages/bruno-electron/src/app/watcher.js | 11 +- .../bruno-electron/src/cache/requestUids.js | 44 ++++ .../src/ipc/local-collection.js | 91 ++++++++- .../bruno-electron/src/utils/filesystem.js | 23 ++- packages/bruno-lang/src/index.js | 3 + packages/bruno-lang/src/inline-tag.js | 1 + .../bruno-schema/src/collections/index.js | 3 +- 15 files changed, 534 insertions(+), 198 deletions(-) create mode 100644 packages/bruno-app/src/components/RequestTabs/RequestTab/RequestTabNotFound.js create mode 100644 packages/bruno-electron/src/cache/requestUids.js diff --git a/packages/bruno-app/src/components/RequestTabPanel/RequestNotFound/index.js b/packages/bruno-app/src/components/RequestTabPanel/RequestNotFound/index.js index 54ef214da..e3bed1c40 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/RequestNotFound/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/RequestNotFound/index.js @@ -1,9 +1,10 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { closeTabs } from 'providers/ReduxStore/slices/tabs'; import { useDispatch } from 'react-redux'; const RequestNotFound = ({ itemUid }) => { const dispatch = useDispatch(); + const [showErrorMessage, setShowErrorMessage] = useState(false); const closeTab = () => { dispatch( @@ -13,6 +14,20 @@ const RequestNotFound = ({ itemUid }) => { ); }; + useEffect(() => { + setTimeout(() => { + setShowErrorMessage(true); + }, 300); + }, []); + + // add a delay component in react that shows a loading spinner + // and then shows the error message after a delay + // this will prevent the error message from flashing on the screen + + if(!showErrorMessage) { + return null; + } + return (
diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/RequestTabNotFound.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/RequestTabNotFound.js new file mode 100644 index 000000000..180d4fa02 --- /dev/null +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/RequestTabNotFound.js @@ -0,0 +1,42 @@ +import React, { useState, useEffect } from 'react'; +import { IconAlertTriangle } from '@tabler/icons'; + +const RequestTabNotFound = ({handleCloseClick}) => { + const [showErrorMessage, setShowErrorMessage] = useState(false); + + // add a delay component in react that shows a loading spinner + // and then shows the error message after a delay + // this will prevent the error message from flashing on the screen + useEffect(() => { + setTimeout(() => { + setShowErrorMessage(true); + }, 300); + }, []); + + if(!showErrorMessage) { + return null; + } + + return ( + <> +
+ {showErrorMessage ? ( + <> + + Not Found + + ) : null} +
+
handleCloseClick(e)}> + + + +
+ + ); +}; + +export default RequestTabNotFound; diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js index b9b8706ab..7754b6286 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js @@ -4,7 +4,7 @@ import { closeTabs } from 'providers/ReduxStore/slices/tabs'; import { useDispatch } from 'react-redux'; import { findItemInCollection } from 'utils/collections'; import StyledWrapper from './StyledWrapper'; -import { IconAlertTriangle } from '@tabler/icons'; +import RequestTabNotFound from './RequestTabNotFound'; const RequestTab = ({ tab, collection }) => { const dispatch = useDispatch(); @@ -61,18 +61,7 @@ const RequestTab = ({ tab, collection }) => { if (!item) { return ( -
- - Not Found -
-
handleCloseClick(e)}> - - - -
+
); } 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 f65b423dc..4c3b43c65 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 @@ -35,28 +35,28 @@ const CollectionItem = ({ item, collection, searchText }) => { const [newFolderModalOpen, setNewFolderModalOpen] = useState(false); const [itemIsCollapsed, setItemisCollapsed] = useState(item.collapsed); - // const [{ isDragging }, drag] = useDrag({ - // type: 'COLLECTION_ITEM', - // item: item, - // collect: (monitor) => ({ - // isDragging: monitor.isDragging() - // }) - // }); + const [{ isDragging }, drag] = useDrag({ + type: 'COLLECTION_ITEM', + item: item, + collect: (monitor) => ({ + isDragging: monitor.isDragging() + }) + }); - // const [{ isOver }, drop] = useDrop({ - // accept: 'COLLECTION_ITEM', - // drop: (draggedItem) => { - // if (draggedItem.uid !== item.uid) { - // dispatch(moveItem(collection.uid, draggedItem.uid, item.uid)); - // } - // }, - // canDrop: (draggedItem) => { - // return draggedItem.uid !== item.uid; - // }, - // collect: (monitor) => ({ - // isOver: monitor.isOver() - // }) - // }); + const [{ isOver }, drop] = useDrop({ + accept: 'COLLECTION_ITEM', + drop: (draggedItem) => { + if (draggedItem.uid !== item.uid) { + dispatch(moveItem(collection.uid, draggedItem.uid, item.uid)); + } + }, + canDrop: (draggedItem) => { + return draggedItem.uid !== item.uid; + }, + collect: (monitor) => ({ + isOver: monitor.isOver() + }) + }); useEffect(() => { if (searchText && searchText.length) { @@ -131,8 +131,18 @@ const CollectionItem = ({ item, collection, searchText }) => { } } - const requestItems = filter(item.items, (i) => isItemARequest(i)); - const folderItems = filter(item.items, (i) => isItemAFolder(i)); + // we need to sort request items by seq property + const sortRequestItems = (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(item.items, (i) => isItemARequest(i))); + const folderItems = sortFolderItems(filter(item.items, (i) => isItemAFolder(i))); return ( @@ -141,7 +151,7 @@ const CollectionItem = ({ item, collection, searchText }) => { {deleteItemModalOpen && setDeleteItemModalOpen(false)} />} {newRequestModalOpen && setNewRequestModalOpen(false)} />} {newFolderModalOpen && setNewFolderModalOpen(false)} />} -
+
drag(drop(node))}>
{indents && indents.length ? indents.map((i) => { @@ -239,13 +249,13 @@ const CollectionItem = ({ item, collection, searchText }) => { {!itemIsCollapsed ? (
- {requestItems && requestItems.length - ? requestItems.map((i) => { + {folderItems && folderItems.length + ? folderItems.map((i) => { return ; }) : null} - {folderItems && folderItems.length - ? folderItems.map((i) => { + {requestItems && requestItems.length + ? requestItems.map((i) => { return ; }) : null} 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 1dd202568..0e2748950 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js @@ -59,28 +59,37 @@ const Collection = ({ collection, searchText }) => { } } - const requestItems = filter(collection.items, (i) => isItemARequest(i)); - const folderItems = filter(collection.items, (i) => isItemAFolder(i)); - const handleExportClick = () => { const collectionCopy = cloneDeep(collection); exportCollection(transformCollectionToSaveToIdb(collectionCopy)); }; - // const [{ isOver }, drop] = useDrop({ - // accept: 'COLLECTION_ITEM', - // drop: (draggedItem) => { - // console.log('drop', draggedItem); - // dispatch(moveItemToRootOfCollection(collection.uid, draggedItem.uid)); - // }, - // canDrop: (draggedItem) => { - // // todo need to make sure that draggedItem belongs to the collection - // return true; - // }, - // collect: (monitor) => ({ - // isOver: monitor.isOver() - // }) - // }); + const [{ isOver }, drop] = useDrop({ + accept: 'COLLECTION_ITEM', + drop: (draggedItem) => { + dispatch(moveItemToRootOfCollection(collection.uid, draggedItem.uid)); + }, + canDrop: (draggedItem) => { + // todo need to make sure that draggedItem belongs to the collection + return true; + }, + collect: (monitor) => ({ + isOver: monitor.isOver() + }) + }); + + // we need to sort request items by seq property + const sortRequestItems = (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))); return ( @@ -88,7 +97,7 @@ const Collection = ({ collection, searchText }) => { {showNewFolderModal && setShowNewFolderModal(false)} />} {showRenameCollectionModal && setShowRenameCollectionModal(false)} />} {showRemoveCollectionModal && setShowRemoveCollectionModal(false)} />} -
+
@@ -148,14 +157,13 @@ const Collection = ({ collection, searchText }) => {
{!collectionIsCollapsed ? (
- {requestItems && requestItems.length - ? requestItems.map((i) => { + {folderItems && folderItems.length + ? folderItems.map((i) => { return ; }) : null} - - {folderItems && folderItems.length - ? folderItems.map((i) => { + {requestItems && requestItems.length + ? requestItems.map((i) => { return ; }) : null} 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 4fb58d0e4..d09b562f1 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -6,6 +6,7 @@ import cloneDeep from 'lodash/cloneDeep'; import { findItemInCollection, moveCollectionItem, + getItemsToResequence, moveCollectionItemToRootOfCollection, findCollectionByUid, recursivelyGetAllItemUids, @@ -13,6 +14,7 @@ import { transformRequestToSaveToFilesystem, findParentItemInCollection, findEnvironmentInCollection, + isItemARequest, isItemAFolder, refreshUidsInItem, interpolateEnvironmentVars @@ -30,8 +32,6 @@ import { renameItem as _renameItem, cloneItem as _cloneItem, deleteItem as _deleteItem, - moveItem as _moveItem, - moveItemToRootOfCollection as _moveItemToRootOfCollection, saveRequest as _saveRequest, selectEnvironment as _selectEnvironment, createCollection as _createCollection, @@ -330,27 +330,6 @@ export const moveItem = (collectionUid, draggedItemUid, targetItemUid) => (dispa return reject(new Error('Collection not found')); } - if (isLocalCollection(collection)) { - const draggedItem = findItemInCollection(collection, draggedItemUid); - const targetItem = findItemInCollection(collection, targetItemUid); - - if (!draggedItem) { - return reject(new Error('Dragged item not found')); - } - - if (!targetItem) { - return reject(new Error('Target item not found')); - } - - const { ipcRenderer } = window; - - ipcRenderer - .invoke('renderer:move-item', draggedItem.pathname, targetItem.pathname) - .then(() => resolve()) - .catch((error) => reject(error)); - return; - } - const collectionCopy = cloneDeep(collection); const draggedItem = findItemInCollection(collectionCopy, draggedItemUid); const targetItem = findItemInCollection(collectionCopy, targetItemUid); @@ -363,24 +342,112 @@ export const moveItem = (collectionUid, draggedItemUid, targetItemUid) => (dispa return reject(new Error('Target item not found')); } - moveCollectionItem(collectionCopy, draggedItem, targetItem); + const draggedItemParent = findParentItemInCollection(collectionCopy, draggedItemUid); + const targetItemParent = findParentItemInCollection(collectionCopy, targetItemUid); + const sameParent = draggedItemParent === targetItemParent; - const collectionToSave = transformCollectionToSaveToIdb(collectionCopy); + // 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); - collectionSchema - .validate(collectionToSave) - .then(() => saveCollectionToIdb(window.__idb, collectionToSave)) - .then(() => { - dispatch( - _moveItem({ - collectionUid: collectionUid, - draggedItemUid: draggedItemUid, - targetItemUid: targetItemUid - }) - ); - }) - .then(() => resolve()) - .catch((error) => reject(error)); + 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); + console.log('itemsToResequence', itemsToResequence); + console.log('itemsToResequence2', itemsToResequence2); + + 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)); + } }); }; @@ -393,45 +460,28 @@ export const moveItemToRootOfCollection = (collectionUid, draggedItemUid) => (di return reject(new Error('Collection not found')); } - if (isLocalCollection(collection)) { - const draggedItem = findItemInCollection(collection, draggedItemUid); - - if (!draggedItem) { - return reject(new Error('Dragged item not found')); - } - - const { ipcRenderer } = window; - - ipcRenderer - .invoke('renderer:move-item-to-root-of-collection', draggedItem.pathname) - .then(() => resolve()) - .catch((error) => reject(error)); - return; - } - 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); + const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy); + const itemsToResequence2 = getItemsToResequence(collectionCopy, collectionCopy); - const collectionToSave = transformCollectionToSaveToIdb(collectionCopy); - - collectionSchema - .validate(collectionToSave) - .then(() => saveCollectionToIdb(window.__idb, collectionToSave)) - .then(() => { - dispatch( - _moveItemToRootOfCollection({ - collectionUid: collectionUid, - draggedItemUid: draggedItemUid - }) - ); - }) - .then(() => resolve()) + 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)); }); }; 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 7ac998102..1809ba68f 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -14,10 +14,11 @@ import { findEnvironmentInCollection, findItemInCollectionByPathname, addDepth, - moveCollectionItem, collapseCollection, deleteItemInCollection, - isItemARequest + deleteItemInCollectionByPathname, + isItemARequest, + areItemsTheSameExceptSeqUpdate } from 'utils/collections'; import { parseQueryParams, stringifyQueryParams } from 'utils/url'; import { getSubdirectoriesFromRoot } from 'utils/common/platform'; @@ -146,38 +147,6 @@ export const collectionsSlice = createSlice({ } } }, - moveItem: (state, action) => { - const collection = findCollectionByUid(state.collections, action.payload.collectionUid); - const draggedItemUid = action.payload.draggedItemUid; - const targetItemUid = action.payload.targetItemUid; - - if (collection) { - const draggedItem = findItemInCollection(collection, draggedItemUid); - const targetItem = findItemInCollection(collection, targetItemUid); - - if (!draggedItem || !targetItem) { - return; - } - - moveCollectionItem(collection, draggedItem, targetItem); - addDepth(collection.items); - } - }, - moveItemToRootOfCollection: (state, action) => { - const collection = findCollectionByUid(state.collections, action.payload.collectionUid); - const draggedItemUid = action.payload.draggedItemUid; - - if (collection) { - const draggedItem = findItemInCollection(collection, draggedItemUid); - - if (!draggedItem) { - return; - } - - moveCollectionItemToRootOfCollection(collection, draggedItem); - addDepth(collection.items); - } - }, requestSent: (state, action) => { const { itemUid, collectionUid, cancelTokenUid, requestSent } = action.payload; const collection = findCollectionByUid(state.collections, collectionUid); @@ -650,7 +619,7 @@ export const collectionsSlice = createSlice({ uid: uuid(), pathname: `${currentPath}${PATH_SEPARATOR}${directoryName}`, name: directoryName, - collapsed: false, + collapsed: true, type: 'folder', items: [] }; @@ -669,6 +638,7 @@ export const collectionsSlice = createSlice({ if (currentItem) { currentItem.name = file.data.name; currentItem.type = file.data.type; + currentItem.seq = file.data.seq; currentItem.request = file.data.request; currentItem.filename = file.meta.name; currentItem.pathname = file.meta.pathname; @@ -678,6 +648,7 @@ export const collectionsSlice = createSlice({ uid: file.data.uid, name: file.data.name, type: file.data.type, + seq: file.data.seq, request: file.data.request, filename: file.meta.name, pathname: file.meta.pathname, @@ -703,7 +674,7 @@ export const collectionsSlice = createSlice({ uid: uuid(), pathname: `${currentPath}${PATH_SEPARATOR}${directoryName}`, name: directoryName, - collapsed: false, + collapsed: true, type: 'folder', items: [] }; @@ -724,12 +695,20 @@ export const collectionsSlice = createSlice({ const item = findItemInCollection(collection, file.data.uid); if (item) { - item.name = file.data.name; - item.type = file.data.type; - item.request = file.data.request; - item.filename = file.meta.name; - item.pathname = file.meta.pathname; - item.draft = null; + // whenever a user attempts to sort a req within the same folder + // the seq is updated, but everything else remains the same + // we don't want to lose the draft in this case + if(areItemsTheSameExceptSeqUpdate(item, file.data)) { + item.seq = file.data.seq; + } else { + item.name = file.data.name; + item.type = file.data.type; + item.seq = file.data.seq; + item.request = file.data.request; + item.filename = file.meta.name; + item.pathname = file.meta.pathname; + item.draft = null; + } } } }, @@ -741,7 +720,7 @@ export const collectionsSlice = createSlice({ const item = findItemInCollectionByPathname(collection, file.meta.pathname); if (item) { - deleteItemInCollection(item.uid, collection); + deleteItemInCollectionByPathname(file.meta.pathname, collection); } } }, @@ -753,7 +732,7 @@ export const collectionsSlice = createSlice({ const item = findItemInCollectionByPathname(collection, directory.meta.pathname); if (item) { - deleteItemInCollection(item.uid, collection); + deleteItemInCollectionByPathname(directory.meta.pathname, collection); } } }, @@ -788,8 +767,6 @@ export const { deleteItem, renameItem, cloneItem, - moveItem, - moveItemToRootOfCollection, requestSent, requestCancelled, responseReceived, diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index 44cd2ed3f..d9923e827 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -1,4 +1,3 @@ -import reckon from 'reckonjs'; import get from 'lodash/get'; import each from 'lodash/each'; import find from 'lodash/find'; @@ -7,7 +6,14 @@ import isString from 'lodash/isString'; import map from 'lodash/map'; import filter from 'lodash/filter'; import sortBy from 'lodash/sortBy'; +import isEqual from 'lodash/isEqual'; +import cloneDeep from 'lodash/cloneDeep'; import { uuid } from 'utils/common'; +import path from 'path'; + +// although we are not using rekonjs directly +// its populating the global string prototype with .reckon method +import reckon from 'reckonjs'; const replaceTabsWithSpaces = (str, numSpaces = 2) => { if (!str || !str.length || !isString(str)) { @@ -128,6 +134,7 @@ export const moveCollectionItem = (collection, draggedItem, targetItem) => { if (draggedItemParent) { draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid); + draggedItem.pathname = path.join(draggedItemParent.pathname, draggedItem.filename); } else { collection.items = filter(collection.items, (i) => i.uid !== draggedItem.uid); } @@ -135,15 +142,18 @@ export const moveCollectionItem = (collection, draggedItem, targetItem) => { if (targetItem.type === 'folder') { targetItem.items = targetItem.items || []; targetItem.items.push(draggedItem); + draggedItem.pathname = path.join(targetItem.pathname, draggedItem.filename); } else { let targetItemParent = findParentItemInCollection(collection, targetItem.uid); if (targetItemParent) { 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 { 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); } } }; @@ -151,13 +161,46 @@ export const moveCollectionItem = (collection, draggedItem, targetItem) => { export const moveCollectionItemToRootOfCollection = (collection, draggedItem) => { let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid); - if (draggedItemParent) { - draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid); - } else { - collection.items = filter(collection.items, (i) => i.uid !== draggedItem.uid); + // If the dragged item is already at the root of the collection, do nothing + if(!draggedItemParent) { + return; } + draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid); collection.items.push(draggedItem); + 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 transformCollectionToSaveToIdb = (collection, options = {}) => { @@ -335,7 +378,6 @@ export const deleteItemInCollection = (itemUid, collection) => { collection.items = filter(collection.items, (i) => i.uid !== itemUid); let flattenedItems = flattenItems(collection.items); - each(flattenedItems, (i) => { if (i.items && i.items.length) { i.items = filter(i.items, (i) => i.uid !== itemUid); @@ -343,6 +385,17 @@ export const deleteItemInCollection = (itemUid, collection) => { }); }; +export const deleteItemInCollectionByPathname = (pathname, collection) => { + collection.items = filter(collection.items, (i) => i.pathname !== pathname); + + let flattenedItems = flattenItems(collection.items); + each(flattenedItems, (i) => { + if (i.items && i.items.length) { + i.items = filter(i.items, (i) => i.pathname !== pathname); + } + }); +}; + export const isItemARequest = (item) => { return item.hasOwnProperty('request') && ['http-request', 'graphql-request'].includes(item.type) && !item.items; }; @@ -449,6 +502,44 @@ export const interpolateEnvironmentVars = (item, variables) => { return request; }; +export const deleteUidsInItem = (item) => { + delete item.uid; + const params = get(item, 'request.params', []); + const headers = get(item, 'request.headers', []); + const bodyFormUrlEncoded = get(item, 'request.body.formUrlEncoded', []); + const bodyMultipartForm = get(item, 'request.body.multipartForm', []); + + params.forEach((param) => delete param.uid); + headers.forEach((header) => delete header.uid); + bodyFormUrlEncoded.forEach((param) => delete param.uid); + bodyMultipartForm.forEach((param) => delete param.uid); + + return item; +}; + +export const areItemsTheSameExceptSeqUpdate = (_item1, _item2) => { + let item1 = cloneDeep(_item1); + let item2 = cloneDeep(_item2); + + // remove seq from both items + delete item1.seq; + delete item2.seq; + + // remove draft from both items + delete item1.draft; + delete item2.draft; + + // get projection of both items + item1 = transformRequestToSaveToFilesystem(item1); + item2 = transformRequestToSaveToFilesystem(item2); + + // delete uids from both items + deleteUidsInItem(item1); + deleteUidsInItem(item2); + + return isEqual(item1, item2); +}; + export const getDefaultRequestPaneTab = (item) => { if(item.type === 'http-request') { return 'params'; diff --git a/packages/bruno-electron/src/app/watcher.js b/packages/bruno-electron/src/app/watcher.js index 068441001..1217fce25 100644 --- a/packages/bruno-electron/src/app/watcher.js +++ b/packages/bruno-electron/src/app/watcher.js @@ -10,7 +10,8 @@ const { envJsonToBru, } = require('@usebruno/bruno-lang'); const { itemSchema } = require('@usebruno/schema'); -const { generateUidBasedOnHash, uuid } = require('../utils/common'); +const { uuid } = require('../utils/common'); +const { getRequestUid } = require('../cache/requestUids'); const isJsonEnvironmentConfig = (pathname, collectionPath) => { const dirname = path.dirname(pathname); @@ -28,7 +29,7 @@ const isBruEnvironmentConfig = (pathname, collectionPath) => { }; const hydrateRequestWithUuid = (request, pathname) => { - request.uid = generateUidBasedOnHash(pathname); + request.uid = getRequestUid(pathname); const params = _.get(request, 'request.params', []); const headers = _.get(request, 'request.headers', []); @@ -57,7 +58,7 @@ const addEnvironmentFile = async (win, pathname, collectionUid) => { const bruContent = fs.readFileSync(pathname, 'utf8'); file.data = bruToEnvJson(bruContent); file.data.name = basename.substring(0, basename.length - 4); - file.data.uid = generateUidBasedOnHash(pathname); + file.data.uid = getRequestUid(pathname); _.each(_.get(file, 'data.variables', []), (variable) => variable.uid = uuid()); win.webContents.send('main:collection-tree-updated', 'addEnvironmentFile', file); @@ -80,7 +81,7 @@ const changeEnvironmentFile = async (win, pathname, collectionUid) => { const bruContent = fs.readFileSync(pathname, 'utf8'); file.data = bruToEnvJson(bruContent); file.data.name = basename.substring(0, basename.length - 4); - file.data.uid = generateUidBasedOnHash(pathname); + file.data.uid = getRequestUid(pathname); _.each(_.get(file, 'data.variables', []), (variable) => variable.uid = uuid()); // we are reusing the addEnvironmentFile event itself @@ -101,7 +102,7 @@ const unlinkEnvironmentFile = async (win, pathname, collectionUid) => { name: path.basename(pathname), }, data: { - uid: generateUidBasedOnHash(pathname), + uid: getRequestUid(pathname), name: path.basename(pathname).substring(0, path.basename(pathname).length - 4), } }; diff --git a/packages/bruno-electron/src/cache/requestUids.js b/packages/bruno-electron/src/cache/requestUids.js new file mode 100644 index 000000000..0a166b41a --- /dev/null +++ b/packages/bruno-electron/src/cache/requestUids.js @@ -0,0 +1,44 @@ +/** + * we maintain a cache of request uids to ensure that we + * preserve the same uid for a request even when the request + * moves to a different location + * + * In the past, we used to generate unique ids based on the + * pathname of the request, but we faced problems when implementing + * functionality where the user can move the request to a different + * location. In that case, the uid would change, and the we would + * lose the request's draft state if the user has made some changes + */ + +const requestUids = new Map(); +const { uuid } = require('../utils/common'); + +const getRequestUid = (pathname) => { + let uid = requestUids.get(pathname); + + if (!uid) { + uid = uuid(); + requestUids.set(pathname, uid); + } + + return uid; +}; + +const moveRequestUid = (oldPathname, newPathname) => { + const uid = requestUids.get(oldPathname); + + if (uid) { + requestUids.delete(oldPathname); + requestUids.set(newPathname, uid); + } +}; + +const deleteRequestUid = (pathname) => { + requestUids.delete(pathname); +}; + +module.exports = { + getRequestUid, + moveRequestUid, + deleteRequestUid +}; diff --git a/packages/bruno-electron/src/ipc/local-collection.js b/packages/bruno-electron/src/ipc/local-collection.js index 918d7aa0c..2bc9304f4 100644 --- a/packages/bruno-electron/src/ipc/local-collection.js +++ b/packages/bruno-electron/src/ipc/local-collection.js @@ -13,11 +13,13 @@ const { hasBruExtension, isDirectory, browseDirectory, - createDirectory + createDirectory, + searchForBruFiles } = require('../utils/filesystem'); -const { uuid, stringifyJson } = require('../utils/common'); +const { stringifyJson } = require('../utils/common'); const { openCollectionDialog, openCollection } = require('../app/collections'); const { generateUidBasedOnHash } = require('../utils/common'); +const { moveRequestUid, deleteRequestUid } = require('../cache/requestUids'); const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollections) => { // browse directory @@ -179,6 +181,12 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection // if its directory, rename and return if(isDirectory(oldPath)) { + const bruFilesAtSource = await searchForBruFiles(oldPath); + + for(let bruFile of bruFilesAtSource) { + const newBruFilePath = bruFile.replace(oldPath, newPath); + moveRequestUid(bruFile, newBruFilePath); + } return fs.renameSync(oldPath, newPath); } @@ -193,6 +201,8 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection jsonData.name = newName; + moveRequestUid(oldPath, newPath); + const content = jsonToBru(jsonData); await writeFile(newPath, content); await fs.unlinkSync(oldPath); @@ -218,9 +228,25 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection ipcMain.handle('renderer:delete-item', async (event, pathname, type) => { try { if(type === 'folder') { - await fs.rmSync(pathname, { recursive: true, force: true}); + if(!fs.existsSync(pathname)) { + return Promise.reject(new Error('The directory does not exist')); + } + + // delete the request uid mappings + const bruFilesAtSource = await searchForBruFiles(pathname); + for(let bruFile of bruFilesAtSource) { + deleteRequestUid(bruFile); + } + + fs.rmSync(pathname, { recursive: true, force: true}); } else if (['http-request', 'graphql-request'].includes(type)) { - await fs.unlinkSync(pathname); + if(!fs.existsSync(pathname)) { + return Promise.reject(new Error('The file does not exist')); + } + + deleteRequestUid(pathname); + + fs.unlinkSync(pathname); } else { return Promise.reject(error); } @@ -308,6 +334,63 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection } }); + ipcMain.handle('renderer:resequence-items', async (event, itemsToResequence) => { + try { + for(let item of itemsToResequence) { + const bru = fs.readFileSync(item.pathname, 'utf8'); + const jsonData = bruToJson(bru); + + if(jsonData.seq !== item.seq) { + jsonData.seq = item.seq; + const content = jsonToBru(jsonData); + await writeFile(item.pathname, content); + } + } + } catch (error) { + return Promise.reject(error); + } + }); + + ipcMain.handle('renderer:move-file-item', async (event, itemPath, destinationPath) => { + try { + const itemContent = fs.readFileSync(itemPath, 'utf8'); + const newItemPath = path.join(destinationPath, path.basename(itemPath)); + + moveRequestUid(itemPath, newItemPath); + + fs.unlinkSync(itemPath); + fs.writeFileSync(newItemPath, itemContent); + } catch (error) { + return Promise.reject(error); + } + }); + + ipcMain.handle('renderer:move-folder-item', async (event, folderPath, destinationPath) => { + try { + const folderName = path.basename(folderPath); + const newFolderPath = path.join(destinationPath, folderName); + + if(!fs.existsSync(folderPath)) { + throw new Error(`folder: ${folderPath} does not exist`); + } + + if(fs.existsSync(newFolderPath)) { + throw new Error(`folder: ${newFolderPath} already exists`); + } + + const bruFilesAtSource = await searchForBruFiles(folderPath); + + for(let bruFile of bruFilesAtSource) { + const newBruFilePath = bruFile.replace(folderPath, newFolderPath); + moveRequestUid(bruFile, newBruFilePath); + } + + fs.renameSync(folderPath, newFolderPath); + } catch (error) { + return Promise.reject(error); + } + }); + ipcMain.handle('renderer:ready', async (event) => { // reload last opened collections const lastOpened = lastOpenedCollections.getAll(); diff --git a/packages/bruno-electron/src/utils/filesystem.js b/packages/bruno-electron/src/utils/filesystem.js index 322ff2e00..33e64ee31 100644 --- a/packages/bruno-electron/src/utils/filesystem.js +++ b/packages/bruno-electron/src/utils/filesystem.js @@ -95,6 +95,25 @@ const browseDirectory = async (win) => { return isDirectory(resolvedPath) ? resolvedPath : false; }; +const searchForFiles = (dir, extension) => { + let results = []; + const files = fs.readdirSync(dir); + for (const file of files) { + const filePath = path.join(dir, file); + const stat = fs.statSync(filePath); + if (stat.isDirectory()) { + results = results.concat(searchForFiles(filePath, extension)); + } else if (path.extname(file) === extension) { + results.push(filePath); + } + } + return results; +} + +const searchForBruFiles = (dir) => { + return searchForFiles(dir, '.bru'); +}; + module.exports = { isValidPathname, exists, @@ -106,5 +125,7 @@ module.exports = { hasJsonExtension, hasBruExtension, createDirectory, - browseDirectory + browseDirectory, + searchForFiles, + searchForBruFiles }; diff --git a/packages/bruno-lang/src/index.js b/packages/bruno-lang/src/index.js index 2258262e7..12217d30f 100644 --- a/packages/bruno-lang/src/index.js +++ b/packages/bruno-lang/src/index.js @@ -44,6 +44,7 @@ const bruToJson = (fileContents) => { const json = { type: parsed.type || '', name: parsed.name || '', + seq: parsed.seq || 1, request: { method: parsed.method || '', url: parsed.url || '', @@ -78,6 +79,7 @@ const jsonToBru = (json) => { const { type, name, + seq, request: { method, url, @@ -92,6 +94,7 @@ method ${method} url ${url} type ${type} body-mode ${body ? body.mode : 'none'} +seq ${seq ? seq : 1} `; if(params && params.length) { diff --git a/packages/bruno-lang/src/inline-tag.js b/packages/bruno-lang/src/inline-tag.js index a16f3338d..03391e4c9 100644 --- a/packages/bruno-lang/src/inline-tag.js +++ b/packages/bruno-lang/src/inline-tag.js @@ -17,6 +17,7 @@ const inlineTag = sequenceOf([ str('name'), str('method'), str('url'), + str('seq'), str('body-mode') ]), whitespace, diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js index 22494d52b..2b3eb9986 100644 --- a/packages/bruno-schema/src/collections/index.js +++ b/packages/bruno-schema/src/collections/index.js @@ -58,6 +58,7 @@ const requestSchema = Yup.object({ const itemSchema = Yup.object({ uid: uidSchema, type: Yup.string().oneOf(['http-request', 'graphql-request', 'folder']).required('type is required'), + seq: Yup.number().min(1), name: Yup.string() .min(1, 'name must be atleast 1 characters') .max(50, 'name must be 100 characters or less') @@ -69,7 +70,7 @@ const itemSchema = Yup.object({ items: Yup.lazy(() => Yup.array().of(itemSchema)), filename: Yup.string().max(1024, 'filename cannot be more than 1024 characters').nullable(), pathname: Yup.string().max(1024, 'pathname cannot be more than 1024 characters').nullable() -}).noUnknown(true).strict(); +}).noUnknown(true); const collectionSchema = Yup.object({ version: Yup.string().oneOf(['1']).required('version is required'),