import type { Folder, GrpcRequest, HttpRequest, Workspace } from '@yaakapp-internal/models'; import classNames from 'classnames'; import { useAtom, useAtomValue } from 'jotai'; import React, { useCallback, useRef, useState } from 'react'; import { useKey, useKeyPressEvent } from 'react-use'; import { getActiveRequest } from '../hooks/useActiveRequest'; import { useActiveWorkspace } from '../hooks/useActiveWorkspace'; import { useCreateDropdownItems } from '../hooks/useCreateDropdownItems'; import { useDeleteAnyRequest } from '../hooks/useDeleteAnyRequest'; import { useGrpcConnections } from '../hooks/useGrpcConnections'; import { useHotKey } from '../hooks/useHotKey'; import { useHttpResponses } from '../hooks/useHttpResponses'; import { useSidebarHidden } from '../hooks/useSidebarHidden'; import { getSidebarCollapsedMap } from '../hooks/useSidebarItemCollapsed'; import { useUpdateAnyFolder } from '../hooks/useUpdateAnyFolder'; import { useUpdateAnyGrpcRequest } from '../hooks/useUpdateAnyGrpcRequest'; import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest'; import { router } from '../lib/router'; import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams'; import { ContextMenu } from './core/Dropdown'; import { sidebarSelectedIdAtom, sidebarTreeAtom } from './SidebarAtoms'; import type { SidebarItemProps } from './SidebarItem'; import { SidebarItems } from './SidebarItems'; interface Props { className?: string; } export type SidebarModel = Folder | GrpcRequest | HttpRequest | Workspace; export interface SidebarTreeNode { id: string; name: string; model: SidebarModel['model']; sortPriority?: number; workspaceId?: string; folderId?: string | null; children: SidebarTreeNode[]; depth: number; } export function Sidebar({ className }: Props) { const [hidden, setHidden] = useSidebarHidden(); const sidebarRef = useRef(null); const activeWorkspace = useActiveWorkspace(); const httpResponses = useHttpResponses(); const grpcConnections = useGrpcConnections(); const [hasFocus, setHasFocus] = useState(false); const [selectedId, setSelectedId] = useAtom(sidebarSelectedIdAtom); const [selectedTree, setSelectedTree] = useState(null); const { mutateAsync: updateAnyHttpRequest } = useUpdateAnyHttpRequest(); const { mutateAsync: updateAnyGrpcRequest } = useUpdateAnyGrpcRequest(); const { mutateAsync: updateAnyFolder } = useUpdateAnyFolder(); const [draggingId, setDraggingId] = useState(null); const [hoveredTree, setHoveredTree] = useState(null); const [hoveredIndex, setHoveredIndex] = useState(null); const { tree, treeParentMap, selectableRequests } = useAtomValue(sidebarTreeAtom); const focusActiveRequest = useCallback( ( args: { forced?: { id: string; tree: SidebarTreeNode; }; noFocusSidebar?: boolean; } = {}, ) => { const activeRequest = getActiveRequest(); 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.id === activeRequest?.id)?.id ?? null; setHasFocus(true); setSelectedId(id); setSelectedTree(tree); if (id == null) { return; } if (!noFocusSidebar) { sidebarRef.current?.focus(); } }, [setHasFocus, setSelectedId, treeParentMap], ); const handleSelect = useCallback( async (id: string) => { const tree = treeParentMap[id ?? 'n/a'] ?? null; const children = tree?.children ?? []; const node = children.find((m) => m.id === id) ?? null; if (node == null || tree == null || node.model === 'workspace') { return; } // NOTE: I'm not sure why, but TS thinks workspaceId is (string | undefined) here if ((node.model === 'http_request' || node.model === 'grpc_request') && node.workspaceId) { const workspaceId = node.workspaceId; await router.navigate({ to: '/workspaces/$workspaceId', params: { workspaceId }, search: (prev) => ({ ...prev, request_id: node.id }), }); setHasFocus(true); setSelectedId(id); setSelectedTree(tree); } }, [treeParentMap, setSelectedId], ); const handleClearSelected = useCallback(() => { setSelectedId(null); setSelectedTree(null); }, [setSelectedId]); const handleFocus = useCallback(() => { if (hasFocus) return; focusActiveRequest({ noFocusSidebar: true }); }, [focusActiveRequest, hasFocus]); const handleBlur = useCallback(() => setHasFocus(false), [setHasFocus]); const deleteRequest = useDeleteAnyRequest(); useHotKey( 'http_request.delete', async () => { // Delete only works if a request is focused if (selectedId == null) return; deleteRequest.mutate(selectedId); }, { enable: hasFocus }, ); 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 0th index on focus if none selected focusActiveRequest( selectedTree != null && selectedId != null ? { forced: { id: selectedId, tree: selectedTree } } : undefined, ); }); useKeyPressEvent('Enter', async (e) => { if (!hasFocus) return; const selected = selectableRequests.find((r) => r.id === selectedId); if (!selected || activeWorkspace == null) { return; } e.preventDefault(); setWorkspaceSearchParams({ request_id: selected.id }); }); 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( async (id, side) => { let hoveredTree = treeParentMap[id] ?? null; const dragIndex = hoveredTree?.children.findIndex((n) => n.id === id) ?? -99; const hoveredItem = hoveredTree?.children[dragIndex] ?? null; let hoveredIndex = dragIndex + (side === 'above' ? 0 : 1); const isHoveredItemCollapsed = hoveredItem != null ? getSidebarCollapsedMap()[hoveredItem.id] : false; if (hoveredItem?.model === 'folder' && side === 'below' && !isHoveredItemCollapsed) { // Move into the folder if it's open and we're moving below it hoveredTree = hoveredTree?.children.find((n) => n.id === id) ?? null; hoveredIndex = 0; } setHoveredTree(hoveredTree); setHoveredIndex(hoveredIndex); }, [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.id === itemId) { return; } const parentTree = treeParentMap[itemId] ?? null; const index = parentTree?.children.findIndex((n) => n.id === itemId) ?? -1; const child = parentTree?.children[index ?? -1]; if (child == null || parentTree == null) return; const movedToDifferentTree = hoveredTree.id !== parentTree.id; const movedUpInSameTree = !movedToDifferentTree && hoveredIndex < index; const newChildren = hoveredTree.children.filter((c) => c.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.id === child.id); const prev = newChildren[insertedIndex - 1]; const next = newChildren[insertedIndex + 1]; const beforePriority = prev?.sortPriority ?? 0; const afterPriority = next?.sortPriority ?? 0; const folderId = hoveredTree.model === 'folder' ? hoveredTree.id : null; const shouldUpdateAll = afterPriority - beforePriority < 1; if (shouldUpdateAll) { await Promise.all( newChildren.map((child, i) => { const sortPriority = i * 1000; if (child.model === 'folder') { const updateFolder = (f: Folder) => ({ ...f, sortPriority, folderId }); return updateAnyFolder({ id: child.id, update: updateFolder }); } else if (child.model === 'grpc_request') { const updateRequest = (r: GrpcRequest) => ({ ...r, sortPriority, folderId }); return updateAnyGrpcRequest({ id: child.id, update: updateRequest }); } else if (child.model === 'http_request') { const updateRequest = (r: HttpRequest) => ({ ...r, sortPriority, folderId }); return updateAnyHttpRequest({ id: child.id, update: updateRequest }); } }), ); } else { const sortPriority = afterPriority - (afterPriority - beforePriority) / 2; if (child.model === 'folder') { const updateFolder = (f: Folder) => ({ ...f, sortPriority, folderId }); await updateAnyFolder({ id: child.id, update: updateFolder }); } else if (child.model === 'grpc_request') { const updateRequest = (r: GrpcRequest) => ({ ...r, sortPriority, folderId }); await updateAnyGrpcRequest({ id: child.id, update: updateRequest }); } else if (child.model === 'http_request') { const updateRequest = (r: HttpRequest) => ({ ...r, sortPriority, folderId }); await updateAnyHttpRequest({ id: child.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({ folderId: null }); // Not ready to render yet if (tree == null) { return null; } return ( ); }