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 cfd236f8cb..2e3e5ee2f1 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 @@ -34,8 +34,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 8d61203e12..db19d2da32 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,66 @@ 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; + transition: ${(props) => props.theme.dragAndDrop.transition}; + 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 +105,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 a08fac3b7d..a25dc0115c 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 @@ -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 { moveItem, reorderAroundFolderItem, sendRequest, showInFolder } from 'providers/ReduxStore/slices/collections/actions'; import { collectionFolderClicked } from 'providers/ReduxStore/slices/collections'; import Dropdown from 'components/Dropdown'; import NewRequest from 'components/Sidebar/NewRequest'; @@ -32,7 +32,9 @@ const CollectionItem = ({ item, collection, searchText }) => { const activeTabUid = useSelector((state) => state.tabs.activeTabUid); 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,10 +46,13 @@ const CollectionItem = ({ item, collection, searchText }) => { const hasSearchText = searchText && searchText?.trim()?.length; const itemIsCollapsed = hasSearchText ? false : item.collapsed; + const isFolder = isItemAFolder(item); + + const [dropPosition, setDropPosition] = useState(null); // 'above', 'below', or 'inside' const [{ isDragging }, drag] = useDrag({ - type: `collection-item-${collection.uid}`, - item: item, + type: `COLLECTION_ITEM_${collection.uid}`, + item: () => item, collect: (monitor) => ({ isDragging: monitor.isDragging() }), @@ -56,21 +61,54 @@ 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 [{ isOver, canDrop }, drop] = useDrop({ + accept: `COLLECTION_ITEM_${collection.uid}`, + hover: (draggedItem, monitor) => { + if (draggedItem.uid !== item.uid) { + const hoverBoundingRect = ref.current?.getBoundingClientRect(); + const clientOffset = monitor.getClientOffset(); + if (hoverBoundingRect && clientOffset) { + // Get vertical middle and mouse position relative to the element + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const clientY = clientOffset.y - hoverBoundingRect.top; + + // Define drop zones - adjust the thresholds to make it easier to drop at the top + const upperThreshold = hoverBoundingRect.height * 0.35; // Increased from 0.25 + const lowerThreshold = hoverBoundingRect.height * 0.65; // Decreased from 0.75 + + // Determine drop position based on mouse location + if (clientY < upperThreshold) { + setDropPosition('above'); + } else if (clientY > lowerThreshold) { + setDropPosition('below'); + } else { + if (isFolder) { + setDropPosition('inside'); + } else { + setDropPosition(clientY < hoverMiddleY ? 'above' : 'below'); + } + } + } + } }, - canDrop: (draggedItem) => { - return draggedItem.uid !== item.uid; + drop: (draggedItem) => { + if (draggedItem.uid !== item.uid) { + if (isFolder && dropPosition === 'inside') { + // Move item inside folder + dispatch(moveItem(collection.uid, draggedItem.uid, item.uid)); + } else { + // Reorder above or below + dispatch(reorderAroundFolderItem(collection.uid, draggedItem.uid, item.uid, dropPosition)); + } + } + setDropPosition(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,9 +122,12 @@ const CollectionItem = ({ item, collection, searchText }) => { 'rotate-90': !itemIsCollapsed }); - const itemRowClassName = classnames('flex collection-item-name items-center', { + const itemRowClassName = classnames('flex collection-item-name relative items-center', { 'item-focused-in-tab': item.uid == activeTabUid, - 'item-hovered': isOver + 'item-hovered': isOver && canDrop, + 'drop-target': isOver && dropPosition === 'inside', + 'drop-target-above': isOver && dropPosition === 'above', + 'drop-target-below': isOver && dropPosition === 'below' }); const handleRun = async () => { @@ -98,23 +139,16 @@ const CollectionItem = ({ item, collection, searchText }) => { }; const handleClick = (event) => { - if (event.detail != 1) return; - //scroll to the active tab + if (event.detail !== 1) return; + // Scroll to the active tab. setTimeout(scrollToTheActiveTab, 50); - const isRequest = isItemARequest(item); - if (isRequest) { dispatch(hideHomePage()); if (itemIsOpenedInTabs(item, tabs)) { - dispatch( - focusTab({ - uid: item.uid - }) - ); + dispatch(focusTab({ uid: item.uid })); return; } - dispatch( addTab({ uid: item.uid, @@ -149,7 +183,7 @@ const CollectionItem = ({ item, collection, searchText }) => { collectionUid: collection.uid }) ); - } + }; const handleRightClick = (event) => { const _menuDropdown = dropdownTippyRef.current; @@ -164,7 +198,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,22 +216,30 @@ 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 + // Sort items by their "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 handleShowInFolder = () => { + dispatch(showInFolder(item.pathname)).catch((error) => { + console.error('Error opening the folder', error); + toast.error('Error opening the folder'); + }); }; + + const items = sortRequestItems(filter(item.items, (i) => isItemARequest(i) || isItemAFolder(i))); + const handleGenerateCode = (e) => { e.stopPropagation(); dropdownTippyRef.current.hide(); - if (item?.request?.url !== '' || (item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '')) { + if ( + (item?.request?.url !== '') || + (item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '') + ) { setGenerateCodeItemModalOpen(true); } else { toast.error('URL is required'); @@ -208,11 +249,7 @@ const CollectionItem = ({ item, collection, searchText }) => { const viewFolderSettings = () => { if (isItemAFolder(item)) { if (itemIsOpenedInTabs(item, tabs)) { - dispatch( - focusTab({ - uid: item.uid - }) - ); + dispatch(focusTab({ uid: item.uid })); return; } dispatch( @@ -222,20 +259,9 @@ const CollectionItem = ({ item, collection, searchText }) => { type: 'folder-settings' }) ); - return; } }; - const handleShowInFolder = () => { - dispatch(showInFolder(item.pathname)).catch((error) => { - console.error('Error opening the folder', error); - toast.error('Error opening the folder'); - }); - }; - - const requestItems = sortRequestItems(filter(item.items, (i) => isItemARequest(i))); - const folderItems = sortFolderItems(filter(item.items, (i) => isItemAFolder(i))); - return ( {renameItemModalOpen && ( @@ -259,33 +285,31 @@ const CollectionItem = ({ item, collection, searchText }) => { {generateCodeItemModalOpen && ( setGenerateCodeItemModalOpen(false)} /> )} -
+
{ + ref.current = node; + drag(drop(node)); + }} + >
{indents && indents.length - ? indents.map((i) => { - return ( -
-  {/* Indent */} -
- ); - }) + ? indents.map((i) => ( +
+  {/* Indent */} +
+ )) : null}
{ /> ) : null}
- -
+
{item.name} @@ -417,18 +438,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
- {!itemIsCollapsed ? (
- {folderItems && folderItems.length - ? folderItems.map((i) => { - return ; - }) - : null} - {requestItems && requestItems.length - ? requestItems.map((i) => { - return ; - }) + {items && items.length + ? items.map((i) => ) : null}
) : null} 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 5c06cc42ab..0378d9ad9d 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 f21f25ac39..4600dc5009 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js @@ -30,6 +30,10 @@ const Collection = ({ collection, searchText }) => { const [showCloneCollectionModalOpen, setShowCloneCollectionModalOpen] = useState(false); const [showExportCollectionModal, setShowExportCollectionModal] = useState(false); const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false); + // const [collectionIsCollapsed, setCollectionIsCollapsed] = useState(collection.collapsed); + const collectionIsCollapsed = Boolean(collection.collapsed); + const [isDropTarget, setIsDropTarget] = useState(false); + const [dropPosition, setDropPosition] = useState(null); const tabs = useSelector((state) => state.tabs.tabs); const dispatch = useDispatch(); const isLoading = areItemsLoading(collection); @@ -66,7 +70,6 @@ const Collection = ({ collection, searchText }) => { } const hasSearchText = searchText && searchText?.trim()?.length; - const collectionIsCollapsed = hasSearchText ? false : collection.collapsed; const iconClassName = classnames({ 'rotate-90': !collectionIsCollapsed @@ -124,11 +127,10 @@ const Collection = ({ collection, searchText }) => { }) ); }; - const isCollectionItem = (itemType) => { return itemType.startsWith('collection-item'); }; - + const [{ isDragging }, drag] = useDrag({ type: "collection", item: collection, @@ -140,36 +142,64 @@ const Collection = ({ collection, searchText }) => { } }); - const [{ isOver }, drop] = useDrop({ - accept: ["collection", `collection-item-${collection.uid}`], - drop: (draggedItem, monitor) => { - const itemType = monitor.getItemType(); - if (isCollectionItem(itemType)) { - dispatch(moveItemToRootOfCollection(collection.uid, draggedItem.uid)) - } else { - dispatch(moveCollectionAndPersist({draggedItem, targetItem: collection})); + const [{ isOver, canDrop }, drop] = useDrop({ + accept: `COLLECTION_ITEM_${collection.uid}`, + hover: (draggedItem, monitor) => { + const hoverBoundingRect = collectionRef.current?.getBoundingClientRect(); + const clientOffset = monitor.getClientOffset(); + + if (hoverBoundingRect && clientOffset) { + const hoverY = clientOffset.y - hoverBoundingRect.top; + // Show drop target styling for the entire collection name area + setIsDropTarget(true); + // Set drop position based on hover location + if (hoverY < hoverBoundingRect.height / 2) { + setDropPosition('above'); + } else { + setDropPosition('below'); + } } }, - canDrop: (draggedItem) => { - return draggedItem.uid !== collection.uid; + drop: (draggedItem, monitor) => { + const hoverBoundingRect = collectionRef.current?.getBoundingClientRect(); + const clientOffset = monitor.getClientOffset(); + + if (hoverBoundingRect && clientOffset) { + const hoverY = clientOffset.y - hoverBoundingRect.top; + if (hoverY < hoverBoundingRect.height) { + dispatch(moveItemToRootOfCollection(collection.uid, draggedItem.uid)); + } + } + setIsDropTarget(false); + setDropPosition(null); }, collect: (monitor) => ({ isOver: monitor.isOver(), - }), + canDrop: monitor.canDrop() + }) }); drag(drop(collectionRef)); + // Clean up drop target state when drag ends + useEffect(() => { + if (!isOver) { + setIsDropTarget(false); + } + }, [isOver]); + + const collectionRowClassName = classnames('flex py-1 collection-name items-center', { + 'drop-target': isDropTarget && isOver, + 'drop-target-above': isOver && dropPosition === 'above', + 'drop-target-below': isOver && dropPosition === 'below' + }); + if (searchText && searchText.length) { if (!doesCollectionHaveItemsMatchingSearchText(collection, searchText)) { return null; } } - const collectionRowClassName = classnames('flex py-1 collection-name items-center', { - 'item-hovered': isOver - }); - // we need to sort request items by seq property const sortRequestItems = (items = []) => { return items.sort((a, b) => a.seq - b.seq); @@ -180,8 +210,7 @@ const Collection = ({ collection, searchText }) => { 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 items = sortRequestItems(filter(collection.items, (i) => isItemARequest(i) || isItemAFolder(i))); return ( @@ -199,8 +228,12 @@ const Collection = ({ collection, searchText }) => { {showCloneCollectionModalOpen && ( setShowCloneCollectionModalOpen(false)} /> )} -
{ + collectionRef.current = node; + drop(node); + }} >
{
{!collectionIsCollapsed ? (
- {folderItems && folderItems.length - ? folderItems.map((i) => { - return ; - }) - : null} - {requestItems && requestItems.length - ? requestItems.map((i) => { + {items && items.length + ? items.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 c2532b3fd3..c6e245fde1 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -46,8 +46,8 @@ import { resolveRequestFilename } from 'utils/common/platform'; import { parsePathParams, parseQueryParams, splitOnFirst } from 'utils/url/index'; import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index'; import slash from 'utils/common/slash'; -import { getGlobalEnvironmentVariables } from 'utils/collections/index'; -import { findCollectionByPathname, findEnvironmentInCollectionByName } from 'utils/collections/index'; +import { getGlobalEnvironmentVariables, moveCollectionItemToFolder, findCollectionByPathname, findEnvironmentInCollectionByName } from 'utils/collections/index'; +import { updateCollectionItemsOrder } from './index'; export const renameCollection = (newName, collectionUid) => (dispatch, getState) => { const state = getState(); @@ -166,6 +166,8 @@ export const saveFolderRoot = (collectionUid, folderUid) => (dispatch, getState) const { ipcRenderer } = window; + folder?.root?.meta?.seq && (folder.root.meta.seq = folder?.seq); + const folderData = { name: folder.name, pathname: folder.pathname, @@ -346,6 +348,8 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive, delay) export const newFolder = (folderName, 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) { @@ -360,10 +364,22 @@ export const newFolder = (folderName, collectionUid, itemUid) => (dispatch, getS if (!folderWithSameNameExists) { const fullName = `${collection.pathname}${PATH_SEPARATOR}${folderName}`; const { ipcRenderer } = window; - ipcRenderer .invoke('renderer:new-folder', fullName) - .then(() => resolve()) + .then(async () => { + const folderData = { + name: folderName, + pathname: fullName, + root: { meta: { 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')); @@ -381,7 +397,20 @@ export const newFolder = (folderName, collectionUid, itemUid) => (dispatch, getS ipcRenderer .invoke('renderer:new-folder', fullName) - .then(() => resolve()) + .then(async () => { + const folderData = { + name: folderName, + pathname: fullName, + root: { meta: { 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')); @@ -622,7 +651,7 @@ export const moveItem = (collectionUid, draggedItemUid, targetItemUid) => (dispa // file item dragged into another folder if (isItemARequest(draggedItem) && isItemAFolder(targetItem) && draggedItemParent !== targetItem) { const draggedItemPathname = draggedItem.pathname; - moveCollectionItem(collectionCopy, draggedItem, targetItem); + moveCollectionItemToFolder(collectionCopy, draggedItem, targetItem); const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy); const itemsToResequence2 = getItemsToResequence(targetItem, collectionCopy); @@ -645,7 +674,13 @@ export const moveItem = (collectionUid, draggedItemUid, targetItemUid) => (dispa // 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(); + moveCollectionItem(collectionCopy, draggedItem, targetItem); + const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy); + + return ipcRenderer + .invoke('renderer:resequence-items', itemsToResequence) + .then(resolve) + .catch((error) => reject(error)); } // folder dragged into a file which is a child of the folder @@ -653,6 +688,21 @@ export const moveItem = (collectionUid, draggedItemUid, targetItemUid) => (dispa return resolve(); } + // folder dragged into a file which is in a different folder + if (isItemAFolder(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-folder-item', draggedItemPathname, targetItemParent.pathname) + .then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence)) + .then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2)) + .then(resolve) + .catch((error) => reject(error)); + } + // folder dragged into a file which is at the root level if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && !targetItemParent) { const draggedItemPathname = draggedItem.pathname; @@ -666,15 +716,105 @@ export const moveItem = (collectionUid, draggedItemUid, targetItemUid) => (dispa // folder dragged into another folder if (isItemAFolder(draggedItem) && isItemAFolder(targetItem) && draggedItemParent !== targetItem) { const draggedItemPathname = draggedItem.pathname; + moveCollectionItemToFolder(collectionCopy, draggedItem, targetItem); + const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy); + const itemsToResequence2 = getItemsToResequence(targetItem, collectionCopy); return ipcRenderer .invoke('renderer:move-folder-item', draggedItemPathname, targetItem.pathname) + .then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence)) + .then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2)) .then(resolve) .catch((error) => reject(error)); } }); }; +// cases when the target item is a folder and we want to reorder dragged files and folders around it +export const reorderAroundFolderItem = (collectionUid, draggedItemUid, targetItemUid, dropPosition) => (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); + 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; + + // Helper function to prepare items for resequencing + const prepareItemsForResequence = (items = []) => { + return items.map((item, index) => ({ + uid: item.uid, + pathname: item.pathname, + type: item.type, + seq: index + 1 + })); + }; + + const moveCollectionItemWithPosition = (collection, draggedItem, targetItem, position) => { + // items comes from either the parent folder or the root collection + let items = draggedItemParent ? draggedItemParent.items : collection.items; + + // Ensure items are sorted by seq so the indexes match the on-screen order + items = items.slice().sort((a, b) => (a.seq || 0) - (b.seq || 0)); + + const targetIndex = items.findIndex(i => i.uid === targetItem.uid); + const draggedIndex = items.findIndex(i => i.uid === draggedItem.uid); + + items.splice(draggedIndex, 1); + + let newIndex = position === 'above' ? targetIndex : targetIndex + 1; + if (draggedIndex < targetIndex) { + newIndex--; + } + + items.splice(newIndex, 0, draggedItem); + + return prepareItemsForResequence(items); + }; + + try { + // Same parent case + if (sameParent) { + const itemsToResequence = moveCollectionItemWithPosition( + collectionCopy, + draggedItem, + targetItem, + dropPosition + ); + + return ipcRenderer + .invoke('renderer:resequence-items', itemsToResequence) + .then(() => { + dispatch(updateCollectionItemsOrder({ collectionUid, newOrder: collectionCopy })); + resolve(); + }) + .catch((error) => { + reject(error); + }); + } + + + } catch (error) { + reject(error); + } + }); +}; + export const moveItemToRootOfCollection = (collectionUid, draggedItemUid) => (dispatch, getState) => { const state = getState(); const collection = findCollectionByUid(state.collections.collections, collectionUid); @@ -775,8 +915,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => { collection.items, (i) => i.type !== 'folder' && trim(i.filename) === trim(filename) ); - 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 = `${collection.pathname}${PATH_SEPARATOR}${filename}`; @@ -802,8 +942,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => { currentItem.items, (i) => i.type !== 'folder' && trim(i.filename) === trim(filename) ); - 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 = `${currentItem.pathname}${PATH_SEPARATOR}${filename}`; 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 905576a2ff..8b7ef37c8a 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -56,6 +56,12 @@ export const collectionsSlice = createSlice({ state.collections.push(collection); } }, + updateCollectionItemsOrder: (state, action) => { + const { collectionUid, newOrder } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + if (!collection) return; + collection.items = newOrder.items; + }, updateCollectionMountStatus: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); if (collection) { @@ -1658,6 +1664,7 @@ export const collectionsSlice = createSlice({ const folderItem = findItemInCollectionByPathname(collection, folderPath); if (folderItem) { folderItem.root = file.data; + folderItem.seq = file.data?.meta?.seq; } return; } @@ -1753,6 +1760,7 @@ export const collectionsSlice = createSlice({ collectionChangeFileEvent: (state, action) => { const { file } = action.payload; const collection = findCollectionByUid(state.collections, file.meta.collectionUid); + const isFolderRoot = file?.meta?.folderRoot ? true : false; // check and update collection root if (collection && file.meta.collectionRoot) { @@ -1760,6 +1768,16 @@ export const collectionsSlice = createSlice({ return; } + if (isFolderRoot) { + const folderPath = path.dirname(file?.meta?.pathname); + const folderItem = findItemInCollectionByPathname(collection, folderPath); + if (folderItem) { + folderItem.root = file.data; + folderItem.seq = file?.data?.meta?.seq; + } + return; + } + if (collection) { const item = findItemInCollection(collection, file.data.uid); @@ -1993,11 +2011,12 @@ export const collectionsSlice = createSlice({ } } } - } + }, }); export const { createCollection, + updateCollectionItemsOrder, updateCollectionMountStatus, setCollectionSecurityConfig, brunoConfigUpdateEvent, diff --git a/packages/bruno-app/src/themes/dark.js b/packages/bruno-app/src/themes/dark.js index a47abb8d24..e45f12610a 100644 --- a/packages/bruno-app/src/themes/dark.js +++ b/packages/bruno-app/src/themes/dark.js @@ -279,6 +279,13 @@ const darkTheme = { scrollbar: { color: 'rgb(52 51 49)' + }, + + dragAndDrop: { + border: '#666666', + borderStyle: '2px solid', + hoverBg: 'rgba(102, 102, 102, 0.08)', + transition: 'all 0.1s ease' } }; diff --git a/packages/bruno-app/src/themes/light.js b/packages/bruno-app/src/themes/light.js index 9d34398950..22f23a7d67 100644 --- a/packages/bruno-app/src/themes/light.js +++ b/packages/bruno-app/src/themes/light.js @@ -280,6 +280,13 @@ const lightTheme = { scrollbar: { 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' } }; diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index 3ac612c629..5e949063f0 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -157,31 +157,46 @@ export const moveCollectionItem = (collection, draggedItem, targetItem) => { 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); + draggedItem.pathname = path.join(draggedItemParent.pathname, path.basename(draggedItem.pathname)); } 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); + 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, path.basename(draggedItem.pathname)); } else { - let targetItemParent = findParentItemInCollection(collection, targetItem.uid); + 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, path.basename(draggedItem.pathname)); + } +}; - 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 moveCollectionItemToFolder = (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, path.basename(draggedItem.pathname)); + } else { + collection.items = sortBy(collection.items, (item) => item.seq); + collection.items = filter(collection.items, (i) => i.uid !== draggedItem.uid); } + + targetItem.items = sortBy(targetItem.items || [], (item) => item.seq); + draggedItem.seq = -1; + targetItem.items.splice(0, 0, draggedItem); + targetItem.items = targetItem.items?.map((item, index) => { + item.seq = index + 1; + return item; + }); + draggedItem.pathname = path.join(targetItem.pathname, path.basename(draggedItem.pathname)); }; export const moveCollectionItemToRootOfCollection = (collection, draggedItem) => { @@ -209,12 +224,11 @@ export const getItemsToResequence = (parent, collection) => { if (!parent) { let index = 1; each(collection.items, (item) => { - if (isItemARequest(item)) { - itemsToResequence.push({ - pathname: item.pathname, - seq: index++ - }); - } + itemsToResequence.push({ + pathname: item.pathname, + seq: index++, + type: isItemAFolder(item) ? 'folder' : 'request' + }); }); return itemsToResequence; } @@ -222,12 +236,11 @@ export const getItemsToResequence = (parent, collection) => { if (parent.items && parent.items.length) { let index = 1; each(parent.items, (item) => { - if (isItemARequest(item)) { - itemsToResequence.push({ - pathname: item.pathname, - seq: index++ - }); - } + itemsToResequence.push({ + pathname: item.pathname, + seq: index++, + type: isItemAFolder(item) ? 'folder' : 'request' + }); }); return itemsToResequence; } diff --git a/packages/bruno-electron/src/app/watcher.js b/packages/bruno-electron/src/app/watcher.js index b2b60fd554..c99693a71e 100644 --- a/packages/bruno-electron/src/app/watcher.js +++ b/packages/bruno-electron/src/app/watcher.js @@ -2,8 +2,8 @@ const _ = require('lodash'); const fs = require('fs'); const path = require('path'); const chokidar = require('chokidar'); +const { bruToEnvJson, bruToJson, collectionBruToJson, jsonToCollectionBru, bruToJsonViaWorker } = require('../bru'); const { hasBruExtension, isWSLPath, normalizeAndResolvePath, normalizeWslPath, sizeInMB } = require('../utils/filesystem'); -const { bruToEnvJson, bruToJson, bruToJsonViaWorker ,collectionBruToJson } = require('../bru'); const { dotenvToJson } = require('@usebruno/lang'); const { uuid } = require('../utils/common'); @@ -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: { @@ -319,7 +318,7 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread } }; -const addDirectory = (win, pathname, collectionUid, collectionPath) => { +const addDirectory = async (win, pathname, collectionUid, collectionPath) => { const envDirectory = path.join(collectionPath, 'environments'); if (pathname === envDirectory) { @@ -333,6 +332,19 @@ const addDirectory = (win, pathname, collectionUid, collectionPath) => { name: path.basename(pathname) } }; + + const folderBruFilePath = path.join(pathname, 'folder.bru'); + if (!fs.existsSync(folderBruFilePath)) { + let folderData = { + meta: { + name: path.basename(pathname), + seq: 0 + } + }; + const content = await jsonToCollectionBru(folderData); + fs.writeFileSync(folderBruFilePath, content); + } + win.webContents.send('main:collection-tree-updated', 'addDir', directory); }; @@ -399,6 +411,30 @@ const change = async (win, pathname, collectionUid, collectionPath) => { } } + if (path.basename(pathname) === 'folder.bru') { + const file = { + meta: { + collectionUid, + pathname, + name: path.basename(pathname), + folderRoot: true + } + }; + + try { + let bruContent = fs.readFileSync(pathname, 'utf8'); + + file.data = await collectionBruToJson(bruContent); + + hydrateBruCollectionFileWithUuid(file.data); + win.webContents.send('main:collection-tree-updated', 'change', file); + return; + } catch (err) { + console.error(err); + return; + } + } + if (hasBruExtension(pathname)) { try { const file = { diff --git a/packages/bruno-electron/src/bru/index.js b/packages/bruno-electron/src/bru/index.js index a641a95a76..5162b175d1 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 }; } @@ -60,9 +62,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 43a3a12830..17fd7bcf09 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -4,7 +4,7 @@ const fsExtra = require('fs-extra'); const os = require('os'); const path = require('path'); const { ipcMain, shell, dialog, app } = require('electron'); -const { envJsonToBru, bruToJson, jsonToBruViaWorker, jsonToCollectionBru, bruToJsonViaWorker } = require('../bru'); +const { envJsonToBru, bruToJson, jsonToBruViaWorker, jsonToCollectionBru, bruToJsonViaWorker, jsonToBru } = require('../bru'); const { isValidPathname, @@ -31,6 +31,7 @@ const { generateUidBasedOnHash, stringifyJson, safeParseJSON, safeStringifyJSON const { moveRequestUid, deleteRequestUid } = require('../cache/requestUids'); const { deleteCookiesForDomain, getDomainsWithCookies } = require('../utils/cookies'); const EnvironmentSecretsStore = require('../store/env-secrets'); +const { collectionBruToJson } = require('@usebruno/lang'); const CollectionSecurityStore = require('../store/collection-security'); const UiStateSnapshotStore = require('../store/ui-state-snapshot'); const { parseBruFileMeta, hydrateRequestWithUuid } = require('../utils/collection'); @@ -192,7 +193,8 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection const folderBruFilePath = path.join(folderPathname, 'folder.bru'); folderRoot.meta = { - name: folderName + name: folderName, + ...(folderRoot.meta || {}) }; const content = await jsonToCollectionBru( @@ -674,17 +676,35 @@ 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'); + if (fs.existsSync(folderRootPath)) { + const bru = fs.readFileSync(folderRootPath, 'utf8'); + const jsonData = await collectionBruToJson(bru); + + if (jsonData?.meta?.seq !== item.seq) { + jsonData.meta.seq = item.seq; + const content = await jsonToCollectionBru(jsonData); + await writeFile(folderRootPath, content); + } + } + } else { + if (fs.existsSync(item.pathname)) { + 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); + } + } } } + return true; } catch (error) { + console.error('Error in resequence-items:', error); return Promise.reject(error); } });