import type { AnyModel, GrpcConnection, HttpResponse, WebsocketConnection, } from '@yaakapp-internal/models'; import { foldersAtom, patchModelById } from '@yaakapp-internal/models'; import classNames from 'classnames'; import { atom, useAtomValue } from 'jotai'; import type { ReactElement } from 'react'; import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { XYCoord } from 'react-dnd'; import { useDrag, useDrop } from 'react-dnd'; import { activeRequestAtom } from '../../hooks/useActiveRequest'; import { allRequestsAtom } from '../../hooks/useAllRequests'; import { useScrollIntoView } from '../../hooks/useScrollIntoView'; import { useSidebarItemCollapsed } from '../../hooks/useSidebarItemCollapsed'; import { jotaiStore } from '../../lib/jotai'; import { HttpMethodTag } from '../core/HttpMethodTag'; import { HttpStatusTag } from '../core/HttpStatusTag'; import { Icon } from '../core/Icon'; import { LoadingIcon } from '../core/LoadingIcon'; import type { DragItem} from './dnd'; import { ItemTypes } from './dnd'; import type { SidebarTreeNode } from './Sidebar'; import { sidebarSelectedIdAtom } from './SidebarAtoms'; import { SidebarItemContextMenu } from './SidebarItemContextMenu'; import type { SidebarItemsProps } from './SidebarItems'; export type SidebarItemProps = { className?: string; itemId: string; itemName: string; itemModel: AnyModel['model']; onMove: (id: string, side: 'above' | 'below') => void; onEnd: (id: string) => void; onDragStart: (id: string) => void; children: ReactElement | null; child: SidebarTreeNode; latestHttpResponse: HttpResponse | null; latestGrpcConnection: GrpcConnection | null; latestWebsocketConnection: WebsocketConnection | null; } & Pick; export const SidebarItem = memo(function SidebarItem({ itemName, itemId, itemModel, child, onMove, onEnd, onDragStart, onSelect, className, latestHttpResponse, latestGrpcConnection, latestWebsocketConnection, children, }: SidebarItemProps) { const ref = useRef(null); const [collapsed, toggleCollapsed] = useSidebarItemCollapsed(itemId); const [, connectDrop] = useDrop( { accept: [ItemTypes.REQUEST, ItemTypes.SIDEBAR], hover: (_, monitor) => { if (!ref.current) return; if (!monitor.isOver()) 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: () => { // Cancel drag when editing if (editing) return null; onDragStart(itemId); return { id: itemId, itemName }; }, collect: (m) => ({ isDragging: m.isDragging() }), options: { dropEffect: 'move' }, end: () => onEnd(itemId), }), [onEnd], ); connectDrag(connectDrop(ref)); const [editing, setEditing] = useState(false); const [selected, setSelected] = useState( jotaiStore.get(sidebarSelectedIdAtom) == itemId, ); useEffect(() => { return jotaiStore.sub(sidebarSelectedIdAtom, () => { const value = jotaiStore.get(sidebarSelectedIdAtom); setSelected(value === itemId); }); }, [itemId]); const [active, setActive] = useState(jotaiStore.get(activeRequestAtom)?.id === itemId); useEffect( () => jotaiStore.sub(activeRequestAtom, () => setActive(jotaiStore.get(activeRequestAtom)?.id === itemId), ), [itemId], ); useScrollIntoView(ref.current, active); const handleSubmitNameEdit = useCallback( async (el: HTMLInputElement) => { await patchModelById(itemModel, itemId, { name: el.value }); // Slight delay for the model to propagate to the local store setTimeout(() => setEditing(false)); }, [itemId, itemModel], ); 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(); await handleSubmitNameEdit(e.currentTarget); break; case 'Escape': e.preventDefault(); setEditing(false); break; } }, [handleSubmitNameEdit], ); const handleStartEditing = useCallback(() => { if ( itemModel !== 'http_request' && itemModel !== 'grpc_request' && itemModel !== 'websocket_request' ) return; setEditing(true); }, [setEditing, itemModel]); const handleBlur = useCallback( async (e: React.FocusEvent) => { await handleSubmitNameEdit(e.currentTarget); }, [handleSubmitNameEdit], ); const handleSelect = useCallback(async () => { if (itemModel === 'folder') { toggleCollapsed(); } else { onSelect(itemId); } }, [itemModel, toggleCollapsed, onSelect, itemId]); const [showContextMenu, setShowContextMenu] = useState<{ x: number; y: number; } | null>(null); const handleContextMenu = useCallback((e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setShowContextMenu({ x: e.clientX, y: e.clientY }); }, []); const handleCloseContextMenu = useCallback(() => setShowContextMenu(null), []); const itemAtom = useMemo(() => { return atom((get) => { if (itemModel === 'folder') { return get(foldersAtom).find((v) => v.id === itemId); } else { return get(allRequestsAtom).find((v) => v.id === itemId); } }); }, [itemId, itemModel]); const item = useAtomValue(itemAtom); if (item == null) { return null; } const opacitySubtle = 'opacity-80'; const itemPrefix = item.model !== 'folder' && ( ); return (
  • {showContextMenu && ( )}
    {collapsed ? null : children}
  • ); });