diff --git a/src-web/components/NewSidebar.tsx b/src-web/components/NewSidebar.tsx index 8a24f766..8f10cae0 100644 --- a/src-web/components/NewSidebar.tsx +++ b/src-web/components/NewSidebar.tsx @@ -25,8 +25,9 @@ import { activeCookieJarAtom } from '../hooks/useActiveCookieJar'; import { activeEnvironmentAtom } from '../hooks/useActiveEnvironment'; import { activeFolderIdAtom } from '../hooks/useActiveFolderId'; import { activeRequestIdAtom } from '../hooks/useActiveRequestId'; -import { activeWorkspaceAtom } from '../hooks/useActiveWorkspace'; +import { activeWorkspaceAtom, activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace'; import { allRequestsAtom } from '../hooks/useAllRequests'; +import { getCreateDropdownItems } from '../hooks/useCreateDropdownItems'; import { getGrpcRequestActions } from '../hooks/useGrpcRequestActions'; import { useHotKey } from '../hooks/useHotKey'; import { getHttpRequestActions } from '../hooks/useHttpRequestActions'; @@ -52,80 +53,9 @@ import { Tree } from './core/tree/Tree'; import type { TreeItemProps } from './core/tree/TreeItem'; import { GitDropdown } from './GitDropdown'; -type Model = Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRequest; +type SidebarModel = Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRequest; -const opacitySubtle = 'opacity-80'; - -function getItemKey(item: Model) { - const responses = jotaiStore.get(httpResponsesAtom); - const latestResponse = responses.find((r) => r.requestId === item.id) ?? null; - const url = 'url' in item ? item.url : 'n/a'; - const method = 'method' in item ? item.method : 'n/a'; - return [ - item.id, - item.name, - url, - method, - latestResponse?.elapsed, - latestResponse?.id ?? 'n/a', - ].join('::'); -} - -const SidebarLeftSlot = memo(function SidebarLeftSlot({ - treeId, - item, -}: { - treeId: string; - item: Model; -}) { - if (item.model === 'folder') { - return ; - } else if (item.model === 'workspace') { - return null; - } else { - const isSelected = jotaiStore.get(isSelectedFamily({ treeId, itemId: item.id })); - return ( - - ); - } -}); - -const SidebarInnerItem = memo(function SidebarInnerItem({ item }: { treeId: string; item: Model }) { - const response = useAtomValue( - useMemo( - () => - selectAtom( - atom((get) => [ - ...get(grpcConnectionsAtom), - ...get(httpResponsesAtom), - ...get(websocketConnectionsAtom), - ]), - (responses) => responses.find((r) => r.requestId === item.id), - (a, b) => a?.state === b?.state && a?.id === b?.id, // Only update when the response state changes updated - ), - [item.id], - ), - ); - - return ( -
-
{resolvedModelName(item)}
- {response != null && ( -
- {response.state !== 'closed' ? ( - - ) : response.model === 'http_response' ? ( - - ) : null} -
- )} -
- ); -}); +const OPACITY_SUBTLE = 'opacity-80'; function NewSidebar({ className }: { className?: string }) { const [hidden, setHidden] = useSidebarHidden(); @@ -161,13 +91,13 @@ function NewSidebar({ className }: { className?: string }) { children, insertAt, }: { - items: Model[]; - parent: Model; - children: Model[]; + items: SidebarModel[]; + parent: SidebarModel; + children: SidebarModel[]; insertAt: number; }) { - const prev = children[insertAt - 1] as Exclude; - const next = children[insertAt] as Exclude; + const prev = children[insertAt - 1] as Exclude; + const next = children[insertAt] as Exclude; const folderId = parent.model === 'folder' ? parent.id : null; const beforePriority = prev?.sortPriority ?? 0; @@ -248,8 +178,8 @@ const activeIdAtom = atom((get) => { }); function getEditOptions( - item: Model, -): ReturnType['getEditOptions']>> { + item: SidebarModel, +): ReturnType['getEditOptions']>> { return { onChange: handleSubmitEdit, defaultValue: resolvedModelName(item), @@ -257,18 +187,18 @@ function getEditOptions( }; } -async function handleSubmitEdit(item: Model, text: string) { +async function handleSubmitEdit(item: SidebarModel, text: string) { await patchModel(item, { name: text }); } -function handleActivate(item: Model) { +function handleActivate(item: SidebarModel) { // TODO: Add folder layout support if (item.model !== 'folder' && item.model !== 'workspace') { navigateToRequestOrFolderOrWorkspace(item.id, item.model); } } -const allPotentialChildrenAtom = atom((get) => { +const allPotentialChildrenAtom = atom((get) => { const requests = get(allRequestsAtom); const folders = get(foldersAtom); return [...requests, ...folders]; @@ -280,7 +210,7 @@ const sidebarTreeAtom = atom((get) => { const allModels = get(memoAllPotentialChildrenAtom); const activeWorkspace = get(activeWorkspaceAtom); - const childrenMap: Record[]> = {}; + const childrenMap: Record[]> = {}; for (const item of allModels) { if ('folderId' in item && item.folderId == null) { childrenMap[item.workspaceId] = childrenMap[item.workspaceId] ?? []; @@ -291,14 +221,14 @@ const sidebarTreeAtom = atom((get) => { } } - const treeParentMap: Record> = {}; + const treeParentMap: Record> = {}; if (activeWorkspace == null) { return null; } // Put requests and folders into a tree structure - const next = (node: TreeNode, depth: number): TreeNode => { + const next = (node: TreeNode, depth: number): TreeNode => { const childItems = childrenMap[node.item.id] ?? []; // Recurse to children @@ -326,10 +256,10 @@ const sidebarTreeAtom = atom((get) => { }); const actions = { - 'sidebar.delete_selected_item': async function (items: Model[]) { + 'sidebar.delete_selected_item': async function (items: SidebarModel[]) { await deleteModelWithConfirm(items); }, - 'model.duplicate': async function (items: Model[]) { + 'model.duplicate': async function (items: SidebarModel[]) { if (items.length === 1) { const item = items[0]!; const newId = await duplicateModel(item); @@ -338,22 +268,29 @@ const actions = { await Promise.all(items.map(duplicateModel)); } }, - 'request.send': async function (items: Model[]) { + 'request.send': async function (items: SidebarModel[]) { await Promise.all( items.filter((i) => i.model === 'http_request').map((i) => sendAnyHttpRequest.mutate(i.id)), ); }, } as const; -const hotkeys: TreeProps['hotkeys'] = { +const hotkeys: TreeProps['hotkeys'] = { priority: 10, // So these ones take precedence over global hotkeys when the sidebar is focused actions, enable: () => isSidebarFocused(), }; -async function getContextMenu(items: Model[]): Promise { +async function getContextMenu(items: SidebarModel[]): Promise { + const workspaceId = jotaiStore.get(activeWorkspaceIdAtom); const child = items[0]; - if (child == null) return []; + + // No children means we're in the root + if (child == null) { + console.log('HELLO', child); + return getCreateDropdownItems({ workspaceId, activeRequest: null, folderId: null }); + } + const workspaces = jotaiStore.get(workspacesAtom); const onlyHttpRequests = items.every((i) => i.model === 'http_request'); @@ -411,7 +348,13 @@ async function getContextMenu(items: Model[]): Promise { }, })), ]; - + const modelCreationItems: DropdownItem[] = + items.length === 1 && child.model === 'folder' + ? [ + { type: 'separator' }, + ...getCreateDropdownItems({ workspaceId, activeRequest: null, folderId: child.id }), + ] + : []; const menuItems: ContextMenuProps['items'] = [ ...initialItems, { type: 'separator', hidden: initialItems.filter((v) => !v.hidden).length === 0 }, @@ -455,6 +398,83 @@ async function getContextMenu(items: Model[]): Promise { leftSlot: , onSelect: () => actions['sidebar.delete_selected_item'](items), }, + ...modelCreationItems, ]; return menuItems; } + +function getItemKey(item: SidebarModel) { + const responses = jotaiStore.get(httpResponsesAtom); + const latestResponse = responses.find((r) => r.requestId === item.id) ?? null; + const url = 'url' in item ? item.url : 'n/a'; + const method = 'method' in item ? item.method : 'n/a'; + return [ + item.id, + item.name, + url, + method, + latestResponse?.elapsed, + latestResponse?.id ?? 'n/a', + ].join('::'); +} + +const SidebarLeftSlot = memo(function SidebarLeftSlot({ + treeId, + item, +}: { + treeId: string; + item: SidebarModel; +}) { + if (item.model === 'folder') { + return ; + } else if (item.model === 'workspace') { + return null; + } else { + const isSelected = jotaiStore.get(isSelectedFamily({ treeId, itemId: item.id })); + return ( + + ); + } +}); + +const SidebarInnerItem = memo(function SidebarInnerItem({ + item, +}: { + treeId: string; + item: SidebarModel; +}) { + const response = useAtomValue( + useMemo( + () => + selectAtom( + atom((get) => [ + ...get(grpcConnectionsAtom), + ...get(httpResponsesAtom), + ...get(websocketConnectionsAtom), + ]), + (responses) => responses.find((r) => r.requestId === item.id), + (a, b) => a?.state === b?.state && a?.id === b?.id, // Only update when the response state changes updated + ), + [item.id], + ), + ); + + return ( +
+
{resolvedModelName(item)}
+ {response != null && ( +
+ {response.state !== 'closed' ? ( + + ) : response.model === 'http_response' ? ( + + ) : null} +
+ )} +
+ ); +}); diff --git a/src-web/components/core/tree/Tree.tsx b/src-web/components/core/tree/Tree.tsx index d47882b0..2cb562f1 100644 --- a/src-web/components/core/tree/Tree.tsx +++ b/src-web/components/core/tree/Tree.tsx @@ -9,14 +9,23 @@ import { } from '@dnd-kit/core'; import { type } from '@tauri-apps/plugin-os'; import classNames from 'classnames'; -import type { ComponentType, ReactElement, Ref, RefAttributes } from 'react'; -import { forwardRef, memo, useCallback, useImperativeHandle, useMemo, useRef } from 'react'; +import type { ComponentType, MouseEvent, ReactElement, Ref, RefAttributes } from 'react'; +import React, { + forwardRef, + memo, + useCallback, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; import { useKey, useKeyPressEvent } from 'react-use'; import type { HotkeyAction, HotKeyOptions } from '../../../hooks/useHotKey'; import { useHotKey } from '../../../hooks/useHotKey'; import { computeSideForDragMove } from '../../../lib/dnd'; import { jotaiStore } from '../../../lib/jotai'; -import type { ContextMenuProps } from '../Dropdown'; +import type { ContextMenuProps, DropdownItem } from '../Dropdown'; +import { ContextMenu } from '../Dropdown'; import { collapsedFamily, draggingIdsFamily, @@ -73,6 +82,15 @@ function TreeInner( ) { const treeRef = useRef(null); const selectableItems = useSelectableItems(root); + const [showContextMenu, setShowContextMenu] = useState<{ + items: DropdownItem[]; + x: number; + y: number; + } | null>(null); + + const handleCloseContextMenu = useCallback(() => { + setShowContextMenu(null); + }, []); const tryFocus = useCallback(() => { treeRef.current?.querySelector('.tree-item button[tabindex="0"]')?.focus(); @@ -403,11 +421,30 @@ function TreeInner( ItemLeftSlot, }; + const handleContextMenu = useCallback( + async (e: MouseEvent) => { + if (getContextMenu == null) return; + + e.preventDefault(); + e.stopPropagation(); + const items = await getContextMenu([]); + setShowContextMenu({ items, x: e.clientX, y: e.clientY }); + }, + [getContextMenu], + ); + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); return ( <> + {showContextMenu && ( + + )} ( >
(
{/* Assign root ID so we can reuse our same move/end logic */} - + ) => void; +}) { const { setNodeRef } = useDroppable({ id }); - return
; + return
; } interface TreeHotKeyProps extends HotKeyOptions { diff --git a/src-web/components/core/tree/TreeItem.tsx b/src-web/components/core/tree/TreeItem.tsx index 4a833e79..09ebd46f 100644 --- a/src-web/components/core/tree/TreeItem.tsx +++ b/src-web/components/core/tree/TreeItem.tsx @@ -242,7 +242,6 @@ function TreeItem_({
{ - const activeRequest = jotaiStore.get(activeRequestAtom); - const folderId = - (folderIdOption === 'active-folder' ? activeRequest?.folderId : folderIdOption) ?? null; - if (workspaceId == null) return []; - - return [ - { - label: 'HTTP', - leftSlot: hideIcons ? undefined : , - onSelect: () => createRequestAndNavigate({ model: 'http_request', workspaceId, folderId }), - }, - { - label: 'GraphQL', - leftSlot: hideIcons ? undefined : , - onSelect: () => - createRequestAndNavigate({ - model: 'http_request', - workspaceId, - folderId, - bodyType: BODY_TYPE_GRAPHQL, - method: 'POST', - headers: [{ name: 'Content-Type', value: 'application/json', id: generateId() }], - }), - }, - { - label: 'gRPC', - leftSlot: hideIcons ? undefined : , - onSelect: () => createRequestAndNavigate({ model: 'grpc_request', workspaceId, folderId }), - }, - { - label: 'WebSocket', - leftSlot: hideIcons ? undefined : , - onSelect: () => - createRequestAndNavigate({ model: 'websocket_request', workspaceId, folderId }), - }, - ...((hideFolder - ? [] - : [ - { type: 'separator' }, - { - label: 'Folder', - leftSlot: hideIcons ? undefined : , - onSelect: () => createFolder.mutate({ folderId }), - }, - ]) as DropdownItem[]), - ]; - }, [folderIdOption, hideFolder, hideIcons, workspaceId]); + return getCreateDropdownItems({ hideFolder, hideIcons, folderId, activeRequest, workspaceId }); + }, [activeRequest, folderId, hideFolder, hideIcons, workspaceId]); return items; } + +export function getCreateDropdownItems({ + hideFolder, + hideIcons, + folderId: folderIdOption, + workspaceId, + activeRequest, +}: { + hideFolder?: boolean; + hideIcons?: boolean; + folderId?: string | null | 'active-folder'; + workspaceId: string | null; + activeRequest: HttpRequest | GrpcRequest | WebsocketRequest | null; +}): DropdownItem[] { + const folderId = + (folderIdOption === 'active-folder' ? activeRequest?.folderId : folderIdOption) ?? null; + if (workspaceId == null) return []; + + return [ + { + label: 'HTTP', + leftSlot: hideIcons ? undefined : , + onSelect: () => createRequestAndNavigate({ model: 'http_request', workspaceId, folderId }), + }, + { + label: 'GraphQL', + leftSlot: hideIcons ? undefined : , + onSelect: () => + createRequestAndNavigate({ + model: 'http_request', + workspaceId, + folderId, + bodyType: BODY_TYPE_GRAPHQL, + method: 'POST', + headers: [{ name: 'Content-Type', value: 'application/json', id: generateId() }], + }), + }, + { + label: 'gRPC', + leftSlot: hideIcons ? undefined : , + onSelect: () => createRequestAndNavigate({ model: 'grpc_request', workspaceId, folderId }), + }, + { + label: 'WebSocket', + leftSlot: hideIcons ? undefined : , + onSelect: () => + createRequestAndNavigate({ model: 'websocket_request', workspaceId, folderId }), + }, + ...((hideFolder + ? [] + : [ + { type: 'separator' }, + { + label: 'Folder', + leftSlot: hideIcons ? undefined : , + onSelect: () => createFolder.mutate({ folderId }), + }, + ]) as DropdownItem[]), + ]; +}