Websocket Support (#159)

This commit is contained in:
Gregory Schier
2025-01-31 09:00:11 -08:00
committed by GitHub
parent d411713502
commit c8be8082c5
122 changed files with 5090 additions and 616 deletions

View File

@@ -3,19 +3,21 @@ import classNames from 'classnames';
import { format } from 'date-fns';
import type { CSSProperties } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { useCopy } from '../hooks/useCopy';
import { useGrpcEvents } from '../hooks/useGrpcEvents';
import { usePinnedGrpcConnection } from '../hooks/usePinnedGrpcConnection';
import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { JsonAttributeTree } from './core/JsonAttributeTree';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout';
import { HStack, VStack } from './core/Stacks';
import { EmptyStateText } from './EmptyStateText';
import { RecentConnectionsDropdown } from './RecentConnectionsDropdown';
import { RecentGrpcConnectionsDropdown } from './RecentGrpcConnectionsDropdown';
interface Props {
style?: CSSProperties;
@@ -37,6 +39,7 @@ export function GrpcConnectionMessagesPane({ style, methodType, activeRequest }:
const { activeConnection, connections, setPinnedConnectionId } =
usePinnedGrpcConnection(activeRequest);
const events = useGrpcEvents(activeConnection?.id ?? null);
const copy = useCopy();
const activeEvent = useMemo(
() => events.find((m) => m.id === activeEventId) ?? null,
@@ -69,11 +72,13 @@ export function GrpcConnectionMessagesPane({ style, methodType, activeRequest }:
<Icon icon="refresh" size="sm" spin className="text-text-subtlest" />
)}
</HStack>
<RecentConnectionsDropdown
connections={connections}
activeConnection={activeConnection}
onPinnedConnectionId={setPinnedConnectionId}
/>
<div className="ml-auto">
<RecentGrpcConnectionsDropdown
connections={connections}
activeConnection={activeConnection}
onPinnedConnectionId={setPinnedConnectionId}
/>
</div>
</HStack>
<div className="overflow-y-auto h-full">
{activeConnection.error && (
@@ -107,8 +112,16 @@ export function GrpcConnectionMessagesPane({ style, methodType, activeRequest }:
{activeEvent.eventType === 'client_message' ||
activeEvent.eventType === 'server_message' ? (
<>
<div className="mb-2 select-text cursor-text font-semibold">
Message {activeEvent.eventType === 'client_message' ? 'Sent' : 'Received'}
<div className="mb-2 select-text cursor-text grid grid-cols-[minmax(0,1fr)_auto] items-center">
<div className="font-semibold">
Message {activeEvent.eventType === 'client_message' ? 'Sent' : 'Received'}
</div>
<IconButton
title="Copy message"
icon="copy"
size="xs"
onClick={() => copy(activeEvent.content)}
/>
</div>
{!showLarge && activeEvent.content.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">

View File

@@ -312,6 +312,7 @@ export function GrpcConnectionSetupPane({
<TabContent value="message">
<GrpcEditor
onChange={handleChangeMessage}
forceUpdateKey={forceUpdateKey}
services={services}
reflectionError={reflectionError}
reflectionLoading={reflectionLoading}

View File

@@ -23,7 +23,7 @@ import { InlineCode } from './core/InlineCode';
import { VStack } from './core/Stacks';
import { GrpcProtoSelection } from './GrpcProtoSelection';
type Props = Pick<EditorProps, 'heightMode' | 'onChange' | 'className'> & {
type Props = Pick<EditorProps, 'heightMode' | 'onChange' | 'className' | 'forceUpdateKey'> & {
services: ReflectResponseService[] | null;
reflectionError?: string;
reflectionLoading?: boolean;

View File

@@ -1,4 +1,4 @@
import type { HttpRequest } from '@yaakapp-internal/models';
import type { HttpRequestHeader } from '@yaakapp-internal/models';
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import { charsets } from '../lib/data/charsets';
import { connections } from '../lib/data/connections';
@@ -11,18 +11,19 @@ import { PairOrBulkEditor } from './core/PairOrBulkEditor';
type Props = {
forceUpdateKey: string;
request: HttpRequest;
onChange: (headers: HttpRequest['headers']) => void;
headers: HttpRequestHeader[];
stateKey: string;
onChange: (headers: HttpRequestHeader[]) => void;
};
export function HeadersEditor({ request, onChange, forceUpdateKey }: Props) {
export function HeadersEditor({ stateKey, headers, onChange, forceUpdateKey }: Props) {
return (
<PairOrBulkEditor
preferenceName="headers"
stateKey={`headers.${request.id}`}
stateKey={stateKey}
valueAutocompleteVariables
nameAutocompleteVariables
pairs={request.headers}
pairs={headers}
onChange={onChange}
forceUpdateKey={forceUpdateKey}
nameValidate={validateHttpHeader}

View File

@@ -1,5 +1,6 @@
import type { GrpcRequest, HttpRequest } from '@yaakapp-internal/models';
import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
import React, { useCallback } from 'react';
import { upsertWebsocketRequest } from '../commands/upsertWebsocketRequest';
import { useHttpAuthenticationConfig } from '../hooks/useHttpAuthenticationConfig';
import { useUpdateAnyGrpcRequest } from '../hooks/useUpdateAnyGrpcRequest';
import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest';
@@ -13,7 +14,7 @@ import { DynamicForm } from './DynamicForm';
import { EmptyStateText } from './EmptyStateText';
interface Props {
request: HttpRequest | GrpcRequest;
request: HttpRequest | GrpcRequest | WebsocketRequest;
}
export function HttpAuthenticationEditor({ request }: Props) {
@@ -32,6 +33,8 @@ export function HttpAuthenticationEditor({ request }: Props) {
id: request.id,
update: (r) => ({ ...r, authentication }),
});
} else if (request.model === 'websocket_request') {
upsertWebsocketRequest.mutate({ ...request, authentication });
} else {
updateGrpcRequest.mutate({
id: request.id,
@@ -39,7 +42,7 @@ export function HttpAuthenticationEditor({ request }: Props) {
});
}
},
[request.id, request.model, updateGrpcRequest, updateHttpRequest],
[request, updateGrpcRequest, updateHttpRequest],
);
if (authConfig.data == null) {

View File

@@ -2,8 +2,8 @@ import type { CSSProperties } from 'react';
import React from 'react';
import type { HttpRequest } from '@yaakapp-internal/models';
import { SplitLayout } from './core/SplitLayout';
import { RequestPane } from './RequestPane';
import { ResponsePane } from './ResponsePane';
import { HttpRequestPane } from './HttpRequestPane';
import { HttpResponsePane } from './HttpResponsePane';
interface Props {
activeRequest: HttpRequest;
@@ -17,13 +17,13 @@ export function HttpRequestLayout({ activeRequest, style }: Props) {
className="p-3 gap-1.5"
style={style}
firstSlot={({ orientation, style }) => (
<RequestPane
<HttpRequestPane
style={style}
activeRequest={activeRequest}
fullHeight={orientation === 'horizontal'}
/>
)}
secondSlot={({ style }) => <ResponsePane activeRequestId={activeRequest.id} style={style} />}
secondSlot={({ style }) => <HttpResponsePane activeRequestId={activeRequest.id} style={style} />}
/>
);
}

View File

@@ -4,7 +4,7 @@ import classNames from 'classnames';
import { atom, useAtom, useAtomValue } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
import type { CSSProperties } from 'react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { activeRequestIdAtom } from '../hooks/useActiveRequestId';
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders';
@@ -77,12 +77,7 @@ const nonActiveRequestUrlsAtom = atom((get) => {
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
export const RequestPane = memo(function RequestPane({
style,
fullHeight,
className,
activeRequest,
}: Props) {
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
const activeRequestId = activeRequest.id;
const { mutateAsync: updateRequestAsync, mutate: updateRequest } = useUpdateAnyHttpRequest();
const [activeTabs, setActiveTabs] = useAtom(tabsAtom);
@@ -94,7 +89,7 @@ export const RequestPane = memo(function RequestPane({
const handleContentTypeChange = useCallback(
async (contentType: string | null) => {
if (activeRequest == null || activeRequest.model !== 'http_request') {
if (activeRequest == null) {
console.error('Failed to get active request to update', activeRequest);
return;
}
@@ -381,7 +376,8 @@ export const RequestPane = memo(function RequestPane({
<TabContent value={TAB_HEADERS}>
<HeadersEditor
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
request={activeRequest}
headers={activeRequest.headers}
stateKey={`headers.${activeRequest.id}`}
onChange={(headers) => updateRequest({ id: activeRequestId, update: { headers } })}
/>
</TabContent>
@@ -492,4 +488,4 @@ export const RequestPane = memo(function RequestPane({
)}
</div>
);
});
}

View File

@@ -1,7 +1,7 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { CSSProperties, ReactNode } from 'react';
import React, { memo, useCallback, useMemo } from 'react';
import React, { useCallback, useMemo } from 'react';
import { useLocalStorage } from 'react-use';
import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
@@ -18,7 +18,7 @@ import { StatusTag } from './core/StatusTag';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText';
import { RecentResponsesDropdown } from './RecentResponsesDropdown';
import { RecentHttpResponsesDropdown } from './RecentHttpResponsesDropdown';
import { ResponseHeaders } from './ResponseHeaders';
import { ResponseInfo } from './ResponseInfo';
import { AudioViewer } from './responseViewers/AudioViewer';
@@ -40,11 +40,7 @@ const TAB_BODY = 'body';
const TAB_HEADERS = 'headers';
const TAB_INFO = 'info';
export const ResponsePane = memo(function ResponsePane({
style,
className,
activeRequestId,
}: Props) {
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const [activeTabs, setActiveTabs] = useLocalStorage<Record<string, string>>(
@@ -135,7 +131,7 @@ export const ResponsePane = memo(function ResponsePane({
<SizeTag contentLength={activeResponse.contentLength ?? 0} />
<div className="ml-auto">
<RecentResponsesDropdown
<RecentHttpResponsesDropdown
responses={responses}
activeResponse={activeResponse}
onPinnedResponseId={setPinnedResponseId}
@@ -206,7 +202,7 @@ export const ResponsePane = memo(function ResponsePane({
)}
</div>
);
});
}
function EnsureCompleteResponse({
response,

View File

@@ -47,7 +47,7 @@ export function LicenseBadge() {
className="!rounded-full mx-1"
onClick={async () => {
if (checkType === 'beta') {
await openUrl('https://feedback.yaak.app/p/yaak-20-feedback');
await openUrl('https://feedback.yaak.app');
} else {
openSettings.mutate();
}

View File

@@ -1,5 +1,6 @@
import type { GrpcRequest, HttpRequest } from '@yaakapp-internal/models';
import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
import React, { useState } from 'react';
import { upsertWebsocketRequest } from '../commands/upsertWebsocketRequest';
import { useUpdateAnyGrpcRequest } from '../hooks/useUpdateAnyGrpcRequest';
import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest';
import { useWorkspaces } from '../hooks/useWorkspaces';
@@ -13,7 +14,7 @@ import { VStack } from './core/Stacks';
interface Props {
activeWorkspaceId: string;
request: HttpRequest | GrpcRequest;
request: HttpRequest | GrpcRequest | WebsocketRequest;
onDone: () => void;
}
@@ -39,15 +40,17 @@ export function MoveToWorkspaceDialog({ onDone, request, activeWorkspaceId }: Pr
color="primary"
disabled={selectedWorkspaceId === activeWorkspaceId}
onClick={async () => {
const args = {
id: request.id,
update: { workspaceId: selectedWorkspaceId, folderId: null },
const update = {
workspaceId: selectedWorkspaceId,
folderId: null,
};
if (request.model === 'http_request') {
await updateHttpRequest.mutateAsync(args);
await updateHttpRequest.mutateAsync({ id: request.id, update });
} else if (request.model === 'grpc_request') {
await updateGrpcRequest.mutateAsync(args);
await updateGrpcRequest.mutateAsync({ id: request.id, update });
} else if (request.model === 'websocket_request') {
await upsertWebsocketRequest.mutateAsync({ ...request, ...update });
}
// Hide after a moment, to give time for request to disappear

View File

@@ -14,7 +14,7 @@ interface Props {
onPinnedConnectionId: (id: string) => void;
}
export function RecentConnectionsDropdown({
export function RecentGrpcConnectionsDropdown({
activeConnection,
connections,
onPinnedConnectionId,
@@ -38,7 +38,7 @@ export function RecentConnectionsDropdown({
disabled: connections.length === 0,
},
{ type: 'separator', label: 'History' },
...connections.slice(0, 20).map((c) => ({
...connections.map((c) => ({
label: (
<HStack space={2}>
{formatDistanceToNowStrict(c.createdAt + 'Z')} ago &bull;{' '}
@@ -53,7 +53,7 @@ export function RecentConnectionsDropdown({
<IconButton
title="Show connection history"
icon={activeConnection?.id === latestConnectionId ? 'chevron_down' : 'pin'}
className="ml-auto"
className="m-0.5"
size="sm"
iconSize="md"
/>

View File

@@ -17,7 +17,7 @@ interface Props {
className?: string;
}
export const RecentResponsesDropdown = function ResponsePane({
export const RecentHttpResponsesDropdown = function ResponsePane({
activeResponse,
responses,
onPinnedResponseId,
@@ -65,7 +65,7 @@ export const RecentResponsesDropdown = function ResponsePane({
disabled: responses.length === 0,
},
{ type: 'separator' },
...responses.slice(0, 20).map((r: HttpResponse) => ({
...responses.map((r: HttpResponse) => ({
label: (
<HStack space={2}>
<StatusTag className="text-sm" response={r} />

View File

@@ -2,11 +2,10 @@ import classNames from 'classnames';
import { useMemo, useRef } from 'react';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { getActiveWorkspaceId } from '../hooks/useActiveWorkspace';
import { grpcRequestsAtom } from '../hooks/useGrpcRequests';
import { useHotKey } from '../hooks/useHotKey';
import { httpRequestsAtom } from '../hooks/useHttpRequests';
import { useKeyboardEvent } from '../hooks/useKeyboardEvent';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { requestsAtom } from '../hooks/useRequests';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { jotaiStore } from '../lib/jotai';
import { router } from '../lib/router';
@@ -51,7 +50,7 @@ export function RecentRequestsDropdown({ className }: Props) {
const activeWorkspaceId = getActiveWorkspaceId();
if (activeWorkspaceId === null) return [];
const requests = [...jotaiStore.get(httpRequestsAtom), ...jotaiStore.get(grpcRequestsAtom)];
const requests = jotaiStore.get(requestsAtom);
const recentRequestItems: DropdownItem[] = [];
for (const id of recentRequestIds) {
const request = requests.find((r) => r.id === id);

View File

@@ -0,0 +1,69 @@
import type { WebsocketConnection } from '@yaakapp-internal/models';
import { formatDistanceToNowStrict } from 'date-fns';
import { deleteWebsocketConnection } from '../commands/deleteWebsocketConnection';
import { deleteWebsocketConnections } from '../commands/deleteWebsocketConnections';
import { websocketRequestsAtom } from '../hooks/useWebsocketRequests';
import { jotaiStore } from '../lib/jotai';
import { pluralizeCount } from '../lib/pluralize';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
interface Props {
connections: WebsocketConnection[];
activeConnection: WebsocketConnection;
onPinnedConnectionId: (id: string) => void;
}
export function RecentWebsocketConnectionsDropdown({
activeConnection,
connections,
onPinnedConnectionId,
}: Props) {
const latestConnectionId = connections[0]?.id ?? 'n/a';
return (
<Dropdown
items={[
{
label: 'Clear Connection',
onSelect: () => deleteWebsocketConnection.mutate(activeConnection),
disabled: connections.length === 0,
},
{
label: `Clear ${pluralizeCount('Connection', connections.length)}`,
onSelect: () => {
const request = jotaiStore
.get(websocketRequestsAtom)
.find((r) => r.id === activeConnection.requestId);
if (request != null) {
deleteWebsocketConnections.mutate(request);
}
},
hidden: connections.length <= 1,
disabled: connections.length === 0,
},
{ type: 'separator', label: 'History' },
...connections.map((c) => ({
label: (
<HStack space={2}>
{formatDistanceToNowStrict(c.createdAt + 'Z')} ago &bull;{' '}
<span className="font-mono text-sm">{c.elapsed}ms</span>
</HStack>
),
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedConnectionId(c.id),
})),
]}
>
<IconButton
title="Show connection history"
icon={activeConnection?.id === latestConnectionId ? 'chevron_down' : 'pin'}
className="m-0.5"
size="sm"
iconSize="md"
/>
</Dropdown>
);
}

View File

@@ -8,6 +8,7 @@ import type { IconProps } from './core/Icon';
import { IconButton } from './core/IconButton';
import type { InputProps } from './core/Input';
import { Input } from './core/Input';
import {HStack} from "./core/Stacks";
import { RequestMethodDropdown } from './RequestMethodDropdown';
type Props = Pick<HttpRequest, 'url'> & {
@@ -69,7 +70,7 @@ export const UrlBar = memo(function UrlBar({
ref={inputRef}
autocompleteVariables
stateKey={stateKey}
size="md"
size="sm"
wrapLines={isFocused}
hideLabel
useTemplating
@@ -99,10 +100,10 @@ export const UrlBar = memo(function UrlBar({
)
}
rightSlot={
<>
{rightSlot}
<HStack space={0.5}>
{rightSlot && <div className="py-0.5 h-full">{rightSlot}</div>}
{submitIcon !== null && (
<div className="py-0.5">
<div className="py-0.5 h-full">
<IconButton
size="xs"
iconSize="md"
@@ -114,7 +115,7 @@ export const UrlBar = memo(function UrlBar({
/>
</div>
)}
</>
</HStack>
}
/>
</form>

View File

@@ -0,0 +1,42 @@
import type { WebsocketRequest } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { CSSProperties } from 'react';
import React from 'react';
import { SplitLayout } from './core/SplitLayout';
import { WebsocketRequestPane } from './WebsocketRequestPane';
import { WebsocketResponsePane } from './WebsocketResponsePane';
interface Props {
activeRequest: WebsocketRequest;
style: CSSProperties;
}
export function WebsocketRequestLayout({ activeRequest, style }: Props) {
return (
<SplitLayout
name="websocket_layout"
className="p-3 gap-1.5"
style={style}
firstSlot={({ orientation, style }) => (
<WebsocketRequestPane
style={style}
activeRequest={activeRequest}
fullHeight={orientation === 'horizontal'}
/>
)}
secondSlot={({ style }) => (
<div
style={style}
className={classNames(
'x-theme-responsePane',
'max-h-full h-full grid grid-rows-[minmax(0,1fr)] grid-cols-1',
'bg-surface rounded-md border border-border-subtle',
'shadow relative',
)}
>
<WebsocketResponsePane activeRequest={activeRequest} />
</div>
)}
/>
);
}

View File

@@ -0,0 +1,325 @@
import type { HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import { closeWebsocket, connectWebsocket, sendWebsocket } from '@yaakapp-internal/ws';
import classNames from 'classnames';
import { atom, useAtom, useAtomValue } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
import type { CSSProperties } from 'react';
import React, { useCallback, useMemo } from 'react';
import { upsertWebsocketRequest } from '../commands/upsertWebsocketRequest';
import { getActiveCookieJar } from '../hooks/useActiveCookieJar';
import { getActiveEnvironment } from '../hooks/useActiveEnvironment';
import { activeRequestIdAtom } from '../hooks/useActiveRequestId';
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useHttpAuthenticationSummaries } from '../hooks/useHttpAuthentication';
import { useImportQuerystring } from '../hooks/useImportQuerystring';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor';
import { requestsAtom } from '../hooks/useRequests';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useLatestWebsocketConnection } from '../hooks/useWebsocketConnections';
import { trackEvent } from '../lib/analytics';
import { deepEqualAtom } from '../lib/atoms';
import { languageFromContentType } from '../lib/contentType';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { generateId } from '../lib/generateId';
import { CountBadge } from './core/CountBadge';
import { Editor } from './core/Editor/Editor';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
import { IconButton } from './core/IconButton';
import type { Pair } from './core/PairEditor';
import { PlainInput } from './core/PlainInput';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { HeadersEditor } from './HeadersEditor';
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
import { MarkdownEditor } from './MarkdownEditor';
import { UrlBar } from './UrlBar';
import { UrlParametersEditor } from './UrlParameterEditor';
interface Props {
style: CSSProperties;
fullHeight: boolean;
className?: string;
activeRequest: WebsocketRequest;
}
const TAB_MESSAGE = 'message';
const TAB_PARAMS = 'params';
const TAB_HEADERS = 'headers';
const TAB_AUTH = 'auth';
const TAB_DESCRIPTION = 'description';
const tabsAtom = atomWithStorage<Record<string, string>>('requestPaneActiveTabs', {});
const nonActiveRequestUrlsAtom = atom((get) => {
const activeRequestId = get(activeRequestIdAtom);
const requests = get(requestsAtom);
return requests
.filter((r) => r.id !== activeRequestId)
.map((r): GenericCompletionOption => ({ type: 'constant', label: r.url }));
});
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) {
const activeRequestId = activeRequest.id;
const [activeTabs, setActiveTabs] = useAtom(tabsAtom);
const { updateKey: forceUpdateKey } = useRequestUpdateKey(activeRequest.id ?? null);
const [{ urlKey }] = useRequestEditor();
const authentication = useHttpAuthenticationSummaries();
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map(
(m) => m[1] ?? '',
);
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
const items: Pair[] = [...nonEmptyParameters];
for (const name of placeholderNames) {
const index = items.findIndex((p) => p.name === name);
if (index >= 0) {
items[index]!.readOnlyName = true;
} else {
items.push({ name, value: '', enabled: true, readOnlyName: true, id: generateId() });
}
}
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(',') };
}, [activeRequest.url, activeRequest.urlParameters]);
const tabs = useMemo<TabItem[]>(() => {
// const options: Omit<RadioDropdownProps<WebsocketMessageType>, 'children'> = {
// value: activeRequest.messageType ?? 'text',
// items: [
// { label: 'Text', value: 'text' },
// { label: 'Binary', value: 'binary' },
// ],
// onChange: async (messageType) => {
// if (messageType === activeRequest.messageType) return;
// upsertWebsocketRequest.mutate({ ...activeRequest, messageType });
// },
// };
return [
{
value: TAB_MESSAGE,
label: 'Message',
} as TabItem,
{
value: TAB_PARAMS,
rightSlot: <CountBadge count={urlParameterPairs.length} />,
label: 'Params',
},
{
value: TAB_HEADERS,
label: 'Headers',
rightSlot: <CountBadge count={activeRequest.headers.filter((h) => h.name).length} />,
},
{
value: TAB_AUTH,
label: 'Auth',
options: {
value: activeRequest.authenticationType,
items: [
...authentication.map((a) => ({
label: a.label || 'UNKNOWN',
shortLabel: a.shortLabel,
value: a.name,
})),
{ type: 'separator' },
{ label: 'No Authentication', shortLabel: 'Auth', value: null },
],
onChange: async (authenticationType) => {
let authentication: HttpRequest['authentication'] = activeRequest.authentication;
if (activeRequest.authenticationType !== authenticationType) {
authentication = {
// Reset auth if changing types
};
}
upsertWebsocketRequest.mutate({
...activeRequest,
authenticationType,
authentication,
});
},
},
},
{
value: TAB_DESCRIPTION,
label: 'Info',
},
];
}, [activeRequest, authentication, urlParameterPairs.length]);
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
const { updateKey } = useRequestUpdateKey(activeRequestId);
const { mutate: importQuerystring } = useImportQuerystring(activeRequestId);
const connection = useLatestWebsocketConnection(activeRequestId);
const activeTab = activeTabs?.[activeRequestId];
const setActiveTab = useCallback(
(tab: string) => {
setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab }));
},
[activeRequest.id, setActiveTabs],
);
useRequestEditorEvent('request_pane.focus_tab', () => {
setActiveTab(TAB_PARAMS);
});
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
const autocomplete: GenericCompletionConfig = useMemo(
() => ({
minMatch: 3,
options:
autocompleteUrls.length > 0
? autocompleteUrls
: [
{ label: 'http://', type: 'constant' },
{ label: 'https://', type: 'constant' },
],
}),
[autocompleteUrls],
);
const handleConnect = useCallback(async () => {
await connectWebsocket({
requestId: activeRequest.id,
environmentId: getActiveEnvironment()?.id ?? null,
cookieJarId: getActiveCookieJar()?.id ?? null,
});
trackEvent('websocket_request', 'send');
}, [activeRequest.id]);
const handleSend = useCallback(async () => {
if (connection == null) return;
await sendWebsocket({
connectionId: connection?.id,
environmentId: getActiveEnvironment()?.id ?? null,
});
trackEvent('websocket_connection', 'send');
}, [connection]);
const handleCancel = useCallback(async () => {
if (connection == null) return;
await closeWebsocket({ connectionId: connection?.id });
trackEvent('websocket_connection', 'cancel');
}, [connection]);
const handleUrlChange = useCallback(
(url: string) => upsertWebsocketRequest.mutate({ ...activeRequest, url }),
[activeRequest],
);
const messageLanguage = languageFromContentType(null, activeRequest.message);
const isLoading = connection !== null && connection.state !== 'closed';
return (
<div
style={style}
className={classNames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}
>
{activeRequest && (
<>
<div className="grid grid-cols-[minmax(0,1fr)_auto]">
<UrlBar
stateKey={`url.${activeRequest.id}`}
key={forceUpdateKey + urlKey}
url={activeRequest.url}
submitIcon={isLoading ? 'send_horizontal' : 'arrow_up_down'}
rightSlot={
isLoading && (
<IconButton
size="xs"
title="Close connection"
icon="x"
className="w-8 mr-0.5 !h-full"
onClick={handleCancel}
/>
)
}
placeholder="wss://example.com"
onPasteOverwrite={importQuerystring}
autocomplete={autocomplete}
onSend={isLoading ? handleSend : handleConnect}
onCancel={cancelResponse}
onUrlChange={handleUrlChange}
forceUpdateKey={updateKey}
isLoading={activeResponse != null && activeResponse.state !== 'closed'}
method={null}
/>
</div>
<Tabs
key={activeRequest.id} // Freshen tabs on request change
value={activeTab}
label="Request"
onChangeValue={setActiveTab}
tabs={tabs}
tabListClassName="mt-2 !mb-1.5"
>
<TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor request={activeRequest} />
</TabContent>
<TabContent value={TAB_HEADERS}>
<HeadersEditor
forceUpdateKey={forceUpdateKey}
headers={activeRequest.headers}
stateKey={`headers.${activeRequest.id}`}
onChange={(headers) => upsertWebsocketRequest.mutate({ ...activeRequest, headers })}
/>
</TabContent>
<TabContent value={TAB_PARAMS}>
<UrlParametersEditor
stateKey={`params.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey + urlParametersKey}
pairs={urlParameterPairs}
onChange={(urlParameters) =>
upsertWebsocketRequest.mutate({ ...activeRequest, urlParameters })
}
/>
</TabContent>
<TabContent value={TAB_MESSAGE}>
<Editor
forceUpdateKey={forceUpdateKey}
useTemplating
autocompleteVariables
placeholder="..."
heightMode={fullHeight ? 'full' : 'auto'}
defaultValue={activeRequest.message}
language={messageLanguage}
onChange={(message) => upsertWebsocketRequest.mutate({ ...activeRequest, message })}
stateKey={`json.${activeRequest.id}`}
/>
</TabContent>
<TabContent value={TAB_DESCRIPTION}>
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
<PlainInput
label="Request Name"
hideLabel
forceUpdateKey={updateKey}
defaultValue={activeRequest.name}
className="font-sans !text-xl !px-0"
containerClassName="border-0"
placeholder={fallbackRequestName(activeRequest)}
onChange={(name) => upsertWebsocketRequest.mutate({ ...activeRequest, name })}
/>
<MarkdownEditor
name="request-description"
placeholder="Request description"
defaultValue={activeRequest.description}
stateKey={`description.${activeRequest.id}`}
forceUpdateKey={updateKey}
onChange={(description) =>
upsertWebsocketRequest.mutate({ ...activeRequest, description })
}
/>
</div>
</TabContent>
</Tabs>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,229 @@
import type { WebsocketEvent, WebsocketRequest } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { format } from 'date-fns';
import { hexy } from 'hexy';
import React, { useMemo, useState } from 'react';
import { useCopy } from '../hooks/useCopy';
import { useFormatText } from '../hooks/useFormatText';
import { usePinnedWebsocketConnection } from '../hooks/usePinnedWebsocketConnection';
import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { useWebsocketEvents } from '../hooks/useWebsocketEvents';
import { languageFromContentType } from '../lib/contentType';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { Editor } from './core/Editor/Editor';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout';
import { HStack, VStack } from './core/Stacks';
import { StatusTag } from './core/StatusTag';
import { EmptyStateText } from './EmptyStateText';
import { RecentWebsocketConnectionsDropdown } from './RecentWebsocketConnectionsDropdown';
interface Props {
activeRequest: WebsocketRequest;
}
export function WebsocketResponsePane({ activeRequest }: Props) {
const [activeEventId, setActiveEventId] = useState<string | null>(null);
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
const [showingLarge, setShowingLarge] = useState<boolean>(false);
const [hexDumps, setHexDumps] = useState<Record<string, boolean>>({});
const { activeConnection, connections, setPinnedConnectionId } =
usePinnedWebsocketConnection(activeRequest);
// const isLoading = activeConnection !== null && activeConnection.state !== 'closed';
const events = useWebsocketEvents(activeConnection?.id ?? null);
const activeEvent = useMemo(
() => events.find((m) => m.id === activeEventId) ?? null,
[activeEventId, events],
);
const hexDump = hexDumps[activeEventId ?? 'n/a'] ?? activeEvent?.messageType === 'binary';
const message = useMemo(() => {
if (hexDump) {
return activeEvent?.message ? hexy(activeEvent?.message) : '';
}
const text = activeEvent?.message
? new TextDecoder('utf-8').decode(Uint8Array.from(activeEvent.message))
: '';
return text;
}, [activeEvent?.message, hexDump]);
const language = languageFromContentType(null, message);
const formattedContent = useFormatText({ language, text: message, pretty: true });
const copy = useCopy();
return (
<SplitLayout
layout="vertical"
name="grpc_events"
defaultRatio={0.4}
minHeightPx={20}
firstSlot={() =>
activeConnection && (
<div className="w-full grid grid-rows-[auto_minmax(0,1fr)] items-center">
<HStack className="pl-3 mb-1 font-mono text-sm">
<HStack space={2}>
{activeConnection.state !== 'closed' && (
<Icon icon="refresh" size="sm" spin className="text-text-subtlest" />
)}
<StatusTag showReason response={activeConnection} />
<span>&bull;</span>
<span>{events.length} Messages</span>
</HStack>
<div className="ml-auto">
<RecentWebsocketConnectionsDropdown
connections={connections}
activeConnection={activeConnection}
onPinnedConnectionId={setPinnedConnectionId}
/>
</div>
</HStack>
<div className="overflow-y-auto h-full">
{activeConnection.error && (
<Banner color="danger" className="m-3">
{activeConnection.error}
</Banner>
)}
{...events.map((e) => (
<EventRow
key={e.id}
event={e}
isActive={e.id === activeEventId}
onClick={() => {
if (e.id === activeEventId) setActiveEventId(null);
else setActiveEventId(e.id);
}}
/>
))}
</div>
</div>
)
}
secondSlot={
activeEvent &&
(() => (
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
<div className="pb-3 px-2">
<Separator />
</div>
<div className="mx-2 overflow-y-auto grid grid-rows-[auto_minmax(0,1fr)]">
<div className="mb-2 select-text cursor-text grid grid-cols-[minmax(0,1fr)_auto] items-center">
<div className="font-semibold">
{activeEvent.messageType === 'close'
? 'Connection Closed'
: `Message ${activeEvent.isServer ? 'Received' : 'Sent'}`}
</div>
<HStack space={1}>
<Button
variant="border"
size="xs"
onClick={() => {
if (activeEventId == null) return;
setHexDumps({ ...hexDumps, [activeEventId]: !hexDump });
}}
>
{hexDump ? 'Show Message' : 'Show Hexdump'}
</Button>
<IconButton
title="Copy message"
icon="copy"
size="xs"
onClick={() => copy(message)}
/>
</HStack>
</div>
{!showLarge && activeEvent.message.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden
<div>
<Button
onClick={() => {
setShowingLarge(true);
setTimeout(() => {
setShowLarge(true);
setShowingLarge(false);
}, 500);
}}
isLoading={showingLarge}
color="secondary"
variant="border"
size="xs"
>
Try Showing
</Button>
</div>
</VStack>
) : activeEvent.message.length === 0 ? (
<EmptyStateText>No Content</EmptyStateText>
) : (
<Editor
language={language}
defaultValue={formattedContent.data ?? ''}
wrapLines={false}
readOnly={true}
stateKey={null}
/>
)}
</div>
</div>
))
}
/>
);
}
function EventRow({
onClick,
isActive,
event,
}: {
onClick?: () => void;
isActive?: boolean;
event: WebsocketEvent;
}) {
const { createdAt, message: messageBytes, isServer, messageType } = event;
const message = messageBytes
? new TextDecoder('utf-8').decode(Uint8Array.from(messageBytes))
: '';
return (
<div className="px-1">
<button
onClick={onClick}
className={classNames(
'w-full grid grid-cols-[auto_minmax(0,3fr)_auto] gap-2 items-center text-left',
'px-1.5 py-1 font-mono cursor-default group focus:outline-none rounded',
isActive && '!bg-surface-highlight !text-text',
'text-text-subtle hover:text',
)}
>
<Icon
className={classNames(
messageType === 'close' ? 'text-secondary' : isServer ? 'text-info' : 'text-primary',
)}
icon={
messageType === 'close'
? 'info'
: isServer
? 'arrow_big_down_dash'
: 'arrow_big_up_dash'
}
/>
<div className={classNames('w-full truncate text-xs')}>
{messageType === 'close'
? 'Connection closed by ' + (isServer ? 'server' : 'client')
: message.slice(0, 1000)}
{/*{error && <span className="text-warning"> ({error})</span>}*/}
</div>
<div className={classNames('opacity-50 text-xs')}>
{format(createdAt + 'Z', 'HH:mm:ss.SSS')}
</div>
</button>
</div>
);
}

View File

@@ -2,25 +2,28 @@ import classNames from 'classnames';
import { motion } from 'framer-motion';
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import {useEnsureActiveCookieJar, useSubscribeActiveCookieJarId} from "../hooks/useActiveCookieJar";
import {useSubscribeActiveEnvironmentId} from "../hooks/useActiveEnvironment";
import {getActiveRequest, useActiveRequest} from '../hooks/useActiveRequest';
import {useSubscribeActiveRequestId} from "../hooks/useActiveRequestId";
import {
useEnsureActiveCookieJar,
useSubscribeActiveCookieJarId,
} from '../hooks/useActiveCookieJar';
import { useSubscribeActiveEnvironmentId } from '../hooks/useActiveEnvironment';
import { getActiveRequest, useActiveRequest } from '../hooks/useActiveRequest';
import { useSubscribeActiveRequestId } from '../hooks/useActiveRequestId';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import {useDuplicateGrpcRequest} from "../hooks/useDuplicateGrpcRequest";
import {useDuplicateHttpRequest} from "../hooks/useDuplicateHttpRequest";
import { useDuplicateGrpcRequest } from '../hooks/useDuplicateGrpcRequest';
import { useDuplicateHttpRequest } from '../hooks/useDuplicateHttpRequest';
import { useFloatingSidebarHidden } from '../hooks/useFloatingSidebarHidden';
import {useHotKey} from "../hooks/useHotKey";
import { useHotKey } from '../hooks/useHotKey';
import { useImportData } from '../hooks/useImportData';
import {useSubscribeRecentCookieJars} from "../hooks/useRecentCookieJars";
import {useSubscribeRecentEnvironments} from "../hooks/useRecentEnvironments";
import {useSubscribeRecentRequests} from "../hooks/useRecentRequests";
import {useSubscribeRecentWorkspaces} from "../hooks/useRecentWorkspaces";
import { useSubscribeRecentCookieJars } from '../hooks/useRecentCookieJars';
import { useSubscribeRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useSubscribeRecentRequests } from '../hooks/useRecentRequests';
import { useSubscribeRecentWorkspaces } from '../hooks/useRecentWorkspaces';
import { useShouldFloatSidebar } from '../hooks/useShouldFloatSidebar';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useSidebarWidth } from '../hooks/useSidebarWidth';
import {useSyncWorkspaceRequestTitle} from "../hooks/useSyncWorkspaceRequestTitle";
import {useToggleCommandPalette} from "../hooks/useToggleCommandPalette";
import { useSyncWorkspaceRequestTitle } from '../hooks/useSyncWorkspaceRequestTitle';
import { useToggleCommandPalette } from '../hooks/useToggleCommandPalette';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
@@ -33,8 +36,9 @@ import { HeaderSize } from './HeaderSize';
import { HttpRequestLayout } from './HttpRequestLayout';
import { Overlay } from './Overlay';
import { ResizeHandle } from './ResizeHandle';
import { Sidebar } from './Sidebar';
import { SidebarActions } from './SidebarActions';
import { Sidebar } from './sidebar/Sidebar';
import { SidebarActions } from './sidebar/SidebarActions';
import { WebsocketRequestLayout } from './WebsocketRequestLayout';
import { WorkspaceHeader } from './WorkspaceHeader';
const side = { gridArea: 'side' };
@@ -213,9 +217,11 @@ function WorkspaceBody() {
if (activeRequest.model === 'grpc_request') {
return <GrpcConnectionLayout style={body} />;
} else if (activeRequest.model === 'websocket_request') {
return <WebsocketRequestLayout style={body} activeRequest={activeRequest} />;
} else {
return <HttpRequestLayout activeRequest={activeRequest} style={body} />;
}
return <HttpRequestLayout activeRequest={activeRequest} style={body} />;
}
function useGlobalWorkspaceHooks() {

View File

@@ -10,7 +10,7 @@ import { ImportCurlButton } from './ImportCurlButton';
import { LicenseBadge } from './LicenseBadge';
import { RecentRequestsDropdown } from './RecentRequestsDropdown';
import { SettingsDropdown } from './SettingsDropdown';
import { SidebarActions } from './SidebarActions';
import { SidebarActions } from './sidebar/SidebarActions';
import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
interface Props {

View File

@@ -1,15 +1,15 @@
import type { GrpcRequest, HttpRequest } from '@yaakapp-internal/models';
import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
import classNames from 'classnames';
interface Props {
request: HttpRequest | GrpcRequest;
request: HttpRequest | GrpcRequest | WebsocketRequest;
className?: string;
shortNames?: boolean;
}
const methodNames: Record<string, string> = {
get: ' GET',
put: ' PUT',
get: 'GET',
put: 'PUT',
post: 'POST',
patch: 'PTCH',
delete: 'DELE',
@@ -24,7 +24,11 @@ export function HttpMethodTag({ request, className }: Props) {
? 'GQL'
: request.model === 'grpc_request'
? 'GRPC'
: request.method;
: request.model === 'websocket_request'
? 'WS'
: (methodNames[request.method.toLowerCase()] ?? request.method.slice(0, 4));
const paddedMethod = method.padStart(4, ' ').toUpperCase();
return (
<span
@@ -34,7 +38,7 @@ export function HttpMethodTag({ request, className }: Props) {
'pt-[0.25em]', // Fix for monospace font not vertically centering
)}
>
{(methodNames[method.toLowerCase()] ?? method.slice(0, 4)).toUpperCase()}
{paddedMethod}
</span>
);
}

View File

@@ -1,8 +1,8 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import type {HttpResponse, WebsocketConnection} from '@yaakapp-internal/models';
import classNames from 'classnames';
interface Props {
response: HttpResponse;
response: HttpResponse | WebsocketConnection;
className?: string;
showReason?: boolean;
}
@@ -28,7 +28,7 @@ export function StatusTag({ response, className, showReason }: Props) {
)}
>
{isInitializing ? 'CONNECTING' : label}{' '}
{showReason && response.statusReason && response.statusReason}
{showReason && 'statusReason' in response ? response.statusReason : null}
</span>
);
}

View File

@@ -1,23 +1,31 @@
import type { Folder, GrpcRequest, HttpRequest, Workspace } from '@yaakapp-internal/models';
import type {
Folder,
GrpcRequest,
HttpRequest,
WebsocketRequest,
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 { upsertWebsocketRequest } from '../../commands/upsertWebsocketRequest';
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 { getWebsocketRequest } from '../../hooks/useWebsocketRequests';
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';
@@ -26,7 +34,7 @@ interface Props {
className?: string;
}
export type SidebarModel = Folder | GrpcRequest | HttpRequest | Workspace;
export type SidebarModel = Folder | GrpcRequest | HttpRequest | WebsocketRequest | Workspace;
export interface SidebarTreeNode {
id: string;
@@ -97,7 +105,7 @@ export function Sidebar({ className }: Props) {
}
// 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) {
if (node.model !== 'folder' && node.workspaceId) {
const workspaceId = node.workspaceId;
await router.navigate({
to: '/workspaces/$workspaceId',
@@ -281,6 +289,11 @@ export function Sidebar({ className }: Props) {
} else if (child.model === 'http_request') {
const updateRequest = (r: HttpRequest) => ({ ...r, sortPriority, folderId });
return updateAnyHttpRequest({ id: child.id, update: updateRequest });
} else if (child.model === 'websocket_request') {
const request = getWebsocketRequest(child.id);
return upsertWebsocketRequest.mutateAsync({ ...request, sortPriority, folderId });
} else {
throw new Error('Invalid model to update: ' + child.model);
}
}),
);
@@ -295,6 +308,11 @@ export function Sidebar({ className }: Props) {
} else if (child.model === 'http_request') {
const updateRequest = (r: HttpRequest) => ({ ...r, sortPriority, folderId });
await updateAnyHttpRequest({ id: child.id, update: updateRequest });
} else if (child.model === 'websocket_request') {
const request = getWebsocketRequest(child.id);
return upsertWebsocketRequest.mutateAsync({ ...request, sortPriority, folderId });
} else {
throw new Error('Invalid model to update: ' + child.model);
}
}
setDraggingId(null);

View File

@@ -1,11 +1,11 @@
import { useMemo } from 'react';
import { useFloatingSidebarHidden } from '../hooks/useFloatingSidebarHidden';
import { useShouldFloatSidebar } from '../hooks/useShouldFloatSidebar';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { trackEvent } from '../lib/analytics';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
import { CreateDropdown } from './CreateDropdown';
import { useFloatingSidebarHidden } from '../../hooks/useFloatingSidebarHidden';
import { useShouldFloatSidebar } from '../../hooks/useShouldFloatSidebar';
import { useSidebarHidden } from '../../hooks/useSidebarHidden';
import { trackEvent } from '../../lib/analytics';
import { IconButton } from '../core/IconButton';
import { HStack } from '../core/Stacks';
import { CreateDropdown } from '../CreateDropdown';
export function SidebarActions() {
const floating = useShouldFloatSidebar();

View File

@@ -1,22 +1,20 @@
import type { Folder, GrpcRequest, HttpRequest } from '@yaakapp-internal/models';
import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } 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';
import { activeWorkspaceAtom } from '../hooks/useActiveWorkspace';
import { foldersAtom } from '../hooks/useFolders';
import { grpcRequestsAtom } from '../hooks/useGrpcRequests';
import { httpRequestsAtom } from '../hooks/useHttpRequests';
import { deepEqualAtom } from '../lib/atoms';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
import { foldersAtom } from '../../hooks/useFolders';
import { requestsAtom } from '../../hooks/useRequests';
import { deepEqualAtom } from '../../lib/atoms';
import { fallbackRequestName } from '../../lib/fallbackRequestName';
import type { SidebarTreeNode } from './Sidebar';
export const sidebarSelectedIdAtom = atom<string | null>(null);
const allPotentialChildrenAtom = atom((get) => {
const httpRequests = get(httpRequestsAtom);
const grpcRequests = get(grpcRequestsAtom);
const requests = get(requestsAtom);
const folders = get(foldersAtom);
return [...httpRequests, ...folders, ...grpcRequests].map((v) => ({
return [...requests, ...folders].map((v) => ({
id: v.id,
model: v.model,
folderId: v.folderId,
@@ -62,7 +60,7 @@ export const sidebarTreeAtom = atom<{
return { tree: null, treeParentMap, selectableRequests };
}
const selectedRequest: HttpRequest | GrpcRequest | null = null;
const selectedRequest: HttpRequest | GrpcRequest | WebsocketRequest | null = null;
let selectableRequestIndex = 0;
// Put requests and folders into a tree structure
@@ -102,7 +100,7 @@ export const sidebarTreeAtom = atom<{
function itemFromModel(
item: Pick<
Folder | HttpRequest | GrpcRequest,
Folder | HttpRequest | GrpcRequest | WebsocketRequest,
'folderId' | 'model' | 'workspaceId' | 'id' | 'name' | 'sortPriority'
>,
depth = 0,

View File

@@ -5,18 +5,19 @@ 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 { 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';
import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest';
import { jotaiStore } from '../lib/jotai';
import { HttpMethodTag } from './core/HttpMethodTag';
import { Icon } from './core/Icon';
import { StatusTag } from './core/StatusTag';
import { upsertWebsocketRequest } from '../../commands/upsertWebsocketRequest';
import { activeRequestAtom } from '../../hooks/useActiveRequest';
import { foldersAtom } from '../../hooks/useFolders';
import { requestsAtom } from '../../hooks/useRequests';
import { useScrollIntoView } from '../../hooks/useScrollIntoView';
import { useSidebarItemCollapsed } from '../../hooks/useSidebarItemCollapsed';
import { useUpdateAnyGrpcRequest } from '../../hooks/useUpdateAnyGrpcRequest';
import { useUpdateAnyHttpRequest } from '../../hooks/useUpdateAnyHttpRequest';
import { getWebsocketRequest } from '../../hooks/useWebsocketRequests';
import { jotaiStore } from '../../lib/jotai';
import { HttpMethodTag } from '../core/HttpMethodTag';
import { Icon } from '../core/Icon';
import { StatusTag } from '../core/StatusTag';
import type { SidebarTreeNode } from './Sidebar';
import { sidebarSelectedIdAtom } from './SidebarAtoms';
import { SidebarItemContextMenu } from './SidebarItemContextMenu';
@@ -138,6 +139,10 @@ export const SidebarItem = memo(function SidebarItem({
id: itemId,
update: (r) => ({ ...r, name: el.value }),
});
} else if (itemModel === 'websocket_request') {
const request = getWebsocketRequest(itemId);
if (request == null) return;
await upsertWebsocketRequest.mutateAsync({ ...request, name: el.value });
}
setEditing(false);
},
@@ -167,7 +172,12 @@ export const SidebarItem = memo(function SidebarItem({
);
const handleStartEditing = useCallback(() => {
if (itemModel !== 'http_request' && itemModel !== 'grpc_request') return;
if (
itemModel !== 'http_request' &&
itemModel !== 'grpc_request' &&
itemModel !== 'websocket_request'
)
return;
setEditing(true);
}, [setEditing, itemModel]);
@@ -197,14 +207,10 @@ export const SidebarItem = memo(function SidebarItem({
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') {
if (itemModel === 'folder') {
return get(foldersAtom).find((v) => v.id === itemId);
} else {
return null;
return get(requestsAtom).find((v) => v.id === itemId);
}
});
}, [itemId, itemModel]);
@@ -215,7 +221,7 @@ export const SidebarItem = memo(function SidebarItem({
return null;
}
const itemPrefix = (item.model === 'http_request' || item.model === 'grpc_request') && (
const itemPrefix = item.model !== 'folder' && (
<HttpMethodTag
request={item}
className={classNames(!(active || selected) && 'text-text-subtlest')}

View File

@@ -1,24 +1,24 @@
import React, { useMemo } from 'react';
import { useCreateDropdownItems } from '../hooks/useCreateDropdownItems';
import { useDeleteFolder } from '../hooks/useDeleteFolder';
import { useDeleteAnyRequest } from '../hooks/useDeleteAnyRequest';
import { useDuplicateFolder } from '../hooks/useDuplicateFolder';
import { useDuplicateGrpcRequest } from '../hooks/useDuplicateGrpcRequest';
import { useDuplicateHttpRequest } from '../hooks/useDuplicateHttpRequest';
import { useHttpRequestActions } from '../hooks/useHttpRequestActions';
import { useMoveToWorkspace } from '../hooks/useMoveToWorkspace';
import { useRenameRequest } from '../hooks/useRenameRequest';
import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
import { useSendManyRequests } from '../hooks/useSendManyRequests';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { useCreateDropdownItems } from '../../hooks/useCreateDropdownItems';
import { useDeleteFolder } from '../../hooks/useDeleteFolder';
import { useDeleteAnyRequest } from '../../hooks/useDeleteAnyRequest';
import { useDuplicateFolder } from '../../hooks/useDuplicateFolder';
import { useDuplicateGrpcRequest } from '../../hooks/useDuplicateGrpcRequest';
import { useDuplicateHttpRequest } from '../../hooks/useDuplicateHttpRequest';
import { useHttpRequestActions } from '../../hooks/useHttpRequestActions';
import { useMoveToWorkspace } from '../../hooks/useMoveToWorkspace';
import { useRenameRequest } from '../../hooks/useRenameRequest';
import { useSendAnyHttpRequest } from '../../hooks/useSendAnyHttpRequest';
import { useSendManyRequests } from '../../hooks/useSendManyRequests';
import { useWorkspaces } from '../../hooks/useWorkspaces';
import { showDialog } from '../lib/dialog';
import type { DropdownItem } from './core/Dropdown';
import { ContextMenu } from './core/Dropdown';
import { Icon } from './core/Icon';
import { FolderSettingsDialog } from './FolderSettingsDialog';
import { showDialog } from '../../lib/dialog';
import type { DropdownItem } from '../core/Dropdown';
import { ContextMenu } from '../core/Dropdown';
import { Icon } from '../core/Icon';
import { FolderSettingsDialog } from '../FolderSettingsDialog';
import type { SidebarTreeNode } from './Sidebar';
import { getHttpRequest } from '../hooks/useHttpRequests';
import { getHttpRequest } from '../../hooks/useHttpRequests';
interface Props {
child: SidebarTreeNode;

View File

@@ -1,8 +1,8 @@
import type { GrpcConnection, HttpResponse } from '@yaakapp-internal/models';
import classNames from 'classnames';
import React, { Fragment, memo } from 'react';
import { VStack } from './core/Stacks';
import { DropMarker } from './DropMarker';
import { VStack } from '../core/Stacks';
import { DropMarker } from '../DropMarker';
import type { SidebarTreeNode } from './Sidebar';
import { SidebarItem } from './SidebarItem';