diff --git a/package-lock.json b/package-lock.json index d7bbdfa5..95978f6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1190,6 +1190,12 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@gilbarbara/deep-equal": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.3.1.tgz", + "integrity": "sha512-I7xWjLs2YSVMc5gGx1Z3ZG1lgFpITPndpi8Ku55GeEIKpACCPQNS/OTqQbxgTCfq0Ncvcc+CrFov96itVh6Qvw==", + "license": "MIT" + }, "node_modules/@grpc/grpc-js": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.12.1.tgz", @@ -15837,6 +15843,7 @@ "@codemirror/lang-xml": "^6.0.2", "@codemirror/language": "^6.6.0", "@codemirror/search": "^6.2.3", + "@gilbarbara/deep-equal": "^0.3.1", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.3", "@react-hook/size": "^2.1.2", diff --git a/src-tauri/yaak_models/bindings/models.ts b/src-tauri/yaak_models/bindings/models.ts index 8b7343f6..3aad7c90 100644 --- a/src-tauri/yaak_models/bindings/models.ts +++ b/src-tauri/yaak_models/bindings/models.ts @@ -12,7 +12,7 @@ export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, up export type Environment = { model: "environment", id: string, workspaceId: string, environmentId: string | null, createdAt: string, updatedAt: string, name: string, variables: Array, }; -export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, }; +export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id: string, }; export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, name: string, description: string, sortPriority: number, }; @@ -24,13 +24,13 @@ export type GrpcEvent = { model: "grpc_event", id: string, createdAt: string, up export type GrpcEventType = "info" | "error" | "client_message" | "server_message" | "connection_start" | "connection_end"; -export type GrpcMetadataEntry = { enabled?: boolean, name: string, value: string, }; +export type GrpcMetadataEntry = { enabled?: boolean, name: string, value: string, id: string, }; export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record, description: string, message: string, metadata: Array, method: string | null, name: string, service: string | null, sortPriority: number, url: string, }; export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, body: Record, bodyType: string | null, description: string, headers: Array, method: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; -export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, }; +export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id: string, }; export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array, remoteAddr: string | null, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; @@ -38,7 +38,7 @@ export type HttpResponseHeader = { name: string, value: string, }; export type HttpResponseState = "initialized" | "connected" | "closed"; -export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, }; +export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, id: string, }; export type KeyValue = { model: "key_value", createdAt: string, updatedAt: string, key: string, namespace: string, value: string, }; diff --git a/src-tauri/yaak_plugin_runtime/bindings/models.ts b/src-tauri/yaak_plugin_runtime/bindings/models.ts index 0ea9b8f7..518443ed 100644 --- a/src-tauri/yaak_plugin_runtime/bindings/models.ts +++ b/src-tauri/yaak_plugin_runtime/bindings/models.ts @@ -2,17 +2,17 @@ export type Environment = { model: "environment", id: string, workspaceId: string, environmentId: string | null, createdAt: string, updatedAt: string, name: string, variables: Array, }; -export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, }; +export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id: string, }; export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, name: string, description: string, sortPriority: number, }; -export type GrpcMetadataEntry = { enabled?: boolean, name: string, value: string, }; +export type GrpcMetadataEntry = { enabled?: boolean, name: string, value: string, id: string, }; export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record, description: string, message: string, metadata: Array, method: string | null, name: string, service: string | null, sortPriority: number, url: string, }; export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, body: Record, bodyType: string | null, description: string, headers: Array, method: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; -export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, }; +export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id: string, }; export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array, remoteAddr: string | null, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; @@ -20,6 +20,6 @@ export type HttpResponseHeader = { name: string, value: string, }; export type HttpResponseState = "initialized" | "connected" | "closed"; -export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, }; +export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, id: string, }; export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, name: string, description: string, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, }; diff --git a/src-web/components/FormMultipartEditor.tsx b/src-web/components/FormMultipartEditor.tsx index 8a5153cb..dcfc798e 100644 --- a/src-web/components/FormMultipartEditor.tsx +++ b/src-web/components/FormMultipartEditor.tsx @@ -18,6 +18,7 @@ export function FormMultipartEditor({ request, forceUpdateKey, onChange }: Props value: p.file ?? p.value, contentType: p.contentType, isFile: !!p.file, + id: p.id, })), [request.body.form], ); @@ -31,6 +32,7 @@ export function FormMultipartEditor({ request, forceUpdateKey, onChange }: Props contentType: p.contentType, file: p.isFile ? p.value : undefined, value: p.isFile ? undefined : p.value, + id: p.id, })), }), [onChange], diff --git a/src-web/components/FormUrlencodedEditor.tsx b/src-web/components/FormUrlencodedEditor.tsx index 2e5eeaae..416873b5 100644 --- a/src-web/components/FormUrlencodedEditor.tsx +++ b/src-web/components/FormUrlencodedEditor.tsx @@ -1,5 +1,5 @@ -import { useCallback, useMemo } from 'react'; import type { HttpRequest } from '@yaakapp-internal/models'; +import { useCallback, useMemo } from 'react'; import type { Pair, PairEditorProps } from './core/PairEditor'; import { PairOrBulkEditor } from './core/PairOrBulkEditor'; @@ -16,6 +16,7 @@ export function FormUrlencodedEditor({ request, forceUpdateKey, onChange }: Prop enabled: !!p.enabled, name: p.name || '', value: p.value || '', + id: p.id, })), [request.body.form], ); diff --git a/src-web/components/GrpcConnectionSetupPane.tsx b/src-web/components/GrpcConnectionSetupPane.tsx index 32409098..0b4a13fd 100644 --- a/src-web/components/GrpcConnectionSetupPane.tsx +++ b/src-web/components/GrpcConnectionSetupPane.tsx @@ -136,12 +136,8 @@ export function GrpcConnectionSetupPane({ () => [ { value: TAB_DESCRIPTION, - label: ( -
- Info - {activeRequest.description && } -
- ), + label: 'Info', + rightSlot: activeRequest.description && , }, { value: TAB_MESSAGE, label: 'Message' }, { @@ -187,10 +183,10 @@ export function GrpcConnectionSetupPane({ const activeTab = activeTabs?.[activeRequest.id]; const setActiveTab = useCallback( - (tab: string) => { - setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab })); - }, - [activeRequest.id, setActiveTabs], + (tab: string) => { + setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab })); + }, + [activeRequest.id, setActiveTabs], ); const handleMetadataChange = useCallback( @@ -224,7 +220,7 @@ export function GrpcConnectionSetupPane({ onUrlChange={handleChangeUrl} onCancel={onCancel} isLoading={isStreaming} - stateKey={'grpc_url.'+activeRequest.id} + stateKey={'grpc_url.' + activeRequest.id} /> )} - secondSlot={({ style }) => } + secondSlot={({ style }) => } /> ); } diff --git a/src-web/components/RecentRequestsDropdown.tsx b/src-web/components/RecentRequestsDropdown.tsx index 981d45fb..35a1da17 100644 --- a/src-web/components/RecentRequestsDropdown.tsx +++ b/src-web/components/RecentRequestsDropdown.tsx @@ -1,26 +1,29 @@ import { useNavigate } from '@tanstack/react-router'; import classNames from 'classnames'; -import { useMemo, useRef } from 'react'; +import { useCallback, useMemo, useRef } from 'react'; import { useKeyPressEvent } from 'react-use'; -import { useActiveRequest } from '../hooks/useActiveRequest'; -import { useActiveWorkspace } from '../hooks/useActiveWorkspace'; +import { getActiveWorkspaceId } from '../hooks/useActiveWorkspace'; +import { grpcRequestsAtom } from '../hooks/useGrpcRequests'; import { useHotKey } from '../hooks/useHotKey'; +import { httpRequestsAtom } from '../hooks/useHttpRequests'; import { useRecentRequests } from '../hooks/useRecentRequests'; -import { useRequests } from '../hooks/useRequests'; import { fallbackRequestName } from '../lib/fallbackRequestName'; -import type { ButtonProps } from './core/Button'; +import { jotaiStore } from '../lib/jotai'; import { Button } from './core/Button'; import type { DropdownItem, DropdownRef } from './core/Dropdown'; import { Dropdown } from './core/Dropdown'; import { HttpMethodTag } from './core/HttpMethodTag'; -export function RecentRequestsDropdown({ className }: Pick) { +interface Props { + activeRequestId: string | null; + activeRequestName: string; + className?: string; +} + +export function RecentRequestsDropdown({ className, activeRequestId, activeRequestName }: Props) { const dropdownRef = useRef(null); - const activeRequest = useActiveRequest(); - const activeWorkspace = useActiveWorkspace(); const [allRecentRequestIds] = useRecentRequests(); const recentRequestIds = useMemo(() => allRecentRequestIds.slice(1), [allRecentRequestIds]); - const requests = useRequests(); const navigate = useNavigate(); // Handle key-up @@ -39,9 +42,11 @@ export function RecentRequestsDropdown({ className }: Pick(() => { - if (activeWorkspace === null) return []; + const getItems = useCallback(() => { + const activeWorkspaceId = getActiveWorkspaceId(); + if (activeWorkspaceId === null) return []; + const requests = [...jotaiStore.get(httpRequestsAtom), ...jotaiStore.get(grpcRequestsAtom)]; const recentRequestItems: DropdownItem[] = []; for (const id of recentRequestIds) { const request = requests.find((r) => r.id === id); @@ -57,7 +62,7 @@ export function RecentRequestsDropdown({ className }: Pick ({ ...prev }), }); @@ -77,10 +82,10 @@ export function RecentRequestsDropdown({ className }: Pick + ); diff --git a/src-web/components/RequestPane.tsx b/src-web/components/RequestPane.tsx index 1460a4a9..bb9f3f12 100644 --- a/src-web/components/RequestPane.tsx +++ b/src-web/components/RequestPane.tsx @@ -17,6 +17,7 @@ import { useToast } from '../hooks/useToast'; import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest'; import { languageFromContentType } from '../lib/contentType'; import { tryFormatJson } from '../lib/formatters'; +import { generateId } from '../lib/generateId'; import { AUTH_TYPE_BASIC, AUTH_TYPE_BEARER, @@ -86,6 +87,11 @@ export const RequestPane = memo(function RequestPane({ const handleContentTypeChange = useCallback( async (contentType: string | null) => { + if (activeRequest == null || activeRequest.model !== 'http_request') { + console.error('Failed to get active request to update', activeRequest); + return; + } + const headers = activeRequest.headers.filter((h) => h.name.toLowerCase() !== 'content-type'); if (contentType != null) { @@ -93,14 +99,15 @@ export const RequestPane = memo(function RequestPane({ name: 'Content-Type', value: contentType, enabled: true, + id: generateId(), }); } - await updateRequest.mutateAsync({ id: activeRequestId, update: { headers } }); + await updateRequest.mutateAsync({ id: activeRequest.id, update: { headers } }); // Force update header editor so any changed headers are reflected setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100); }, - [activeRequest.headers, activeRequestId, updateRequest], + [activeRequest, updateRequest], ); const toast = useToast(); @@ -116,51 +123,39 @@ export const RequestPane = memo(function RequestPane({ if (index >= 0) { items[index]!.readOnlyName = true; } else { - items.push({ name, value: '', enabled: true, readOnlyName: true }); + items.push({ name, value: '', enabled: true, readOnlyName: true, id: generateId() }); } } return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(',') }; }, [activeRequest.url, activeRequest.urlParameters]); - const tabs: TabItem[] = useMemo( + let numParams = 0; + if ( + activeRequest.bodyType === BODY_TYPE_FORM_URLENCODED || + activeRequest.bodyType === BODY_TYPE_FORM_MULTIPART + ) { + const n = Array.isArray(activeRequest.body?.form) + ? activeRequest.body.form.filter((p) => p.name).length + : 0; + numParams = n; + } + + const tabs = useMemo( () => [ { value: TAB_DESCRIPTION, - label: ( -
- Info - {activeRequest.description && } -
- ), + label: 'Info', + rightSlot: activeRequest.description ? : null, }, { value: TAB_BODY, + rightSlot: numParams > 0 ? : null, options: { value: activeRequest.bodyType, items: [ { type: 'separator', label: 'Form Data' }, - { - label: ( - <> - Url Encoded - - - ), - value: BODY_TYPE_FORM_URLENCODED, - }, - { - label: ( - <> - Url Encoded - - - ), - value: BODY_TYPE_FORM_MULTIPART, - }, + { label: 'Url Encoded', value: BODY_TYPE_FORM_URLENCODED }, + { label: 'Multi-Part', value: BODY_TYPE_FORM_MULTIPART }, { type: 'separator', label: 'Text Content' }, { label: 'GraphQL', value: BODY_TYPE_GRAPHQL }, { label: 'JSON', value: BODY_TYPE_JSON }, @@ -221,21 +216,13 @@ export const RequestPane = memo(function RequestPane({ }, { value: TAB_PARAMS, - label: ( -
- Params - -
- ), + rightSlot: , + label: 'Params', }, { value: TAB_HEADERS, - label: ( -
- Headers - h.name).length} /> -
- ), + label: 'Headers', + rightSlot: h.name).length} />, }, { value: TAB_AUTH, @@ -271,13 +258,13 @@ export const RequestPane = memo(function RequestPane({ [ activeRequest.authentication, activeRequest.authenticationType, - activeRequest.body, activeRequest.bodyType, activeRequest.description, activeRequest.headers, activeRequest.method, activeRequestId, handleContentTypeChange, + numParams, toast, updateRequest, urlParameterPairs.length, @@ -285,7 +272,7 @@ export const RequestPane = memo(function RequestPane({ ); const sendRequest = useSendAnyHttpRequest(); - const { activeResponse } = usePinnedHttpResponse(activeRequest); + const { activeResponse } = usePinnedHttpResponse(activeRequestId); const cancelResponse = useCancelHttpResponse(activeResponse?.id ?? null); const isLoading = useIsResponseLoading(activeRequestId); const { updateKey } = useRequestUpdateKey(activeRequestId); diff --git a/src-web/components/ResponsePane.tsx b/src-web/components/ResponsePane.tsx index 21dfedf8..d6ca33c9 100644 --- a/src-web/components/ResponsePane.tsx +++ b/src-web/components/ResponsePane.tsx @@ -1,4 +1,4 @@ -import type { HttpRequest, HttpResponse } from '@yaakapp-internal/models'; +import type { HttpResponse } from '@yaakapp-internal/models'; import classNames from 'classnames'; import type { CSSProperties, ReactNode } from 'react'; import React, { memo, useCallback, useMemo } from 'react'; @@ -32,15 +32,19 @@ import { VideoViewer } from './responseViewers/VideoViewer'; interface Props { style?: CSSProperties; className?: string; - activeRequest: HttpRequest; + activeRequestId: string; } const TAB_BODY = 'body'; const TAB_HEADERS = 'headers'; const TAB_INFO = 'info'; -export const ResponsePane = memo(function ResponsePane({ style, className, activeRequest }: Props) { - const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequest); +export const ResponsePane = memo(function ResponsePane({ + style, + className, + activeRequestId, +}: Props) { + const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId); const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId); const [activeTabs, setActiveTabs] = useLocalStorage>( 'responsePaneActiveTabs', @@ -64,13 +68,11 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ }, { value: TAB_HEADERS, - label: ( -
- Headers - h.name && h.value).length ?? 0} - /> -
+ label: 'Headers', + rightSlot: ( + h.name && h.value).length ?? 0} + /> ), }, { @@ -80,12 +82,12 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ ], [activeResponse?.headers, contentType, setViewMode, viewMode], ); - const activeTab = activeTabs?.[activeRequest.id]; + const activeTab = activeTabs?.[activeRequestId]; const setActiveTab = useCallback( - (tab: string) => { - setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab })); - }, - [activeRequest.id, setActiveTabs], + (tab: string) => { + setActiveTabs((r) => ({ ...r, [activeRequestId]: tab })); + }, + [activeRequestId, setActiveTabs], ); const isLoading = isResponseLoading(activeResponse); @@ -150,7 +152,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ ) : ( (null); - const folders = useFolders(); - const requests = useRequests(); const activeWorkspace = useActiveWorkspace(); const httpResponses = useHttpResponses(); const grpcConnections = useGrpcConnections(); @@ -51,64 +54,7 @@ export const Sidebar = memo(function Sidebar({ className }: Props) { const [hoveredIndex, setHoveredIndex] = useState(null); const navigate = useNavigate(); - const { tree, treeParentMap, selectableRequests } = useMemo<{ - tree: SidebarTreeNode | null; - treeParentMap: Record; - selectableRequests: { - id: string; - index: number; - tree: SidebarTreeNode; - }[]; - }>(() => { - const childrenMap: Record = {}; - for (const item of [...requests, ...folders]) { - if (item.folderId == null) { - childrenMap[item.workspaceId] = childrenMap[item.workspaceId] ?? []; - childrenMap[item.workspaceId]!.push(item); - } else { - childrenMap[item.folderId] = childrenMap[item.folderId] ?? []; - childrenMap[item.folderId]!.push(item); - } - } - - const treeParentMap: Record = {}; - const selectableRequests: { - id: string; - index: number; - tree: SidebarTreeNode; - }[] = []; - - if (activeWorkspace == null) { - return { tree: null, treeParentMap, selectableRequests }; - } - - const selectedRequest: HttpRequest | GrpcRequest | null = null; - let selectableRequestIndex = 0; - - // Put requests and folders into a tree structure - const next = (node: SidebarTreeNode): SidebarTreeNode => { - const childItems = childrenMap[node.item.id] ?? []; - - // Recurse to children - 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') { - 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, requests, folders]); + const { tree, treeParentMap, selectableRequests } = useAtomValue(sidebarTreeAtom); const focusActiveRequest = useCallback( ( @@ -124,8 +70,7 @@ export const Sidebar = memo(function Sidebar({ className }: Props) { 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; + const id = forced?.id ?? children.find((m) => m.id === activeRequest?.id)?.id ?? null; setHasFocus(true); setSelectedId(id); @@ -145,19 +90,17 @@ export const Sidebar = memo(function Sidebar({ className }: Props) { async (id: string) => { 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') { + const node = children.find((m) => m.id === id) ?? null; + if (node == null || tree == null || node.model === 'workspace') { return; } - const { item } = node; - - if (item.model === 'http_request' || item.model === 'grpc_request') { + if (node.model === 'http_request' || node.model === 'grpc_request') { await navigate({ to: '/workspaces/$workspaceId/requests/$requestId', params: { requestId: id, - workspaceId: item.workspaceId, + workspaceId: node.workspaceId ?? 'n/a', }, search: (prev) => ({ ...prev }), }); @@ -259,14 +202,15 @@ export const Sidebar = memo(function Sidebar({ className }: Props) { const handleMove = useCallback( async (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; + 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; + 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.item.id === id) ?? null; + hoveredTree = hoveredTree?.children.find((n) => n.id === id) ?? null; hoveredIndex = 0; } @@ -290,19 +234,19 @@ export const Sidebar = memo(function Sidebar({ className }: Props) { } // Block dragging folder into itself - if (hoveredTree.item.id === itemId) { + if (hoveredTree.id === itemId) { return; } const parentTree = treeParentMap[itemId] ?? null; - const index = parentTree?.children.findIndex((n) => n.item.id === itemId) ?? -1; + 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.item.id !== parentTree.item.id; + const movedToDifferentTree = hoveredTree.id !== parentTree.id; const movedUpInSameTree = !movedToDifferentTree && hoveredIndex < index; - const newChildren = hoveredTree.children.filter((c) => c.item.id !== itemId); + 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); @@ -311,42 +255,42 @@ export const Sidebar = memo(function Sidebar({ className }: Props) { 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 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.item.model === 'folder' ? hoveredTree.item.id : null; + 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.item.model === 'folder') { + if (child.model === 'folder') { const updateFolder = (f: Folder) => ({ ...f, sortPriority, folderId }); - return updateAnyFolder({ id: child.item.id, update: updateFolder }); - } else if (child.item.model === 'grpc_request') { + return updateAnyFolder({ id: child.id, update: updateFolder }); + } else if (child.model === 'grpc_request') { const updateRequest = (r: GrpcRequest) => ({ ...r, sortPriority, folderId }); - return updateAnyGrpcRequest({ id: child.item.id, update: updateRequest }); - } else if (child.item.model === 'http_request') { + return updateAnyGrpcRequest({ id: child.id, update: updateRequest }); + } else if (child.model === 'http_request') { const updateRequest = (r: HttpRequest) => ({ ...r, sortPriority, folderId }); - return updateAnyHttpRequest({ id: child.item.id, update: updateRequest }); + return updateAnyHttpRequest({ id: child.id, update: updateRequest }); } }), ); } else { const sortPriority = afterPriority - (afterPriority - beforePriority) / 2; - if (child.item.model === 'folder') { + if (child.model === 'folder') { const updateFolder = (f: Folder) => ({ ...f, sortPriority, folderId }); - await updateAnyFolder({ id: child.item.id, update: updateFolder }); - } else if (child.item.model === 'grpc_request') { + await updateAnyFolder({ id: child.id, update: updateFolder }); + } else if (child.model === 'grpc_request') { const updateRequest = (r: GrpcRequest) => ({ ...r, sortPriority, folderId }); - await updateAnyGrpcRequest({ id: child.item.id, update: updateRequest }); - } else if (child.item.model === 'http_request') { + await updateAnyGrpcRequest({ id: child.id, update: updateRequest }); + } else if (child.model === 'http_request') { const updateRequest = (r: HttpRequest) => ({ ...r, sortPriority, folderId }); - await updateAnyHttpRequest({ id: child.item.id, update: updateRequest }); + await updateAnyHttpRequest({ id: child.id, update: updateRequest }); } } setDraggingId(null); @@ -420,4 +364,4 @@ export const Sidebar = memo(function Sidebar({ className }: Props) { ); -}); +} diff --git a/src-web/components/SidebarAtoms.ts b/src-web/components/SidebarAtoms.ts index 1d18caa5..4724613c 100644 --- a/src-web/components/SidebarAtoms.ts +++ b/src-web/components/SidebarAtoms.ts @@ -1,5 +1,125 @@ +import deepEqual from '@gilbarbara/deep-equal'; +import type { Folder, GrpcRequest, HttpRequest } from '@yaakapp-internal/models'; // This is an atom so we can use it in the child items to avoid re-rendering the entire list -import {atom} from "jotai/index"; +import { atom } from 'jotai'; +import { selectAtom } from 'jotai/utils'; +import { activeWorkspaceAtom } from '../hooks/useActiveWorkspace'; +import { foldersAtom } from '../hooks/useFolders'; +import { grpcRequestsAtom } from '../hooks/useGrpcRequests'; +import { httpRequestsAtom } from '../hooks/useHttpRequests'; +import { fallbackRequestName } from '../lib/fallbackRequestName'; +import type { SidebarTreeNode } from './Sidebar'; export const sidebarSelectedIdAtom = atom(null); + +const allPotentialChildrenAtom = atom((get) => { + const httpRequests = get(httpRequestsAtom); + const grpcRequests = get(grpcRequestsAtom); + const folders = get(foldersAtom); + return [...httpRequests, ...folders, ...grpcRequests].map((v) => ({ + id: v.id, + model: v.model, + folderId: v.folderId, + name: fallbackRequestName(v), + workspaceId: v.workspaceId, + sortPriority: v.sortPriority, + })); +}); + +const memoAllPotentialChildrenAtom = selectAtom( + allPotentialChildrenAtom, + (v) => v, + (a, b) => deepEqual(a, b), +); + +export const sidebarTreeAtom = atom<{ + tree: SidebarTreeNode | null; + treeParentMap: Record; + selectableRequests: { + id: string; + index: number; + tree: SidebarTreeNode; + }[]; +}>((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 = {}; + const selectableRequests: { + id: string; + index: number; + tree: SidebarTreeNode; + }[] = []; + + if (activeWorkspace == null) { + return { tree: null, treeParentMap, selectableRequests }; + } + + const selectedRequest: HttpRequest | GrpcRequest | null = null; + let selectableRequestIndex = 0; + + // Put requests and folders into a tree structure + const next = (node: SidebarTreeNode): SidebarTreeNode => { + const childItems = childrenMap[node.id] ?? []; + + // Recurse to children + const depth = node.depth + 1; + childItems.sort((a, b) => a.sortPriority - b.sortPriority); + for (const childItem of childItems) { + treeParentMap[childItem.id] = node; + // Add to children + node.children.push(next(itemFromModel(childItem, depth))); + // Add to selectable requests + if (childItem.model !== 'folder') { + selectableRequests.push({ + id: childItem.id, + index: selectableRequestIndex++, + tree: node, + }); + } + } + + return node; + }; + + const tree = next({ + id: activeWorkspace.id, + name: activeWorkspace.name, + model: activeWorkspace.model, + children: [], + depth: 0, + }); + + return { tree, treeParentMap, selectableRequests, selectedRequest }; +}); + +function itemFromModel( + item: Pick< + Folder | HttpRequest | GrpcRequest, + 'folderId' | 'model' | 'workspaceId' | 'id' | 'name' | 'sortPriority' + >, + depth = 0, +): SidebarTreeNode { + return { + id: item.id, + name: item.name, + model: item.model, + sortPriority: 'sortPriority' in item ? item.sortPriority : -1, + workspaceId: item.workspaceId, + folderId: item.folderId, + depth, + children: [], + }; +} diff --git a/src-web/components/SidebarItem.tsx b/src-web/components/SidebarItem.tsx index 8bde27dd..387dd0b9 100644 --- a/src-web/components/SidebarItem.tsx +++ b/src-web/components/SidebarItem.tsx @@ -1,10 +1,14 @@ import type { AnyModel, GrpcConnection, HttpResponse } from '@yaakapp-internal/models'; import classNames from 'classnames'; +import { atom, useAtomValue } from 'jotai'; import type { ReactNode } from 'react'; -import React, { memo, useCallback, useEffect, useRef, useState } 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 { foldersAtom } from '../hooks/useFolders'; +import { grpcRequestsAtom } from '../hooks/useGrpcRequests'; +import { httpRequestsAtom } from '../hooks/useHttpRequests'; import { useScrollIntoView } from '../hooks/useScrollIntoView'; import { useSidebarItemCollapsed } from '../hooks/useSidebarItemCollapsed'; import { useUpdateAnyGrpcRequest } from '../hooks/useUpdateAnyGrpcRequest'; @@ -14,9 +18,9 @@ import { isResponseLoading } from '../lib/model_util'; import { HttpMethodTag } from './core/HttpMethodTag'; import { Icon } from './core/Icon'; import { StatusTag } from './core/StatusTag'; -import { RequestContextMenu } from './RequestContextMenu'; import type { SidebarTreeNode } from './Sidebar'; import { sidebarSelectedIdAtom } from './SidebarAtoms'; +import { SidebarItemContextMenu } from './SidebarItemContextMenu'; import type { SidebarItemsProps } from './SidebarItems'; enum ItemTypes { @@ -194,10 +198,29 @@ export const SidebarItem = memo(function SidebarItem({ const handleCloseContextMenu = useCallback(() => setShowContextMenu(null), []); - const itemPrefix = (child.item.model === 'http_request' || - child.item.model === 'grpc_request') && ( + const itemAtom = useMemo(() => { + return atom((get) => { + if (itemModel === 'http_request') { + return get(httpRequestsAtom).find((v) => v.id === itemId); + } else if (itemModel === 'grpc_request') { + return get(grpcRequestsAtom).find((v) => v.id === itemId); + } else if (itemModel === 'folder') { + return get(foldersAtom).find((v) => v.id === itemId); + } else { + return null; + } + }); + }, [itemId, itemModel]) + + const item = useAtomValue(itemAtom); + + if (item == null) { + return null; + } + + const itemPrefix = (item.model === 'http_request' || item.model === 'grpc_request') && ( ); @@ -205,7 +228,11 @@ export const SidebarItem = memo(function SidebarItem({ return (
  • - + ); } diff --git a/src-web/components/responseViewers/TextViewer.tsx b/src-web/components/responseViewers/TextViewer.tsx index 3e59db03..acf9b8f0 100644 --- a/src-web/components/responseViewers/TextViewer.tsx +++ b/src-web/components/responseViewers/TextViewer.tsx @@ -167,7 +167,7 @@ export function TextViewer({ language={language} actions={actions} extraExtensions={extraExtensions} - stateKey={null} + stateKey={`response_text.${responseId}`} /> ); } diff --git a/src-web/hooks/useActiveRequest.ts b/src-web/hooks/useActiveRequest.ts index 67aff110..03747709 100644 --- a/src-web/hooks/useActiveRequest.ts +++ b/src-web/hooks/useActiveRequest.ts @@ -1,5 +1,6 @@ import type { GrpcRequest, HttpRequest } from '@yaakapp-internal/models'; import { atom, useAtomValue } from 'jotai'; +import {fallbackRequestName} from "../lib/fallbackRequestName"; import {jotaiStore} from "../lib/jotai"; import { activeRequestIdAtom } from './useActiveRequestId'; import { grpcRequestsAtom } from './useGrpcRequests'; @@ -20,6 +21,11 @@ export function getActiveRequest() { return jotaiStore.get(activeRequestAtom); } +export const activeRequestNameAtom = atom(get => { + const activeRequest = get(activeRequestAtom); + return fallbackRequestName(activeRequest); +}); + export function useActiveRequest( model?: T | undefined, ): TypeMap[T] | null { diff --git a/src-web/hooks/useActiveWorkspace.ts b/src-web/hooks/useActiveWorkspace.ts index 9783e157..b20d80e3 100644 --- a/src-web/hooks/useActiveWorkspace.ts +++ b/src-web/hooks/useActiveWorkspace.ts @@ -3,18 +3,18 @@ import type { Workspace } from '@yaakapp-internal/models'; import { atom, useAtomValue } from 'jotai/index'; import { useEffect } from 'react'; import { jotaiStore } from '../lib/jotai'; -import { useWorkspaces } from './useWorkspaces'; +import { workspacesAtom } from './useWorkspaces'; export const activeWorkspaceIdAtom = atom(); -export function useActiveWorkspace(): Workspace | null { - const workspaceId = useActiveWorkspaceId(); - const workspaces = useWorkspaces(); - return workspaces.find((w) => w.id === workspaceId) ?? null; -} +export const activeWorkspaceAtom = atom((get) => { + const activeWorkspaceId = get(activeWorkspaceIdAtom); + const workspaces = get(workspacesAtom); + return workspaces.find((w) => w.id === activeWorkspaceId) ?? null; +}); -function useActiveWorkspaceId(): string | null { - return useAtomValue(activeWorkspaceIdAtom) ?? null; +export function useActiveWorkspace(): Workspace | null { + return useAtomValue(activeWorkspaceAtom); } export function getActiveWorkspaceId() { diff --git a/src-web/hooks/useCreateDropdownItems.tsx b/src-web/hooks/useCreateDropdownItems.tsx index 0dbc77fc..70901a43 100644 --- a/src-web/hooks/useCreateDropdownItems.tsx +++ b/src-web/hooks/useCreateDropdownItems.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import type { DropdownItem } from '../components/core/Dropdown'; import { Icon } from '../components/core/Icon'; +import { generateId } from '../lib/generateId'; import { BODY_TYPE_GRAPHQL } from '../lib/model_util'; import { useCreateFolder } from './useCreateFolder'; import { useCreateGrpcRequest } from './useCreateGrpcRequest'; @@ -36,7 +37,7 @@ export function useCreateDropdownItems({ folderId, bodyType: BODY_TYPE_GRAPHQL, method: 'POST', - headers: [{ name: 'Content-Type', value: 'application/json' }], + headers: [{ name: 'Content-Type', value: 'application/json', id: generateId() }], }), }, { diff --git a/src-web/hooks/useCreateGrpcRequest.ts b/src-web/hooks/useCreateGrpcRequest.ts index ccfc1d5f..4aaf89a3 100644 --- a/src-web/hooks/useCreateGrpcRequest.ts +++ b/src-web/hooks/useCreateGrpcRequest.ts @@ -2,15 +2,15 @@ import { useNavigate } from '@tanstack/react-router'; import type { GrpcRequest } from '@yaakapp-internal/models'; import { useSetAtom } from 'jotai'; import { trackEvent } from '../lib/analytics'; +import { jotaiStore } from '../lib/jotai'; import { invokeCmd } from '../lib/tauri'; import { getActiveRequest } from './useActiveRequest'; -import { useActiveWorkspace } from './useActiveWorkspace'; +import { activeWorkspaceAtom } from './useActiveWorkspace'; import { useFastMutation } from './useFastMutation'; import { grpcRequestsAtom } from './useGrpcRequests'; import { updateModelList } from './useSyncModelStores'; export function useCreateGrpcRequest() { - const workspace = useActiveWorkspace(); const setGrpcRequests = useSetAtom(grpcRequestsAtom); const navigate = useNavigate(); @@ -21,6 +21,7 @@ export function useCreateGrpcRequest() { >({ mutationKey: ['create_grpc_request'], mutationFn: async (patch) => { + const workspace = jotaiStore.get(activeWorkspaceAtom); if (workspace === null) { throw new Error("Cannot create grpc request when there's no active workspace"); } @@ -46,7 +47,7 @@ export function useCreateGrpcRequest() { // Optimistic update setGrpcRequests(updateModelList(request)); - navigate({ + await navigate({ to: '/workspaces/$workspaceId/requests/$requestId', params: { workspaceId: request.workspaceId, diff --git a/src-web/hooks/useImportQuerystring.ts b/src-web/hooks/useImportQuerystring.ts index 7e908424..ee1d0e96 100644 --- a/src-web/hooks/useImportQuerystring.ts +++ b/src-web/hooks/useImportQuerystring.ts @@ -1,3 +1,4 @@ +import {generateId} from "../lib/generateId"; import { useFastMutation } from './useFastMutation'; import type { HttpUrlParameter } from '@yaakapp-internal/models'; import { useToast } from './useToast'; @@ -27,6 +28,7 @@ export function useImportQuerystring(requestId: string) { name, value, enabled: true, + id: generateId(), })); await updateRequest.mutateAsync({ diff --git a/src-web/hooks/usePinnedHttpResponse.ts b/src-web/hooks/usePinnedHttpResponse.ts index 037d605e..13459172 100644 --- a/src-web/hooks/usePinnedHttpResponse.ts +++ b/src-web/hooks/usePinnedHttpResponse.ts @@ -1,10 +1,10 @@ -import type { HttpRequest, HttpResponse } from '@yaakapp-internal/models'; +import type { HttpResponse } from '@yaakapp-internal/models'; import { useHttpResponses } from './useHttpResponses'; import { useKeyValue } from './useKeyValue'; import { useLatestHttpResponse } from './useLatestHttpResponse'; -export function usePinnedHttpResponse(activeRequest: HttpRequest) { - const latestResponse = useLatestHttpResponse(activeRequest.id); +export function usePinnedHttpResponse(activeRequestId: string) { + const latestResponse = useLatestHttpResponse(activeRequestId); const { set, value: pinnedResponseId } = useKeyValue({ // Key on the latest response instead of activeRequest because responses change out of band of active request key: ['pinned_http_response_id', latestResponse?.id ?? 'n/a'], @@ -12,7 +12,7 @@ export function usePinnedHttpResponse(activeRequest: HttpRequest) { namespace: 'global', }); const allResponses = useHttpResponses(); - const responses = allResponses.filter((r) => r.requestId === activeRequest.id); + const responses = allResponses.filter((r) => r.requestId === activeRequestId); const activeResponse: HttpResponse | null = responses.find((r) => r.id === pinnedResponseId) ?? latestResponse; diff --git a/src-web/lib/fallbackRequestName.ts b/src-web/lib/fallbackRequestName.ts index e7d7e760..f39618ea 100644 --- a/src-web/lib/fallbackRequestName.ts +++ b/src-web/lib/fallbackRequestName.ts @@ -1,13 +1,17 @@ -import type { GrpcRequest, HttpRequest } from '@yaakapp-internal/models'; +import type {AnyModel, GrpcRequest, HttpRequest} from '@yaakapp-internal/models'; -export function fallbackRequestName(r: HttpRequest | GrpcRequest | null): string { +export function fallbackRequestName(r: HttpRequest | GrpcRequest | AnyModel | null): string { if (r == null) return ''; // Return name if it has one - if (r.name) { + if ('name' in r && r.name) { return r.name; } + if (r.model !== 'http_request' && r.model !== 'grpc_request') { + return 'No Name'; + } + // Replace variable syntax with variable name const withoutVariables = r.url.replace(/\$\{\[\s*([^\]\s]+)\s*]}/g, '$1'); if (withoutVariables.trim() === '') { diff --git a/src-web/package.json b/src-web/package.json index ab904bc3..dbe08ee4 100644 --- a/src-web/package.json +++ b/src-web/package.json @@ -16,6 +16,7 @@ "@codemirror/lang-xml": "^6.0.2", "@codemirror/language": "^6.6.0", "@codemirror/search": "^6.2.3", + "@gilbarbara/deep-equal": "^0.3.1", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.3", "@react-hook/size": "^2.1.2",