Better performance on large workspaces

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

View File

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

View File

@@ -1,6 +1,6 @@
import classNames from 'classnames'; import classNames from 'classnames';
import type { ForwardedRef, ReactNode } from 'react'; import type { ReactNode } from 'react';
import React, { forwardRef, Fragment, useCallback, useMemo, useRef, useState } from 'react'; import React, { Fragment, useCallback, useMemo, useRef, useState } from 'react';
import type { XYCoord } from 'react-dnd'; import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd'; import { useDrag, useDrop } from 'react-dnd';
import { useKey, useKeyPressEvent } from 'react-use'; import { useKey, useKeyPressEvent } from 'react-use';
@@ -299,7 +299,7 @@ export function Sidebar({ className }: Props) {
[hasFocus, selectableRequests, selectedId, setSelectedId, setSelectedTree], [hasFocus, selectableRequests, selectedId, setSelectedId, setSelectedTree],
); );
const handleMove = useCallback<DraggableSidebarItemProps['onMove']>( const handleMove = useCallback<SidebarItemProps['onMove']>(
(id, side) => { (id, side) => {
let hoveredTree = treeParentMap[id] ?? null; let hoveredTree = treeParentMap[id] ?? null;
const dragIndex = hoveredTree?.children.findIndex((n) => n.item.id === id) ?? -99; const dragIndex = hoveredTree?.children.findIndex((n) => n.item.id === id) ?? -99;
@@ -318,11 +318,11 @@ export function Sidebar({ className }: Props) {
[isCollapsed, treeParentMap], [isCollapsed, treeParentMap],
); );
const handleDragStart = useCallback<DraggableSidebarItemProps['onDragStart']>((id: string) => { const handleDragStart = useCallback<SidebarItemProps['onDragStart']>((id: string) => {
setDraggingId(id); setDraggingId(id);
}, []); }, []);
const handleEnd = useCallback<DraggableSidebarItemProps['onEnd']>( const handleEnd = useCallback<SidebarItemProps['onEnd']>(
async (itemId) => { async (itemId) => {
setHoveredTree(null); setHoveredTree(null);
handleClearSelected(); handleClearSelected();
@@ -436,7 +436,7 @@ export function Sidebar({ className }: Props) {
)} )}
> >
<ContextMenu <ContextMenu
show={showMainContextMenu} triggerPosition={showMainContextMenu}
items={mainContextMenuItems} items={mainContextMenuItems}
onClose={() => setShowMainContextMenu(null)} onClose={() => setShowMainContextMenu(null)}
/> />
@@ -511,8 +511,7 @@ function SidebarItems({
return ( return (
<Fragment key={child.item.id}> <Fragment key={child.item.id}>
{hoveredIndex === i && hoveredTree?.item.id === tree.item.id && <DropMarker />} {hoveredIndex === i && hoveredTree?.item.id === tree.item.id && <DropMarker />}
<DraggableSidebarItem <SidebarItem
draggable
selected={selected} selected={selected}
itemId={child.item.id} itemId={child.item.id}
itemName={child.item.name} itemName={child.item.name}
@@ -558,7 +557,7 @@ function SidebarItems({
handleDragStart={handleDragStart} handleDragStart={handleDragStart}
/> />
)} )}
</DraggableSidebarItem> </SidebarItem>
</Fragment> </Fragment>
); );
})} })}
@@ -579,28 +578,74 @@ type SidebarItemProps = {
useProminentStyles?: boolean; useProminentStyles?: boolean;
selected?: boolean; selected?: boolean;
draggable?: boolean; draggable?: boolean;
onMove: (id: string, side: 'above' | 'below') => void;
onEnd: (id: string) => void;
onDragStart: (id: string) => void;
children?: ReactNode; children?: ReactNode;
child: TreeNode; child: TreeNode;
} & Pick<SidebarItemsProps, 'isCollapsed' | 'onSelect'>; } & Pick<SidebarItemsProps, 'isCollapsed' | 'onSelect'>;
const SidebarItem = forwardRef(function SidebarItem( type DragItem = {
{ id: string;
children, itemName: string;
className, };
itemName,
itemFallbackName, function SidebarItem({
itemId, itemName,
itemModel, itemId,
itemPrefix, itemModel,
useProminentStyles, child,
selected, onMove,
onSelect, onEnd,
isCollapsed, onDragStart,
child, onSelect,
draggable, isCollapsed,
}: SidebarItemProps, itemPrefix,
ref: ForwardedRef<HTMLLIElement>, 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 activeRequest = useActiveRequest();
const deleteFolder = useDeleteFolder(itemId); const deleteFolder = useDeleteFolder(itemId);
const deleteRequest = useDeleteRequest(itemId); const deleteRequest = useDeleteRequest(itemId);
@@ -673,134 +718,163 @@ const SidebarItem = forwardRef(function SidebarItem(
y: number; y: number;
} | null>(null); } | null>(null);
const handleCloseContextMenu = useCallback(() => {
setShowContextMenu(null);
}, []);
const handleContextMenu = useCallback((e: React.MouseEvent<HTMLElement>) => { const handleContextMenu = useCallback((e: React.MouseEvent<HTMLElement>) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setShowContextMenu({ x: e.clientX, y: e.clientY }); 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 ( return (
<li ref={ref} draggable={draggable}> <li ref={ref} draggable>
<div className={classNames(className, 'block relative group/item px-1.5 pb-0.5')}> <div className={classNames(className, 'block relative group/item px-1.5 pb-0.5')}>
<ContextMenu <ContextMenu
show={showContextMenu} triggerPosition={showContextMenu}
items={ items={items}
itemModel === 'folder' onClose={handleCloseContextMenu}
? [
{
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)}
/> />
<button <button
// tabIndex={-1} // Will prevent drag-n-drop // tabIndex={-1} // Will prevent drag-n-drop
@@ -864,79 +938,4 @@ const SidebarItem = forwardRef(function SidebarItem(
{children} {children}
</li> </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 { interface ContextMenuProps {
show: { x: number; y: number } | null; triggerPosition: { x: number; y: number } | null;
className?: string; className?: string;
items: DropdownProps['items']; items: DropdownProps['items'];
onClose: () => void; onClose: () => void;
} }
export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function ContextMenu( export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function ContextMenu(
{ show, className, items, onClose }, { triggerPosition, className, items, onClose },
ref, ref,
) { ) {
const triggerShape = useMemo( const triggerShape = useMemo(
() => ({ () => ({
top: show?.y ?? 0, top: triggerPosition?.y ?? 0,
bottom: show?.y ?? 0, bottom: triggerPosition?.y ?? 0,
left: show?.x ?? 0, left: triggerPosition?.x ?? 0,
right: show?.x ?? 0, right: triggerPosition?.x ?? 0,
}), }),
[show], [triggerPosition],
); );
if (triggerPosition == null) return null;
return ( return (
<Menu <Menu
isOpen // Always open because we return null if not
className={className} className={className}
ref={ref} ref={ref}
items={items} items={items}
isOpen={show != null}
onClose={onClose} onClose={onClose}
triggerShape={triggerShape} triggerShape={triggerShape}
/> />