Better performance on large workspaces

This commit is contained in:
Gregory Schier
2024-06-21 10:53:11 -07:00
parent 5722880890
commit 151450f55b
3 changed files with 231 additions and 230 deletions

View File

@@ -258,7 +258,7 @@ function SidebarButton({
</div>
{environment != null && (
<ContextMenu
show={showContextMenu}
triggerPosition={showContextMenu}
onClose={() => setShowContextMenu(null)}
items={[
{

View File

@@ -1,6 +1,6 @@
import classNames from 'classnames';
import type { ForwardedRef, ReactNode } from 'react';
import React, { forwardRef, Fragment, useCallback, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import React, { Fragment, useCallback, useMemo, useRef, useState } from 'react';
import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd';
import { useKey, useKeyPressEvent } from 'react-use';
@@ -299,7 +299,7 @@ export function Sidebar({ className }: Props) {
[hasFocus, selectableRequests, selectedId, setSelectedId, setSelectedTree],
);
const handleMove = useCallback<DraggableSidebarItemProps['onMove']>(
const handleMove = useCallback<SidebarItemProps['onMove']>(
(id, side) => {
let hoveredTree = treeParentMap[id] ?? null;
const dragIndex = hoveredTree?.children.findIndex((n) => n.item.id === id) ?? -99;
@@ -318,11 +318,11 @@ export function Sidebar({ className }: Props) {
[isCollapsed, treeParentMap],
);
const handleDragStart = useCallback<DraggableSidebarItemProps['onDragStart']>((id: string) => {
const handleDragStart = useCallback<SidebarItemProps['onDragStart']>((id: string) => {
setDraggingId(id);
}, []);
const handleEnd = useCallback<DraggableSidebarItemProps['onEnd']>(
const handleEnd = useCallback<SidebarItemProps['onEnd']>(
async (itemId) => {
setHoveredTree(null);
handleClearSelected();
@@ -436,7 +436,7 @@ export function Sidebar({ className }: Props) {
)}
>
<ContextMenu
show={showMainContextMenu}
triggerPosition={showMainContextMenu}
items={mainContextMenuItems}
onClose={() => setShowMainContextMenu(null)}
/>
@@ -511,8 +511,7 @@ function SidebarItems({
return (
<Fragment key={child.item.id}>
{hoveredIndex === i && hoveredTree?.item.id === tree.item.id && <DropMarker />}
<DraggableSidebarItem
draggable
<SidebarItem
selected={selected}
itemId={child.item.id}
itemName={child.item.name}
@@ -558,7 +557,7 @@ function SidebarItems({
handleDragStart={handleDragStart}
/>
)}
</DraggableSidebarItem>
</SidebarItem>
</Fragment>
);
})}
@@ -579,28 +578,74 @@ type SidebarItemProps = {
useProminentStyles?: boolean;
selected?: boolean;
draggable?: boolean;
onMove: (id: string, side: 'above' | 'below') => void;
onEnd: (id: string) => void;
onDragStart: (id: string) => void;
children?: ReactNode;
child: TreeNode;
} & Pick<SidebarItemsProps, 'isCollapsed' | 'onSelect'>;
const SidebarItem = forwardRef(function SidebarItem(
{
children,
className,
itemName,
itemFallbackName,
itemId,
itemModel,
itemPrefix,
useProminentStyles,
selected,
onSelect,
isCollapsed,
child,
draggable,
}: SidebarItemProps,
ref: ForwardedRef<HTMLLIElement>,
) {
type DragItem = {
id: string;
itemName: string;
};
function SidebarItem({
itemName,
itemId,
itemModel,
child,
onMove,
onEnd,
onDragStart,
onSelect,
isCollapsed,
itemPrefix,
className,
selected,
itemFallbackName,
useProminentStyles,
children,
}: SidebarItemProps) {
const ref = useRef<HTMLLIElement>(null);
const [, connectDrop] = useDrop<DragItem, void>(
{
accept: ItemTypes.REQUEST,
hover: (_, monitor) => {
if (!ref.current) return;
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset();
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
onMove(itemId, hoverClientY < hoverMiddleY ? 'above' : 'below');
},
},
[onMove],
);
const [, connectDrag] = useDrag<
DragItem,
unknown,
{
isDragging: boolean;
}
>(
() => ({
type: ItemTypes.REQUEST,
item: () => {
onDragStart(itemId);
return { id: itemId, itemName };
},
collect: (m) => ({ isDragging: m.isDragging() }),
options: { dropEffect: 'move' },
end: () => onEnd(itemId),
}),
[onEnd],
);
connectDrag(connectDrop(ref));
const activeRequest = useActiveRequest();
const deleteFolder = useDeleteFolder(itemId);
const deleteRequest = useDeleteRequest(itemId);
@@ -673,134 +718,163 @@ const SidebarItem = forwardRef(function SidebarItem(
y: number;
} | null>(null);
const handleCloseContextMenu = useCallback(() => {
setShowContextMenu(null);
}, []);
const handleContextMenu = useCallback((e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
setShowContextMenu({ x: e.clientX, y: e.clientY });
}, []);
const items = useMemo<DropdownItem[]>(() => {
if (itemModel === 'folder') {
return [
{
key: 'sendAll',
label: 'Send All',
leftSlot: <Icon icon="sendHorizontal" />,
onSelect: () => sendManyRequests.mutate(child.children.map((c) => c.item.id)),
},
{
key: 'rename',
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
onSelect: async () => {
const name = await prompt({
id: 'rename-folder',
title: 'Rename Folder',
description: (
<>
Enter a new name for <InlineCode>{itemName}</InlineCode>
</>
),
name: 'name',
label: 'Name',
placeholder: 'New Name',
defaultValue: itemName,
});
updateAnyFolder.mutate({ id: itemId, update: (f) => ({ ...f, name }) });
},
},
{
key: 'deleteFolder',
label: 'Delete',
variant: 'danger',
leftSlot: <Icon icon="trash" />,
onSelect: () => deleteFolder.mutate(),
},
{ type: 'separator' },
...createDropdownItems,
];
} else {
const requestItems: DropdownItem[] =
itemModel === 'http_request'
? [
{
key: 'sendRequest',
label: 'Send',
hotKeyAction: 'http_request.send',
hotKeyLabelOnly: true, // Already bound in URL bar
leftSlot: <Icon icon="sendHorizontal" />,
onSelect: () => sendRequest.mutate(itemId),
},
{
key: 'copyCurl',
label: 'Copy as Curl',
leftSlot: <Icon icon="copy" />,
onSelect: copyAsCurl,
},
{ type: 'separator' },
]
: [];
return [
...requestItems,
{
key: 'renameRequest',
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
onSelect: async () => {
const name = await prompt({
id: 'rename-request',
title: 'Rename Request',
description:
itemName === '' ? (
'Enter a new name'
) : (
<>
Enter a new name for <InlineCode>{itemName}</InlineCode>
</>
),
name: 'name',
label: 'Name',
placeholder: 'New Name',
defaultValue: itemName,
});
if (itemModel === 'http_request') {
updateHttpRequest.mutate({ id: itemId, update: (r) => ({ ...r, name }) });
} else {
updateGrpcRequest.mutate({ id: itemId, update: (r) => ({ ...r, name }) });
}
},
},
{
key: 'duplicateRequest',
label: 'Duplicate',
hotKeyAction: 'http_request.duplicate',
hotKeyLabelOnly: true, // Would trigger for every request (bad)
leftSlot: <Icon icon="copy" />,
onSelect: () => {
itemModel === 'http_request'
? duplicateHttpRequest.mutate()
: duplicateGrpcRequest.mutate();
},
},
{
key: 'moveWorkspace',
label: 'Change Workspace',
leftSlot: <Icon icon="house" />,
hidden: workspaces.length <= 1,
onSelect: moveToWorkspace.mutate,
},
{
key: 'deleteRequest',
variant: 'danger',
label: 'Delete',
leftSlot: <Icon icon="trash" />,
onSelect: () => deleteRequest.mutate(),
},
];
}
}, [
child.children,
copyAsCurl,
createDropdownItems,
deleteFolder,
deleteRequest,
duplicateGrpcRequest,
duplicateHttpRequest,
itemId,
itemModel,
itemName,
moveToWorkspace.mutate,
prompt,
sendManyRequests,
sendRequest,
updateAnyFolder,
updateGrpcRequest,
updateHttpRequest,
workspaces.length,
]);
return (
<li ref={ref} draggable={draggable}>
<li ref={ref} draggable>
<div className={classNames(className, 'block relative group/item px-1.5 pb-0.5')}>
<ContextMenu
show={showContextMenu}
items={
itemModel === 'folder'
? [
{
key: 'sendAll',
label: 'Send All',
leftSlot: <Icon icon="sendHorizontal" />,
onSelect: () => sendManyRequests.mutate(child.children.map((c) => c.item.id)),
},
{
key: 'rename',
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
onSelect: async () => {
const name = await prompt({
id: 'rename-folder',
title: 'Rename Folder',
description: (
<>
Enter a new name for <InlineCode>{itemName}</InlineCode>
</>
),
name: 'name',
label: 'Name',
placeholder: 'New Name',
defaultValue: itemName,
});
updateAnyFolder.mutate({ id: itemId, update: (f) => ({ ...f, name }) });
},
},
{
key: 'deleteFolder',
label: 'Delete',
variant: 'danger',
leftSlot: <Icon icon="trash" />,
onSelect: () => deleteFolder.mutate(),
},
{ type: 'separator' },
...createDropdownItems,
]
: [
...((itemModel === 'http_request'
? [
{
key: 'sendRequest',
label: 'Send',
hotKeyAction: 'http_request.send',
hotKeyLabelOnly: true, // Already bound in URL bar
leftSlot: <Icon icon="sendHorizontal" />,
onSelect: () => sendRequest.mutate(itemId),
},
{
key: 'copyCurl',
label: 'Copy as Curl',
leftSlot: <Icon icon="copy" />,
onSelect: copyAsCurl,
},
{ type: 'separator' },
]
: []) as DropdownItem[]),
{
key: 'renameRequest',
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
onSelect: async () => {
const name = await prompt({
id: 'rename-request',
title: 'Rename Request',
description:
itemName === '' ? (
'Enter a new name'
) : (
<>
Enter a new name for <InlineCode>{itemName}</InlineCode>
</>
),
name: 'name',
label: 'Name',
placeholder: 'New Name',
defaultValue: itemName,
});
if (itemModel === 'http_request') {
updateHttpRequest.mutate({ id: itemId, update: (r) => ({ ...r, name }) });
} else {
updateGrpcRequest.mutate({ id: itemId, update: (r) => ({ ...r, name }) });
}
},
},
{
key: 'duplicateRequest',
label: 'Duplicate',
hotKeyAction: 'http_request.duplicate',
hotKeyLabelOnly: true, // Would trigger for every request (bad)
leftSlot: <Icon icon="copy" />,
onSelect: () => {
itemModel === 'http_request'
? duplicateHttpRequest.mutate()
: duplicateGrpcRequest.mutate();
},
},
{
key: 'moveWorkspace',
label: 'Change Workspace',
leftSlot: <Icon icon="house" />,
hidden: workspaces.length <= 1,
onSelect: moveToWorkspace.mutate,
},
{
key: 'deleteRequest',
variant: 'danger',
label: 'Delete',
leftSlot: <Icon icon="trash" />,
onSelect: () => deleteRequest.mutate(),
},
]
}
onClose={() => setShowContextMenu(null)}
triggerPosition={showContextMenu}
items={items}
onClose={handleCloseContextMenu}
/>
<button
// tabIndex={-1} // Will prevent drag-n-drop
@@ -864,79 +938,4 @@ const SidebarItem = forwardRef(function SidebarItem(
{children}
</li>
);
});
type DraggableSidebarItemProps = SidebarItemProps & {
onMove: (id: string, side: 'above' | 'below') => void;
onEnd: (id: string) => void;
onDragStart: (id: string) => void;
children?: ReactNode;
child?: TreeNode;
};
type DragItem = {
id: string;
itemName: string;
};
function DraggableSidebarItem({
itemName,
itemId,
itemModel,
child,
onMove,
onEnd,
onDragStart,
...props
}: DraggableSidebarItemProps) {
const ref = useRef<HTMLLIElement>(null);
const [, connectDrop] = useDrop<DragItem, void>(
{
accept: ItemTypes.REQUEST,
hover: (_, monitor) => {
if (!ref.current) return;
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset();
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
onMove(itemId, hoverClientY < hoverMiddleY ? 'above' : 'below');
},
},
[onMove],
);
const [{ isDragging }, connectDrag] = useDrag<
DragItem,
unknown,
{
isDragging: boolean;
}
>(
() => ({
type: ItemTypes.REQUEST,
item: () => {
onDragStart(itemId);
return { id: itemId, itemName };
},
collect: (m) => ({ isDragging: m.isDragging() }),
options: { dropEffect: 'move' },
end: () => onEnd(itemId),
}),
[onEnd],
);
connectDrag(connectDrop(ref));
return (
<SidebarItem
ref={ref}
className={classNames(isDragging && 'opacity-20')}
itemName={itemName}
itemId={itemId}
itemModel={itemModel}
child={child}
{...props}
/>
);
}

View File

@@ -167,32 +167,34 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
});
interface ContextMenuProps {
show: { x: number; y: number } | null;
triggerPosition: { x: number; y: number } | null;
className?: string;
items: DropdownProps['items'];
onClose: () => void;
}
export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function ContextMenu(
{ show, className, items, onClose },
{ triggerPosition, className, items, onClose },
ref,
) {
const triggerShape = useMemo(
() => ({
top: show?.y ?? 0,
bottom: show?.y ?? 0,
left: show?.x ?? 0,
right: show?.x ?? 0,
top: triggerPosition?.y ?? 0,
bottom: triggerPosition?.y ?? 0,
left: triggerPosition?.x ?? 0,
right: triggerPosition?.x ?? 0,
}),
[show],
[triggerPosition],
);
if (triggerPosition == null) return null;
return (
<Menu
isOpen // Always open because we return null if not
className={className}
ref={ref}
items={items}
isOpen={show != null}
onClose={onClose}
triggerShape={triggerShape}
/>