import classNames from 'classnames'; 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'; import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId'; import { useActiveRequest } from '../hooks/useActiveRequest'; import { useActiveWorkspace } from '../hooks/useActiveWorkspace'; import { useAppRoutes } from '../hooks/useAppRoutes'; import { useCopyAsCurl } from '../hooks/useCopyAsCurl'; import { useCreateDropdownItems } from '../hooks/useCreateDropdownItems'; import { useDeleteFolder } from '../hooks/useDeleteFolder'; import { useDeleteRequest } from '../hooks/useDeleteRequest'; import { useDuplicateGrpcRequest } from '../hooks/useDuplicateGrpcRequest'; import { useDuplicateHttpRequest } from '../hooks/useDuplicateHttpRequest'; import { useFolders } from '../hooks/useFolders'; import { useHotKey } from '../hooks/useHotKey'; import { useKeyValue } from '../hooks/useKeyValue'; import { useLatestGrpcConnection } from '../hooks/useLatestGrpcConnection'; import { useLatestHttpResponse } from '../hooks/useLatestHttpResponse'; import { useMoveToWorkspace } from '../hooks/useMoveToWorkspace'; import { usePrompt } from '../hooks/usePrompt'; import { useRequests } from '../hooks/useRequests'; import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest'; import { useSendManyRequests } from '../hooks/useSendFolder'; import { useSidebarHidden } from '../hooks/useSidebarHidden'; import { useUpdateAnyFolder } from '../hooks/useUpdateAnyFolder'; import { useUpdateAnyGrpcRequest } from '../hooks/useUpdateAnyGrpcRequest'; import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest'; import { useWorkspaces } from '../hooks/useWorkspaces'; import { fallbackRequestName } from '../lib/fallbackRequestName'; import type { Folder, GrpcRequest, HttpRequest, Workspace } from '../lib/models'; import { isResponseLoading } from '../lib/models'; import type { DropdownItem } from './core/Dropdown'; import { ContextMenu } from './core/Dropdown'; import { HttpMethodTag } from './core/HttpMethodTag'; import { Icon } from './core/Icon'; import { InlineCode } from './core/InlineCode'; import { VStack } from './core/Stacks'; import { StatusTag } from './core/StatusTag'; import { DropMarker } from './DropMarker'; interface Props { className?: string; } enum ItemTypes { REQUEST = 'request', } interface TreeNode { item: Workspace | Folder | HttpRequest | GrpcRequest; children: TreeNode[]; depth: number; } export function Sidebar({ className }: Props) { const [hidden, setHidden] = useSidebarHidden(); const sidebarRef = useRef(null); const activeRequest = useActiveRequest(); const activeEnvironmentId = useActiveEnvironmentId(); const folders = useFolders(); const requests = useRequests(); const activeWorkspace = useActiveWorkspace(); const duplicateHttpRequest = useDuplicateHttpRequest({ id: activeRequest?.id ?? null, navigateAfter: true, }); const duplicateGrpcRequest = useDuplicateGrpcRequest({ id: activeRequest?.id ?? null, navigateAfter: true, }); const routes = useAppRoutes(); const [hasFocus, setHasFocus] = useState(false); const [selectedId, setSelectedId] = useState(null); const [selectedTree, setSelectedTree] = useState(null); const updateAnyHttpRequest = useUpdateAnyHttpRequest(); const updateAnyGrpcRequest = useUpdateAnyGrpcRequest(); const updateAnyFolder = useUpdateAnyFolder(); const [draggingId, setDraggingId] = useState(null); const [hoveredTree, setHoveredTree] = useState(null); const [hoveredIndex, setHoveredIndex] = useState(null); const collapsed = useKeyValue>({ key: ['sidebar_collapsed', activeWorkspace?.id ?? 'n/a'], fallback: {}, namespace: 'no_sync', }); useHotKey('http_request.duplicate', async () => { if (activeRequest?.model === 'http_request') { await duplicateHttpRequest.mutateAsync(); } else { await duplicateGrpcRequest.mutateAsync(); } }); const isCollapsed = useCallback( (id: string) => collapsed.value?.[id] ?? false, [collapsed.value], ); const { tree, treeParentMap, selectableRequests } = useMemo<{ tree: TreeNode | null; treeParentMap: Record; selectedRequest: HttpRequest | GrpcRequest | null; selectableRequests: { id: string; index: number; tree: TreeNode; }[]; }>(() => { const treeParentMap: Record = {}; const selectableRequests: { id: string; index: number; tree: TreeNode; }[] = []; if (activeWorkspace == null) { return { tree: null, treeParentMap, selectableRequests, selectedRequest: null }; } let selectedRequest: HttpRequest | GrpcRequest | null = null; let selectableRequestIndex = 0; // Put requests and folders into a tree structure const next = (node: TreeNode): TreeNode => { if ( node.item.id === selectedId && (node.item.model === 'http_request' || node.item.model === 'grpc_request') ) { selectedRequest = node.item; } const childItems = [...requests, ...folders].filter((f) => node.item.model === 'workspace' ? f.folderId == null : f.folderId === node.item.id, ); // Recurse to children const isCollapsed = collapsed.value?.[node.item.id]; const depth = node.depth + 1; childItems.sort((a, b) => a.sortPriority - b.sortPriority); for (const item of childItems) { treeParentMap[item.id] = node; // Add to children node.children.push(next({ item, children: [], depth })); // Add to selectable requests if (item.model !== 'folder' && !isCollapsed) { selectableRequests.push({ id: item.id, index: selectableRequestIndex++, tree: node }); } } return node; }; const tree = next({ item: activeWorkspace, children: [], depth: 0 }); return { tree, treeParentMap, selectableRequests, selectedRequest }; }, [activeWorkspace, selectedId, requests, folders, collapsed.value]); const focusActiveRequest = useCallback( ( args: { forced?: { id: string; tree: TreeNode; }; noFocusSidebar?: boolean; } = {}, ) => { const { forced, noFocusSidebar } = args; const tree = forced?.tree ?? treeParentMap[activeRequest?.id ?? 'n/a'] ?? null; const children = tree?.children ?? []; const id = forced?.id ?? children.find((m) => m.item.id === activeRequest?.id)?.item.id ?? null; setHasFocus(true); setSelectedId(id); setSelectedTree(tree); if (id == null) { return; } if (!noFocusSidebar) { sidebarRef.current?.focus(); } }, [activeRequest, treeParentMap], ); const handleSelect = useCallback( async (id: string, opts: { noFocus?: boolean } = {}) => { const tree = treeParentMap[id ?? 'n/a'] ?? null; const children = tree?.children ?? []; const node = children.find((m) => m.item.id === id) ?? null; if (node == null || tree == null || node.item.model === 'workspace') { return; } const { item } = node; if (item.model === 'folder') { await collapsed.set((c) => ({ ...c, [item.id]: !c[item.id] })); } else { routes.navigate('request', { requestId: id, workspaceId: item.workspaceId, environmentId: activeEnvironmentId ?? undefined, }); setSelectedId(id); setSelectedTree(tree); if (!opts.noFocus) focusActiveRequest({ forced: { id, tree } }); } }, [treeParentMap, collapsed, routes, activeEnvironmentId, focusActiveRequest], ); const handleClearSelected = useCallback(() => { setSelectedId(null); setSelectedTree(null); }, []); const handleFocus = useCallback(() => { if (hasFocus) return; focusActiveRequest({ noFocusSidebar: true }); }, [focusActiveRequest, hasFocus]); const handleBlur = useCallback(() => setHasFocus(false), []); useHotKey('sidebar.focus', async () => { // Hide the sidebar if it's already focused if (!hidden && hasFocus) { await setHidden(true); return; } // Show the sidebar if it's hidden if (hidden) { await setHidden(false); } // Select 0 index on focus if none selected focusActiveRequest( selectedTree != null && selectedId != null ? { forced: { id: selectedId, tree: selectedTree } } : undefined, ); }); useKeyPressEvent('Enter', (e) => { if (!hasFocus) return; const selected = selectableRequests.find((r) => r.id === selectedId); if (!selected || selected.id === activeRequest?.id || activeWorkspace == null) { return; } e.preventDefault(); routes.navigate('request', { requestId: selected.id, workspaceId: activeWorkspace?.id, environmentId: activeEnvironmentId ?? undefined, }); }); useKey( 'ArrowUp', (e) => { if (!hasFocus) return; e.preventDefault(); const i = selectableRequests.findIndex((r) => r.id === selectedId); const newSelectable = selectableRequests[i - 1]; if (newSelectable == null) { return; } setSelectedId(newSelectable.id); setSelectedTree(newSelectable.tree); }, undefined, [hasFocus, selectableRequests, selectedId, setSelectedId, setSelectedTree], ); useKey( 'ArrowDown', (e) => { if (!hasFocus) return; e.preventDefault(); const i = selectableRequests.findIndex((r) => r.id === selectedId); const newSelectable = selectableRequests[i + 1]; if (newSelectable == null) { return; } setSelectedId(newSelectable.id); setSelectedTree(newSelectable.tree); }, undefined, [hasFocus, selectableRequests, selectedId, setSelectedId, setSelectedTree], ); const handleMove = useCallback( (id, side) => { let hoveredTree = treeParentMap[id] ?? null; const dragIndex = hoveredTree?.children.findIndex((n) => n.item.id === id) ?? -99; const hoveredItem = hoveredTree?.children[dragIndex]?.item ?? null; let hoveredIndex = dragIndex + (side === 'above' ? 0 : 1); if (hoveredItem?.model === 'folder' && side === 'below' && !isCollapsed(hoveredItem.id)) { // Move into folder if it's open and we're moving below it hoveredTree = hoveredTree?.children.find((n) => n.item.id === id) ?? null; hoveredIndex = 0; } setHoveredTree(hoveredTree); setHoveredIndex(hoveredIndex); }, [isCollapsed, treeParentMap], ); const handleDragStart = useCallback((id: string) => { setDraggingId(id); }, []); const handleEnd = useCallback( async (itemId) => { setHoveredTree(null); handleClearSelected(); if (hoveredTree == null || hoveredIndex == null) { return; } // Block dragging folder into itself if (hoveredTree.item.id === itemId) { return; } const parentTree = treeParentMap[itemId] ?? null; const index = parentTree?.children.findIndex((n) => n.item.id === itemId) ?? -1; const child = parentTree?.children[index ?? -1]; if (child == null || parentTree == null) return; const movedToDifferentTree = hoveredTree.item.id !== parentTree.item.id; const movedUpInSameTree = !movedToDifferentTree && hoveredIndex < index; const newChildren = hoveredTree.children.filter((c) => c.item.id !== itemId); if (movedToDifferentTree || movedUpInSameTree) { // Moving up or into a new tree is simply inserting before the hovered item newChildren.splice(hoveredIndex, 0, child); } else { // Moving down has to account for the fact that the original item will be removed newChildren.splice(hoveredIndex - 1, 0, child); } const insertedIndex = newChildren.findIndex((c) => c.item === child.item); const prev = newChildren[insertedIndex - 1]?.item; const next = newChildren[insertedIndex + 1]?.item; const beforePriority = prev == null || prev.model === 'workspace' ? 0 : prev.sortPriority; const afterPriority = next == null || next.model === 'workspace' ? 0 : next.sortPriority; const folderId = hoveredTree.item.model === 'folder' ? hoveredTree.item.id : null; const shouldUpdateAll = afterPriority - beforePriority < 1; if (shouldUpdateAll) { await Promise.all( newChildren.map((child, i) => { const sortPriority = i * 1000; if (child.item.model === 'folder') { const updateFolder = (f: Folder) => ({ ...f, sortPriority, folderId }); return updateAnyFolder.mutateAsync({ id: child.item.id, update: updateFolder }); } else if (child.item.model === 'grpc_request') { const updateRequest = (r: GrpcRequest) => ({ ...r, sortPriority, folderId }); return updateAnyGrpcRequest.mutateAsync({ id: child.item.id, update: updateRequest }); } else if (child.item.model === 'http_request') { const updateRequest = (r: HttpRequest) => ({ ...r, sortPriority, folderId }); return updateAnyHttpRequest.mutateAsync({ id: child.item.id, update: updateRequest }); } }), ); } else { const sortPriority = afterPriority - (afterPriority - beforePriority) / 2; if (child.item.model === 'folder') { const updateFolder = (f: Folder) => ({ ...f, sortPriority, folderId }); await updateAnyFolder.mutateAsync({ id: child.item.id, update: updateFolder }); } else if (child.item.model === 'grpc_request') { const updateRequest = (r: GrpcRequest) => ({ ...r, sortPriority, folderId }); await updateAnyGrpcRequest.mutateAsync({ id: child.item.id, update: updateRequest }); } else if (child.item.model === 'http_request') { const updateRequest = (r: HttpRequest) => ({ ...r, sortPriority, folderId }); await updateAnyHttpRequest.mutateAsync({ id: child.item.id, update: updateRequest }); } } setDraggingId(null); }, [ handleClearSelected, hoveredTree, hoveredIndex, treeParentMap, updateAnyFolder, updateAnyGrpcRequest, updateAnyHttpRequest, ], ); const [showMainContextMenu, setShowMainContextMenu] = useState<{ x: number; y: number; } | null>(null); const handleMainContextMenu = useCallback((e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setShowMainContextMenu({ x: e.clientX, y: e.clientY }); }, []); const mainContextMenuItems = useCreateDropdownItems(); // Not ready to render yet if (tree == null || collapsed.value == null) { return null; } return ( ); } interface SidebarItemsProps { tree: TreeNode; focused: boolean; draggingId: string | null; activeId: string | null; selectedId: string | null; selectedTree: TreeNode | null; treeParentMap: Record; hoveredTree: TreeNode | null; hoveredIndex: number | null; handleMove: (id: string, side: 'above' | 'below') => void; handleEnd: (id: string) => void; handleDragStart: (id: string) => void; onSelect: (requestId: string) => void; isCollapsed: (id: string) => boolean; } function SidebarItems({ tree, focused, activeId, selectedId, selectedTree, draggingId, onSelect, treeParentMap, isCollapsed, hoveredTree, hoveredIndex, handleEnd, handleMove, handleDragStart, }: SidebarItemsProps) { return ( 0 && 'border-l border-background-highlight-secondary', tree.depth === 0 && 'ml-0', tree.depth >= 1 && 'ml-[1.2rem]', )} > {tree.children.map((child, i) => { const selected = selectedId === child.item.id; const active = activeId === child.item.id; return ( {hoveredIndex === i && hoveredTree?.item.id === tree.item.id && } ) } onMove={handleMove} onEnd={handleEnd} onSelect={onSelect} onDragStart={handleDragStart} useProminentStyles={focused} isCollapsed={isCollapsed} child={child} > {child.item.model === 'folder' && !isCollapsed(child.item.id) && draggingId !== child.item.id && ( )} ); })} {hoveredIndex === tree.children.length && hoveredTree?.item.id === tree.item.id && ( )} ); } type SidebarItemProps = { className?: string; itemId: string; itemName: string; itemFallbackName: string; itemModel: string; itemPrefix: ReactNode; 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; 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(null); const [, connectDrop] = useDrop( { 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); const duplicateHttpRequest = useDuplicateHttpRequest({ id: itemId, navigateAfter: true }); const duplicateGrpcRequest = useDuplicateGrpcRequest({ id: itemId, navigateAfter: true }); const copyAsCurl = useCopyAsCurl(itemId); const sendRequest = useSendAnyHttpRequest(); const moveToWorkspace = useMoveToWorkspace(itemId); const sendManyRequests = useSendManyRequests(); const latestHttpResponse = useLatestHttpResponse(itemId); const latestGrpcConnection = useLatestGrpcConnection(itemId); const updateHttpRequest = useUpdateAnyHttpRequest(); const workspaces = useWorkspaces(); const updateGrpcRequest = useUpdateAnyGrpcRequest(); const updateAnyFolder = useUpdateAnyFolder(); const prompt = usePrompt(); const [editing, setEditing] = useState(false); const isActive = activeRequest?.id === itemId; const createDropdownItems = useCreateDropdownItems({ folderId: itemId }); const handleSubmitNameEdit = useCallback( (el: HTMLInputElement) => { if (itemModel === 'http_request') { updateHttpRequest.mutate({ id: itemId, update: (r) => ({ ...r, name: el.value }) }); } else if (itemModel === 'grpc_request') { updateGrpcRequest.mutate({ id: itemId, update: (r) => ({ ...r, name: el.value }) }); } setEditing(false); }, [itemId, itemModel, updateGrpcRequest, updateHttpRequest], ); const handleFocus = useCallback((el: HTMLInputElement | null) => { el?.focus(); el?.select(); }, []); const handleInputKeyDown = useCallback( async (e: React.KeyboardEvent) => { e.stopPropagation(); switch (e.key) { case 'Enter': e.preventDefault(); handleSubmitNameEdit(e.currentTarget); break; case 'Escape': e.preventDefault(); setEditing(false); break; } }, [handleSubmitNameEdit], ); const handleStartEditing = useCallback(() => { if (itemModel !== 'http_request' && itemModel !== 'grpc_request') return; setEditing(true); }, [setEditing, itemModel]); const handleBlur = useCallback( (e: React.FocusEvent) => { handleSubmitNameEdit(e.currentTarget); }, [handleSubmitNameEdit], ); const handleSelect = useCallback(() => onSelect(itemId), [onSelect, itemId]); const [showContextMenu, setShowContextMenu] = useState<{ x: number; y: number; } | null>(null); const handleCloseContextMenu = useCallback(() => { setShowContextMenu(null); }, []); const handleContextMenu = useCallback((e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setShowContextMenu({ x: e.clientX, y: e.clientY }); }, []); const items = useMemo(() => { if (itemModel === 'folder') { return [ { key: 'sendAll', label: 'Send All', leftSlot: , onSelect: () => sendManyRequests.mutate(child.children.map((c) => c.item.id)), }, { key: 'rename', label: 'Rename', leftSlot: , onSelect: async () => { const name = await prompt({ id: 'rename-folder', title: 'Rename Folder', description: ( <> Enter a new name for {itemName} ), name: 'name', label: 'Name', placeholder: 'New Name', defaultValue: itemName, }); updateAnyFolder.mutate({ id: itemId, update: (f) => ({ ...f, name }) }); }, }, { key: 'deleteFolder', label: 'Delete', variant: 'danger', leftSlot: , 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: , onSelect: () => sendRequest.mutate(itemId), }, { key: 'copyCurl', label: 'Copy as Curl', leftSlot: , onSelect: copyAsCurl.mutate, }, { type: 'separator' }, ] : []; return [ ...requestItems, { key: 'renameRequest', label: 'Rename', leftSlot: , onSelect: async () => { const name = await prompt({ id: 'rename-request', title: 'Rename Request', description: itemName === '' ? ( 'Enter a new name' ) : ( <> Enter a new name for {itemName} ), 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: , onSelect: () => { itemModel === 'http_request' ? duplicateHttpRequest.mutate() : duplicateGrpcRequest.mutate(); }, }, { key: 'moveWorkspace', label: 'Move', leftSlot: , hidden: workspaces.length <= 1, onSelect: moveToWorkspace.mutate, }, { key: 'deleteRequest', variant: 'danger', label: 'Delete', leftSlot: , 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 (
  • {children}
  • ); }