import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace, } from '@yaakapp-internal/models'; import { duplicateModel, foldersAtom, getModel, grpcConnectionsAtom, httpResponsesAtom, patchModel, websocketConnectionsAtom, workspacesAtom, } from '@yaakapp-internal/models'; import classNames from 'classnames'; import { atom, useAtomValue } from 'jotai'; import { selectAtom } from 'jotai/utils'; import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'; import { moveToWorkspace } from '../commands/moveToWorkspace'; import { openFolderSettings } from '../commands/openFolderSettings'; import { activeCookieJarAtom } from '../hooks/useActiveCookieJar'; import { activeEnvironmentAtom } from '../hooks/useActiveEnvironment'; import { activeFolderIdAtom } from '../hooks/useActiveFolderId'; import { activeRequestIdAtom } from '../hooks/useActiveRequestId'; 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'; import { sendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest'; import { useSidebarHidden } from '../hooks/useSidebarHidden'; import { deepEqualAtom } from '../lib/atoms'; import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm'; import { jotaiStore } from '../lib/jotai'; import { renameModelWithPrompt } from '../lib/renameModelWithPrompt'; import { resolvedModelName } from '../lib/resolvedModelName'; import { isSidebarFocused } from '../lib/scopes'; import { navigateToRequestOrFolderOrWorkspace } from '../lib/setWorkspaceSearchParams'; import { invokeCmd } from '../lib/tauri'; import type { ContextMenuProps, DropdownItem } from './core/Dropdown'; import { HttpMethodTag } from './core/HttpMethodTag'; import { HttpStatusTag } from './core/HttpStatusTag'; import { Icon } from './core/Icon'; import { LoadingIcon } from './core/LoadingIcon'; import { isSelectedFamily } from './core/tree/atoms'; import type { TreeNode } from './core/tree/common'; import type { TreeHandle, TreeProps } from './core/tree/Tree'; import { Tree } from './core/tree/Tree'; import type { TreeItemProps } from './core/tree/TreeItem'; import { GitDropdown } from './GitDropdown'; type SidebarModel = Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRequest; const OPACITY_SUBTLE = 'opacity-80'; function NewSidebar({ className }: { className?: string }) { const [hidden, setHidden] = useSidebarHidden(); const tree = useAtomValue(sidebarTreeAtom); const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id; const treeId = 'tree.' + (activeWorkspaceId ?? 'unknown'); const wrapperRef = useRef(null); const treeRef = useRef(null); const focusActiveItem = useCallback(() => { treeRef.current?.focus(); }, []); useHotKey('sidebar.focus', async function focusHotkey() { // Hide the sidebar if it's already focused if (!hidden && isSidebarFocused()) { await setHidden(true); return; } // Show the sidebar if it's hidden if (hidden) { await setHidden(false); } // Select the 0th index on focus if none selected focusActiveItem(); }); const handleDragEnd = useCallback(async function handleDragEnd({ items, parent, children, insertAt, }: { items: SidebarModel[]; parent: SidebarModel; children: SidebarModel[]; insertAt: number; }) { 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; const afterPriority = next?.sortPriority ?? 0; const shouldUpdateAll = afterPriority - beforePriority < 1; try { if (shouldUpdateAll) { // Add items to children at insertAt children.splice(insertAt, 0, ...items); await Promise.all( children.map((m, i) => patchModel(m, { sortPriority: i * 1000, folderId })), ); } else { const range = afterPriority - beforePriority; const increment = range / (items.length + 2); await Promise.all( items.map((m, i) => // Spread item sortPriority out over before/after range patchModel(m, { sortPriority: beforePriority + (i + 1) * increment, folderId }), ), ); } } catch (e) { console.error(e); } }, []); const handleTreeRefInit = useCallback((n: TreeHandle) => { treeRef.current = n; if (n == null) return; const activeId = jotaiStore.get(activeIdAtom); if (activeId == null) return; n.selectItem(activeId); }, []); useEffect(() => { return jotaiStore.sub(activeIdAtom, () => { const activeId = jotaiStore.get(activeIdAtom); if (activeId == null) return; treeRef.current?.selectItem(activeId); }); }, []); if (tree == null || hidden) { return null; } return ( ); } export default NewSidebar; const activeIdAtom = atom((get) => { return get(activeRequestIdAtom) || get(activeFolderIdAtom); }); function getEditOptions( item: SidebarModel, ): ReturnType['getEditOptions']>> { return { onChange: handleSubmitEdit, defaultValue: resolvedModelName(item), placeholder: item.name, }; } async function handleSubmitEdit(item: SidebarModel, text: string) { await patchModel(item, { name: text }); } 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 requests = get(allRequestsAtom); const folders = get(foldersAtom); return [...requests, ...folders]; }); const memoAllPotentialChildrenAtom = deepEqualAtom(allPotentialChildrenAtom); const sidebarTreeAtom = atom((get) => { const allModels = get(memoAllPotentialChildrenAtom); const activeWorkspace = get(activeWorkspaceAtom); const childrenMap: Record[]> = {}; for (const item of allModels) { if ('folderId' in item && item.folderId == null) { childrenMap[item.workspaceId] = childrenMap[item.workspaceId] ?? []; childrenMap[item.workspaceId]!.push(item); } else if ('folderId' in item && item.folderId != null) { childrenMap[item.folderId] = childrenMap[item.folderId] ?? []; childrenMap[item.folderId]!.push(item); } } const treeParentMap: Record> = {}; if (activeWorkspace == null) { return null; } // Put requests and folders into a tree structure const next = (node: TreeNode, depth: number): TreeNode => { const childItems = childrenMap[node.item.id] ?? []; // Recurse to children childItems.sort((a, b) => a.sortPriority - b.sortPriority); if (node.item.model === 'folder' || node.item.model === 'workspace') { node.children = node.children ?? []; for (const item of childItems) { treeParentMap[item.id] = node; node.children.push(next({ item, parent: node, depth }, depth + 1)); } } return node; }; return next( { item: activeWorkspace, children: [], parent: null, depth: 0, }, 1, ); }); const actions = { 'sidebar.delete_selected_item': async function (items: SidebarModel[]) { await deleteModelWithConfirm(items); }, 'model.duplicate': async function (items: SidebarModel[]) { if (items.length === 1) { const item = items[0]!; const newId = await duplicateModel(item); navigateToRequestOrFolderOrWorkspace(newId, item.model); } else { await Promise.all(items.map(duplicateModel)); } }, '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'] = { priority: 10, // So these ones take precedence over global hotkeys when the sidebar is focused actions, enable: () => isSidebarFocused(), }; async function getContextMenu(items: SidebarModel[]): Promise { const workspaceId = jotaiStore.get(activeWorkspaceIdAtom); const child = items[0]; // 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'); const initialItems: ContextMenuProps['items'] = [ { label: 'Folder Settings', hidden: !(items.length === 1 && child.model === 'folder'), leftSlot: , onSelect: () => openFolderSettings(child.id), }, { label: 'Send All', hidden: !(items.length === 1 && child.model === 'folder'), leftSlot: , onSelect: () => { const environment = jotaiStore.get(activeEnvironmentAtom); const cookieJar = jotaiStore.get(activeCookieJarAtom); invokeCmd('cmd_send_folder', { folderId: child.id, environmentId: environment?.id, cookieJarId: cookieJar?.id, }); }, }, { label: 'Send', hotKeyAction: 'request.send', hotKeyLabelOnly: true, hidden: !onlyHttpRequests, leftSlot: , onSelect: () => actions['request.send'](items), }, ...(items.length === 1 && child.model === 'http_request' ? await getHttpRequestActions() : [] ).map((a) => ({ label: a.label, // eslint-disable-next-line @typescript-eslint/no-explicit-any leftSlot: , onSelect: async () => { const request = getModel('http_request', child.id); if (request != null) await a.call(request); }, })), ...(items.length === 1 && child.model === 'grpc_request' ? await getGrpcRequestActions() : [] ).map((a) => ({ label: a.label, // eslint-disable-next-line @typescript-eslint/no-explicit-any leftSlot: , onSelect: async () => { const request = getModel('grpc_request', child.id); if (request != null) await a.call(request); }, })), ]; 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 }, { label: 'Rename', leftSlot: , hidden: items.length > 1, onSelect: async () => { const request = getModel( ['folder', 'http_request', 'grpc_request', 'websocket_request'], child.id, ); await renameModelWithPrompt(request); }, }, { label: 'Duplicate', hotKeyAction: 'model.duplicate', hotKeyLabelOnly: true, // Would trigger for every request (bad) leftSlot: , onSelect: () => actions['model.duplicate'](items), }, { label: 'Move', leftSlot: , hidden: workspaces.length <= 1 || items.length > 1 || child.model === 'folder' || child.model === 'workspace', onSelect: () => { if (child.model === 'folder' || child.model === 'workspace') return; moveToWorkspace.mutate(child); }, }, { color: 'danger', label: 'Delete', hotKeyAction: 'sidebar.delete_selected_item', hotKeyLabelOnly: true, 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}
)}
); });