Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,12 @@ import {
} from 'src/selectors/tab';
import { isEqual } from 'lodash';
import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest';
import { calculateDraggedItemNewPathname, getInitialExampleName, findParentItemInCollection } from 'utils/collections/index';
import {
canCollectionItemBeDropped,
determineCollectionItemDrop,
getInitialExampleName,
findParentItemInCollection
} from 'utils/collections/index';
import { sortByNameThenSequence } from 'utils/common/index';
import { getRevealInFolderLabel } from 'utils/common/platform';
import CreateExampleModal from 'components/ResponseExample/CreateExampleModal';
Expand Down Expand Up @@ -130,13 +135,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
return false;
}, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] });

useKeybinding('newRequest', () => {
if (!isFolder) return false;
setNewRequestModalOpen(true);
return false;
}, { enabled: isKeyboardFocused && isFolder, deps: [isKeyboardFocused, isFolder] });

const [dropType, setDropType] = useState(null); // 'adjacent' or 'inside'
const [dropType, setDropType] = useState(null); // 'above', 'inside' or 'below'

const [{ isDragging }, drag, dragPreview] = useDrag({
type: 'collection-item',
Expand Down Expand Up @@ -164,55 +163,22 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
}
}, [isTabForItemActive]);

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;
}
};

// Which sidebar section an item belongs to. The sidebar renders these three
// sections in order (folders → apps → requests), each sorted by seq independently.
const getSidebarSection = (i) => {
if (isItemAFolder(i)) return 'folder';
if (i?.type === 'app') return 'app';
return 'request';
const resolveDropFromMonitor = (monitor) => {
return determineCollectionItemDrop({
item,
hoverBoundingRect: ref.current?.getBoundingClientRect(),
clientOffset: monitor.getClientOffset()
});
};

const canItemBeDropped = ({ draggedItem, targetItem, dropType }) => {
const { uid: targetItemUid, pathname: targetItemPathname } = targetItem;
const { uid: draggedItemUid, pathname: draggedItemPathname, sourceCollectionUid } = draggedItem;

if (draggedItemUid === targetItemUid) return false;

// The sidebar renders items grouped by section (folders → apps → requests) and
// sorts each section by seq independently. An 'adjacent' drop between two different
// sections could never move the item across sections visually, so reject it.
// 'inside' drops on a folder are unaffected (that's a directory move, not a reorder).
if (dropType === 'adjacent' && getSidebarSection(draggedItem) !== getSidebarSection(targetItem)) {
return false;
}

// For cross-collection moves, we allow the drop
if (sourceCollectionUid !== collectionUid) {
return true;
}

const newPathname = calculateDraggedItemNewPathname({ draggedItem, targetItem, dropType, collectionPathname });
if (!newPathname) return false;

if (targetItemPathname?.startsWith(draggedItemPathname)) return false;

return true;
return canCollectionItemBeDropped({
draggedItem,
targetItem,
dropType,
collectionUid,
collectionPathname
});
};

const [{ isOver, canDrop }, drop] = useDrop({
Expand All @@ -223,7 +189,11 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })

if (draggedItemUid === targetItemUid) return;

const dropType = determineDropType(monitor);
const dropType = resolveDropFromMonitor(monitor);
if (!dropType) {
setDropType(null);
return;
}

const _canItemBeDropped = canItemBeDropped({ draggedItem, targetItem: item, dropType });

Expand All @@ -235,19 +205,39 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })

if (draggedItemUid === targetItemUid) return;

const dropType = determineDropType(monitor);
const dropType = resolveDropFromMonitor(monitor);
if (!dropType) return;

if (!canItemBeDropped({ draggedItem, targetItem: item, dropType })) return;

await dispatch(handleCollectionItemDrop({ targetItem: item, draggedItem, dropType, collectionUid }));
await dispatch(handleCollectionItemDrop({
targetItem: item,
draggedItem,
dropType,
collectionUid
}));
setDropType(null);
},
canDrop: (draggedItem) => draggedItem.uid !== item.uid,
canDrop: (draggedItem, monitor) => {
if (draggedItem.uid === item.uid) return false;

const dropType = resolveDropFromMonitor(monitor);
if (!dropType) return false;

return canItemBeDropped({ draggedItem, targetItem: item, dropType });
},
collect: (monitor) => ({
isOver: monitor.isOver()
isOver: monitor.isOver(),
canDrop: monitor.canDrop()
})
});

useEffect(() => {
if (!isOver) {
setDropType(null);
}
}, [isOver]);

const iconClassName = classnames({
'rotate-90': !itemIsCollapsed
});
Expand All @@ -259,8 +249,9 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
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',
'drop-target': isOver && canDrop && dropType === 'inside',
'drop-target-above': isOver && canDrop && dropType === 'above',
'drop-target-below': isOver && canDrop && dropType === 'below',
'item-keyboard-focused': isKeyboardFocused
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ const Collection = ({ collection, searchText }) => {
setDropType('inside');
} else {
// For collections, show line indicator (adjacent drop)
setDropType('adjacent');
setDropType('above');
}
},
drop: (draggedItem, monitor) => {
Expand Down Expand Up @@ -326,7 +326,7 @@ const Collection = ({ collection, searchText }) => {
}

const collectionRowClassName = classnames('flex py-1 collection-name items-center', {
'item-hovered': isOver && dropType === 'adjacent', // For collection-to-collection moves (show line)
'item-hovered': isOver && dropType === 'above', // For collection-to-collection moves (show line)
'drop-target': isOver && dropType === 'inside', // For collection-item drops (highlight full area)
'collection-focused-in-tab': isCollectionFocused && !isKeyboardFocused,
'collection-keyboard-focused': isKeyboardFocused
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ import {
calculateDraggedItemNewPathname,
transformFolderRootToSave,
getTreePathFromCollectionToItem,
mergeHeaders
mergeHeaders,
isPathOrDescendant
} from 'utils/collections/index';
import { sanitizeName } from 'utils/common/regex';
import { applyScriptEnvVars, getScriptModifiedKeys } from 'utils/environments';
Expand Down Expand Up @@ -1267,7 +1268,7 @@ export const handleCollectionItemDrop
}

// Update sequences in the target directory (if dropping adjacent)
Comment thread
ravindra-bruno marked this conversation as resolved.
Outdated
if (dropType === 'adjacent') {
if (dropType === 'above' || dropType === 'below') {
Comment thread
ravindra-bruno marked this conversation as resolved.
Outdated
const targetItemSequence = targetItemDirectoryItems.find((i) => i.uid === targetItemUid)?.seq;

const draggedItemWithNewPathAndSequence = {
Expand All @@ -1280,7 +1281,8 @@ export const handleCollectionItemDrop
const reorderedTargetItems = getReorderedItemsInTargetDirectory({
items: [...targetItemDirectoryItems, draggedItemWithNewPathAndSequence],
targetItemUid,
draggedItemUid
draggedItemUid,
dropType
});

if (reorderedTargetItems?.length) {
Expand All @@ -1289,15 +1291,16 @@ export const handleCollectionItemDrop
}
};

const handleReorderInSameLocation = async ({ draggedItem, targetItem, targetItemDirectoryItems }) => {
const handleReorderInSameLocation = async ({ draggedItem, targetItem, targetItemDirectoryItems, dropType }) => {
const { uid: targetItemUid } = targetItem;
const { uid: draggedItemUid } = draggedItem;

// reorder items in the targetItem's directory
const reorderedItems = getReorderedItemsInTargetDirectory({
items: targetItemDirectoryItems,
targetItemUid,
draggedItemUid
draggedItemUid,
dropType
});

if (reorderedItems?.length) {
Expand All @@ -1314,7 +1317,7 @@ export const handleCollectionItemDrop
collectionPathname: collection.pathname
});
if (!newPathname) return;
if (targetItemPathname?.startsWith(draggedItemPathname)) return;
if (isPathOrDescendant(targetItemPathname, draggedItemPathname)) return;

if (isCrossFormatMove && isItemAFolder(draggedItem)) {
toast.error('Moving folders between collections with different formats is not supported');
Expand All @@ -1338,7 +1341,7 @@ export const handleCollectionItemDrop
dropType
});
} else {
await handleReorderInSameLocation({ draggedItem, targetItemDirectoryItems, targetItem });
await handleReorderInSameLocation({ draggedItem, targetItemDirectoryItems, targetItem, dropType });
}

if (isCrossCollectionMove) {
Expand Down
95 changes: 77 additions & 18 deletions packages/bruno-app/src/utils/collections/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { cloneDeep, isEqual, sortBy, filter, map, isString, findIndex, find, each, get } from 'lodash';
import { uuid } from 'utils/common';
import { sortByNameThenSequence } from 'utils/common/index';
import path from 'utils/common/path';
import path, { normalizePath } from 'utils/common/path';
import { isRequestTagsIncluded } from '@usebruno/common';

const replaceTabsWithSpaces = (str, numSpaces = 2) => {
Expand Down Expand Up @@ -1530,24 +1530,27 @@ export const calculateNewSequence = (isDraggedItem, targetSequence, draggedSeque
return targetSequence > draggedSequence ? targetSequence - 1 : targetSequence;
};

export const getReorderedItemsInTargetDirectory = ({ items, targetItemUid, draggedItemUid }) => {
export const getReorderedItemsInTargetDirectory = ({ items, targetItemUid, draggedItemUid, dropType = 'above' }) => {
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;
}
const sortedItems = [...itemsWithFixedSequences].sort((a, b) => a.seq - b.seq);

const targetIndex = sortedItems.findIndex((i) => i.uid === targetItemUid);
const draggedIndex = sortedItems.findIndex((i) => i.uid === draggedItemUid);

if (targetIndex === -1 || draggedIndex === -1) return [];

let newIndex = dropType === 'below' ? targetIndex + 1 : targetIndex;
if (draggedIndex < newIndex) {
newIndex -= 1;
}

const [draggedItem] = sortedItems.splice(draggedIndex, 1);
sortedItems.splice(newIndex, 0, draggedItem);

sortedItems.forEach((item, index) => {
item.seq = index + 1;
});
// only return items that have been reordered

return itemsWithFixedSequences.filter((item) =>
items?.find((originalItem) => originalItem?.uid === item?.uid)?.seq !== item?.seq
);
Expand All @@ -1569,12 +1572,68 @@ export const calculateDraggedItemNewPathname = ({ draggedItem, targetItem, dropT

if (dropType === 'inside' && (isTargetItemAFolder || isTargetTheCollection)) {
return path.join(targetItemPathname, draggedItemFilename);
} else if (dropType === 'adjacent') {
} else if (dropType === 'above' || dropType === 'below') {
return path.join(targetItemDirname, draggedItemFilename);
}
return null;
};

export const determineCollectionItemDrop = ({ item, hoverBoundingRect, clientOffset }) => {
if (!hoverBoundingRect || !clientOffset) return null;

const clientY = clientOffset.y - hoverBoundingRect.top;

if (isItemAFolder(item)) {
const folderUpperThreshold = hoverBoundingRect.height * 0.3;
const folderLowerThreshold = hoverBoundingRect.height * 0.7;

if (clientY < folderUpperThreshold) return 'above';
if (clientY > folderLowerThreshold) return 'below';
return 'inside';
}

const midpoint = hoverBoundingRect.height * 0.5;
return clientY < midpoint ? 'above' : 'below';
};

/**
* Separator-aware ancestry check between two filesystem pathnames.
*
* Returns true when `childPathname` is the same as, or a descendant of, `ancestorPathname`.
* Uses path-segment boundaries so siblings like "/users-archive" are NOT treated as
* descendants of "/users". Normalizes separators and trailing slashes for cross-platform safety.
*/
export const isPathOrDescendant = (childPathname, ancestorPathname) => {
if (!childPathname || !ancestorPathname) return false;
const child = normalizePath(childPathname);
const ancestor = normalizePath(ancestorPathname);
return child === ancestor || child.startsWith(`${ancestor}/`);
};

export const canCollectionItemBeDropped = ({
draggedItem,
targetItem,
dropType,
collectionUid,
collectionPathname
}) => {
const { uid: targetItemUid, pathname: targetItemPathname } = targetItem;
const { uid: draggedItemUid, pathname: draggedItemPathname, sourceCollectionUid } = draggedItem;

if (draggedItemUid === targetItemUid) return false;

if (sourceCollectionUid !== collectionUid) {
return true;
}

const newPathname = calculateDraggedItemNewPathname({ draggedItem, targetItem, dropType, collectionPathname });
if (!newPathname) return false;

if (isPathOrDescendant(targetItemPathname, draggedItemPathname)) return false;

return true;
};

// item sequence utils - END

export const getUniqueTagsFromItems = (items = []) => {
Expand Down
Loading
Loading