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

@@ -0,0 +1,14 @@
import type { WebsocketConnection } from '@yaakapp-internal/models';
import { deleteWebsocketConnection as cmdDeleteWebsocketConnection } from '@yaakapp-internal/ws';
import { createFastMutation } from '../hooks/useFastMutation';
import { trackEvent } from '../lib/analytics';
export const deleteWebsocketConnection = createFastMutation({
mutationKey: ['delete_websocket_connection'],
mutationFn: async function (connection: WebsocketConnection) {
return cmdDeleteWebsocketConnection(connection.id);
},
onSuccess: async () => {
trackEvent('websocket_connection', 'delete');
},
});

View File

@@ -0,0 +1,14 @@
import type { WebsocketRequest } from '@yaakapp-internal/models';
import { deleteWebsocketConnections as cmdDeleteWebsocketConnections } from '@yaakapp-internal/ws';
import { createFastMutation } from '../hooks/useFastMutation';
import { trackEvent } from '../lib/analytics';
export const deleteWebsocketConnections = createFastMutation({
mutationKey: ['delete_websocket_connections'],
mutationFn: async function (request: WebsocketRequest) {
return cmdDeleteWebsocketConnections(request.id);
},
onSuccess: async () => {
trackEvent('websocket_connection', 'delete_many');
},
});

View File

@@ -0,0 +1,31 @@
import type {WebsocketRequest} from "@yaakapp-internal/models";
import { deleteWebsocketRequest as cmdDeleteWebsocketRequest } from '@yaakapp-internal/ws';
import { InlineCode } from '../components/core/InlineCode';
import { createFastMutation } from '../hooks/useFastMutation';
import { trackEvent } from '../lib/analytics';
import { showConfirm } from '../lib/confirm';
import { fallbackRequestName } from '../lib/fallbackRequestName';
export const deleteWebsocketRequest = createFastMutation({
mutationKey: ['delete_websocket_request'],
mutationFn: async (request: WebsocketRequest) => {
const confirmed = await showConfirm({
id: 'delete-websocket-request',
title: 'Delete WebSocket Request',
variant: 'delete',
description: (
<>
Permanently delete <InlineCode>{fallbackRequestName(request)}</InlineCode>?
</>
),
});
if (!confirmed) {
return null;
}
return cmdDeleteWebsocketRequest(request.id);
},
onSuccess: async () => {
trackEvent('websocket_request', 'delete');
},
});

View File

@@ -0,0 +1,27 @@
import type { WebsocketRequest } from '@yaakapp-internal/models';
import { upsertWebsocketRequest as cmdUpsertWebsocketRequest } from '@yaakapp-internal/ws';
import { differenceInMilliseconds } from 'date-fns';
import { createFastMutation } from '../hooks/useFastMutation';
import { trackEvent } from '../lib/analytics';
import { router } from '../lib/router';
export const upsertWebsocketRequest = createFastMutation<
WebsocketRequest,
void,
Parameters<typeof cmdUpsertWebsocketRequest>[0]
>({
mutationKey: ['upsert_websocket_request'],
mutationFn: (request) => cmdUpsertWebsocketRequest(request),
onSuccess: async (request) => {
const isNew = differenceInMilliseconds(new Date(), request.createdAt + 'Z') < 100;
if (isNew) {
trackEvent('websocket_request', 'create');
await router.navigate({
to: '/workspaces/$workspaceId',
params: { workspaceId: request.workspaceId },
search: (prev) => ({ ...prev, request_id: request.id }),
});
} else trackEvent('websocket_request', 'update');
},
});

View File

@@ -1,4 +1,5 @@
import type { Workspace } from '@yaakapp-internal/models';
import { differenceInMilliseconds } from 'date-fns';
import { createFastMutation } from '../hooks/useFastMutation';
import { trackEvent } from '../lib/analytics';
import { invokeCmd } from '../lib/tauri';
@@ -11,7 +12,7 @@ export const upsertWorkspace = createFastMutation<
mutationKey: ['upsert_workspace'],
mutationFn: (workspace) => invokeCmd<Workspace>('cmd_update_workspace', { workspace }),
onSuccess: async (workspace) => {
const isNew = workspace.createdAt == workspace.updatedAt;
const isNew = differenceInMilliseconds(new Date(), workspace.createdAt + 'Z') < 100;
if (isNew) trackEvent('workspace', 'create');
else trackEvent('workspace', 'update');

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';

View File

@@ -1,18 +1,18 @@
import type { GrpcRequest, HttpRequest } from '@yaakapp-internal/models';
import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
import { atom, useAtomValue } from 'jotai';
import { jotaiStore } from '../lib/jotai';
import { activeRequestIdAtom } from './useActiveRequestId';
import { grpcRequestsAtom } from './useGrpcRequests';
import { httpRequestsAtom } from './useHttpRequests';
import { requestsAtom } from './useRequests';
interface TypeMap {
http_request: HttpRequest;
grpc_request: GrpcRequest;
websocket_request: WebsocketRequest;
}
export const activeRequestAtom = atom<HttpRequest | GrpcRequest | null>((get) => {
export const activeRequestAtom = atom((get) => {
const activeRequestId = get(activeRequestIdAtom);
const requests = [...get(httpRequestsAtom), ...get(grpcRequestsAtom)];
const requests = get(requestsAtom);
return requests.find((r) => r.id === activeRequestId) ?? null;
});

View File

@@ -13,7 +13,7 @@ export function useCopy({ disableToast }: { disableToast?: boolean } = {}) {
if (text != '' && !disableToast) {
showToast({
id: 'copied',
color: 'secondary',
color: 'success',
icon: 'copy',
message: 'Copied to clipboard',
});

View File

@@ -1,10 +1,12 @@
import { useMemo } from 'react';
import { createFolder } from '../commands/commands';
import { upsertWebsocketRequest } from '../commands/upsertWebsocketRequest';
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 { getActiveRequest } from './useActiveRequest';
import { getActiveWorkspace } from './useActiveWorkspace';
import { useCreateGrpcRequest } from './useCreateGrpcRequest';
import { useCreateHttpRequest } from './useCreateHttpRequest';
@@ -19,21 +21,24 @@ export function useCreateDropdownItems({
} = {}): DropdownItem[] {
const { mutate: createHttpRequest } = useCreateHttpRequest();
const { mutate: createGrpcRequest } = useCreateGrpcRequest();
const activeWorkspace = getActiveWorkspace();
return useMemo((): DropdownItem[] => {
const items = useMemo((): DropdownItem[] => {
const activeRequest = getActiveRequest();
const folderId =
folderIdOption === 'active-folder' ? getActiveRequest()?.folderId : folderIdOption;
(folderIdOption === 'active-folder' ? activeRequest?.folderId : folderIdOption) ?? null;
if (activeWorkspace == null) return [];
return [
{
label: 'HTTP Request',
label: 'HTTP',
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: () => {
createHttpRequest({ folderId });
},
},
{
label: 'GraphQL Query',
label: 'GraphQL',
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: () =>
createHttpRequest({
@@ -44,10 +49,16 @@ export function useCreateDropdownItems({
}),
},
{
label: 'gRPC Call',
label: 'gRPC',
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: () => createGrpcRequest({ folderId }),
},
{
label: 'WebSocket',
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: () =>
upsertWebsocketRequest.mutate({ folderId, workspaceId: activeWorkspace.id }),
},
...((hideFolder
? []
: [
@@ -59,5 +70,14 @@ export function useCreateDropdownItems({
},
]) as DropdownItem[]),
];
}, [createGrpcRequest, createHttpRequest, folderIdOption, hideFolder, hideIcons]);
}, [
activeWorkspace,
createGrpcRequest,
createHttpRequest,
folderIdOption,
hideFolder,
hideIcons,
]);
return items;
}

View File

@@ -5,15 +5,11 @@ import { showConfirm } from '../lib/confirm';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { invokeCmd } from '../lib/tauri';
import { useFastMutation } from './useFastMutation';
import { getGrpcRequest } from './useGrpcRequests';
export function useDeleteAnyGrpcRequest() {
return useFastMutation<GrpcRequest | null, string, string>({
return useFastMutation<GrpcRequest | null, string, GrpcRequest>({
mutationKey: ['delete_any_grpc_request'],
mutationFn: async (id) => {
const request = getGrpcRequest(id);
if (request == null) return null;
mutationFn: async (request) => {
const confirmed = await showConfirm({
id: 'delete-grpc-request',
title: 'Delete Request',
@@ -24,9 +20,11 @@ export function useDeleteAnyGrpcRequest() {
</>
),
});
if (!confirmed) return null;
return invokeCmd('cmd_delete_grpc_request', { requestId: id });
if (!confirmed) {
return null;
}
return invokeCmd('cmd_delete_grpc_request', { requestId: request.id });
},
onSettled: () => trackEvent('grpc_request', 'delete'),
onSuccess: () => trackEvent('grpc_request', 'delete'),
});
}

View File

@@ -5,15 +5,11 @@ import { showConfirm } from '../lib/confirm';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { invokeCmd } from '../lib/tauri';
import { useFastMutation } from './useFastMutation';
import { getHttpRequest } from './useHttpRequests';
export function useDeleteAnyHttpRequest() {
return useFastMutation<HttpRequest | null, string, string>({
return useFastMutation<HttpRequest | null, string, HttpRequest>({
mutationKey: ['delete_any_http_request'],
mutationFn: async (id) => {
const request = getHttpRequest(id);
if (request == null) return null;
mutationFn: async (request) => {
const confirmed = await showConfirm({
id: 'delete-request',
title: 'Delete Request',
@@ -24,9 +20,11 @@ export function useDeleteAnyHttpRequest() {
</>
),
});
if (!confirmed) return null;
return invokeCmd<HttpRequest>('cmd_delete_http_request', { requestId: id });
if (!confirmed) {
return null;
}
return invokeCmd<HttpRequest>('cmd_delete_http_request', { requestId: request.id });
},
onSettled: () => trackEvent('http_request', 'delete'),
onSuccess: () => trackEvent('http_request', 'delete'),
});
}

View File

@@ -1,6 +1,9 @@
import { deleteWebsocketRequest } from '../commands/deleteWebsocketRequest';
import { jotaiStore } from '../lib/jotai';
import { useDeleteAnyGrpcRequest } from './useDeleteAnyGrpcRequest';
import { useDeleteAnyHttpRequest } from './useDeleteAnyHttpRequest';
import { useFastMutation } from './useFastMutation';
import { requestsAtom } from './useRequests';
export function useDeleteAnyRequest() {
const deleteAnyHttpRequest = useDeleteAnyHttpRequest();
@@ -10,9 +13,17 @@ export function useDeleteAnyRequest() {
mutationKey: ['delete_request'],
mutationFn: async (id) => {
if (id == null) return;
// We don't know what type it is based on the ID, so just try deleting both
deleteAnyHttpRequest.mutate(id);
deleteAnyGrpcRequest.mutate(id);
const request = jotaiStore.get(requestsAtom).find((r) => r.id === id);
if (request?.model === 'websocket_request') {
deleteWebsocketRequest.mutate(request);
} else if (request?.model === 'http_request') {
deleteAnyHttpRequest.mutate(request);
} else if (request?.model === 'grpc_request') {
deleteAnyGrpcRequest.mutate(request);
} else {
console.log('Failed to delete request', id, request);
}
},
});
}

View File

@@ -7,24 +7,29 @@ import { getActiveWorkspaceId } from './useActiveWorkspace';
import { useFastMutation } from './useFastMutation';
import { useGrpcConnections } from './useGrpcConnections';
import { httpResponsesAtom, useHttpResponses } from './useHttpResponses';
import { useWebsocketConnections } from './useWebsocketConnections';
export function useDeleteSendHistory() {
const setHttpResponses = useSetAtom(httpResponsesAtom);
const httpResponses = useHttpResponses();
const grpcConnections = useGrpcConnections();
const websocketConnections = useWebsocketConnections();
const labels = [
httpResponses.length > 0 ? pluralizeCount('Http Response', httpResponses.length) : null,
grpcConnections.length > 0 ? pluralizeCount('Grpc Connection', grpcConnections.length) : null,
websocketConnections.length > 0
? pluralizeCount('WebSocket Connection', websocketConnections.length)
: null,
].filter((l) => l != null);
return useFastMutation({
mutationKey: ['delete_send_history'],
mutationKey: ['delete_send_history', labels],
mutationFn: async () => {
if (labels.length === 0) {
showAlert({
id: 'no-responses',
title: 'Nothing to Delete',
body: 'There are no Http Response or Grpc Connections to delete',
body: 'There is no Http, Grpc, or Websocket history',
});
return;
}

View File

@@ -116,6 +116,16 @@ export function useHotKey(
if (e.shiftKey) currentKeysWithModifiers.add('Shift');
for (const [hkAction, hkKeys] of Object.entries(hotkeys) as [HotkeyAction, string[]][]) {
if (
(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) &&
currentKeysWithModifiers.size === 1 &&
currentKeysWithModifiers.has('Backspace')
) {
// Don't support Backspace-only modifiers within input fields. This is fairly brittle, so maybe there's a
// better way to do stuff like this in the future.
continue;
}
for (const hkKey of hkKeys) {
if (hkAction !== action) {
continue;

View File

@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import type { GrpcRequest, HttpRequest } from '@yaakapp-internal/models';
import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
import type { GetHttpAuthenticationConfigResponse, JsonPrimitive } from '@yaakapp-internal/plugins';
import { md5 } from 'js-md5';
import { useState } from 'react';
@@ -48,7 +48,7 @@ export function useHttpAuthenticationConfig(
...config,
actions: config.actions?.map((a, i) => ({
...a,
call: async ({ id: requestId }: HttpRequest | GrpcRequest) => {
call: async ({ id: requestId }: HttpRequest | GrpcRequest | WebsocketRequest) => {
await invokeCmd('cmd_call_http_authentication_action', {
pluginRefId: config.pluginRefId,
actionIndex: i,

View File

@@ -1,9 +1,10 @@
import React from 'react';
import { MoveToWorkspaceDialog } from '../components/MoveToWorkspaceDialog';
import { showDialog } from '../lib/dialog';
import { jotaiStore } from '../lib/jotai';
import { getActiveWorkspaceId } from './useActiveWorkspace';
import { useFastMutation } from './useFastMutation';
import { getRequests } from './useRequests';
import { requestsAtom } from './useRequests';
export function useMoveToWorkspace(id: string) {
return useFastMutation<void, unknown>({
@@ -12,7 +13,7 @@ export function useMoveToWorkspace(id: string) {
const activeWorkspaceId = getActiveWorkspaceId();
if (activeWorkspaceId == null) return;
const request = getRequests().find((r) => r.id === id);
const request = jotaiStore.get(requestsAtom).find((r) => r.id === id);
if (request == null) return;
showDialog({

View File

@@ -0,0 +1,18 @@
import type { WebsocketConnection, WebsocketRequest } from '@yaakapp-internal/models';
import { useKeyValue } from './useKeyValue';
import { useLatestWebsocketConnection, useWebsocketConnections } from './useWebsocketConnections';
export function usePinnedWebsocketConnection(activeRequest: WebsocketRequest) {
const latestConnection = useLatestWebsocketConnection(activeRequest.id);
const { set: setPinnedConnectionId, value: pinnedConnectionId } = useKeyValue<string | null>({
// Key on latest connection instead of activeRequest because connections change out of band of active request
key: ['pinned_websocket_connection_id', latestConnection?.id ?? 'n/a'],
fallback: null,
namespace: 'global',
});
const connections = useWebsocketConnections().filter((c) => c.requestId === activeRequest.id);
const activeConnection: WebsocketConnection | null =
connections.find((r) => r.id === pinnedConnectionId) ?? latestConnection;
return { activeConnection, setPinnedConnectionId, pinnedConnectionId, connections } as const;
}

View File

@@ -1,15 +1,16 @@
import { createGlobalState } from 'react-use';
import { atom, useAtomValue } from 'jotai';
import { generateId } from '../lib/generateId';
import { jotaiStore } from '../lib/jotai';
const useGlobalState = createGlobalState<Record<string, string>>({});
const keyAtom = atom<Record<string, string>>({});
export function useRequestUpdateKey(requestId: string | null) {
const [keys, setKeys] = useGlobalState();
const keys = useAtomValue(keyAtom);
const key = keys[requestId ?? 'n/a'];
return {
updateKey: `${requestId}::${key ?? 'default'}`,
wasUpdatedExternally: (changedRequestId: string) => {
setKeys((m) => ({ ...m, [changedRequestId]: generateId() }));
jotaiStore.set(keyAtom, (m) => ({ ...m, [changedRequestId]: generateId() }));
},
};
}

View File

@@ -1,14 +1,14 @@
import { atom, useAtomValue } from 'jotai';
import {jotaiStore} from "../lib/jotai";
import { grpcRequestsAtom } from './useGrpcRequests';
import { httpRequestsAtom } from './useHttpRequests';
import { websocketRequestsAtom } from './useWebsocketRequests';
const requestsAtom = atom((get) => [...get(httpRequestsAtom), ...get(grpcRequestsAtom)]);
export const requestsAtom = atom((get) => [
...get(httpRequestsAtom),
...get(grpcRequestsAtom),
...get(websocketRequestsAtom),
]);
export function useRequests() {
return useAtomValue(requestsAtom);
}
export function getRequests() {
return jotaiStore.get(requestsAtom);
}

View File

@@ -19,6 +19,9 @@ import { useListenToTauriEvent } from './useListenToTauriEvent';
import { pluginsAtom } from './usePlugins';
import { useRequestUpdateKey } from './useRequestUpdateKey';
import { settingsAtom } from './useSettings';
import { websocketConnectionsAtom } from './useWebsocketConnections';
import { websocketEventsQueryKey } from './useWebsocketEvents';
import { websocketRequestsAtom } from './useWebsocketRequests';
import { workspaceMetaAtom } from './useWorkspaceMeta';
import { workspacesAtom } from './useWorkspaces';
@@ -31,13 +34,17 @@ export function useSyncModelStores() {
const queryKey =
payload.model.model === 'grpc_event'
? grpcEventsQueryKey(payload.model)
: payload.model.model === 'key_value'
? keyValueQueryKey(payload.model)
: null;
: payload.model.model === 'websocket_event'
? websocketEventsQueryKey(payload.model)
: payload.model.model === 'key_value'
? keyValueQueryKey(payload.model)
: null;
// TODO: Move this logic to useRequestEditor() hook
if (
payload.model.model === 'http_request' &&
(payload.model.model === 'http_request' ||
payload.model.model === 'grpc_request' ||
payload.model.model === 'websocket_request') &&
(payload.windowLabel !== getCurrentWebviewWindow().label || payload.updateSource !== 'window')
) {
wasUpdatedExternally(payload.model.id);
@@ -64,6 +71,10 @@ export function useSyncModelStores() {
jotaiStore.set(httpResponsesAtom, updateModelList(payload.model));
} else if (payload.model.model === 'grpc_request') {
jotaiStore.set(grpcRequestsAtom, updateModelList(payload.model));
} else if (payload.model.model === 'websocket_request') {
jotaiStore.set(websocketRequestsAtom, updateModelList(payload.model));
} else if (payload.model.model === 'websocket_connection') {
jotaiStore.set(websocketConnectionsAtom, updateModelList(payload.model));
} else if (payload.model.model === 'grpc_connection') {
jotaiStore.set(grpcConnectionsAtom, updateModelList(payload.model));
} else if (payload.model.model === 'environment') {
@@ -103,6 +114,15 @@ export function useSyncModelStores() {
jotaiStore.set(environmentsAtom, removeModelById(payload.model));
} else if (payload.model.model === 'grpc_request') {
jotaiStore.set(grpcRequestsAtom, removeModelById(payload.model));
} else if (payload.model.model === 'websocket_request') {
jotaiStore.set(websocketRequestsAtom, removeModelById(payload.model));
} else if (payload.model.model === 'websocket_connection') {
jotaiStore.set(websocketConnectionsAtom, removeModelById(payload.model));
} else if (payload.model.model === 'websocket_event') {
queryClient.setQueryData(
websocketEventsQueryKey(payload.model),
removeModelById(payload.model),
);
} else if (payload.model.model === 'grpc_connection') {
jotaiStore.set(grpcConnectionsAtom, removeModelById(payload.model));
} else if (payload.model.model === 'grpc_event') {
@@ -117,7 +137,10 @@ export function useSyncModelStores() {
export function updateModelList<T extends AnyModel>(model: T) {
// Mark these models as DESC instead of ASC
const pushToFront = model.model === 'http_response' || model.model === 'grpc_connection';
const pushToFront =
model.model === 'http_response' ||
model.model === 'grpc_connection' ||
model.model === 'websocket_connection';
return (current: T[] | undefined | null): T[] => {
const index = current?.findIndex((v) => modelsEq(v, model)) ?? -1;

View File

@@ -1,3 +1,4 @@
import {listWebsocketConnections, listWebsocketRequests} from '@yaakapp-internal/ws';
import { useEffect } from 'react';
import { jotaiStore } from '../lib/jotai';
import { invokeCmd } from '../lib/tauri';
@@ -10,6 +11,8 @@ import { grpcRequestsAtom } from './useGrpcRequests';
import { httpRequestsAtom } from './useHttpRequests';
import { httpResponsesAtom } from './useHttpResponses';
import { keyValuesAtom } from './useKeyValue';
import {websocketConnectionsAtom} from "./useWebsocketConnections";
import { websocketRequestsAtom } from './useWebsocketRequests';
import { workspaceMetaAtom } from './useWorkspaceMeta';
export function useSyncWorkspaceChildModels() {
@@ -33,11 +36,13 @@ async function sync() {
jotaiStore.set(httpRequestsAtom, await invokeCmd('cmd_list_http_requests', args));
jotaiStore.set(grpcRequestsAtom, await invokeCmd('cmd_list_grpc_requests', args));
jotaiStore.set(foldersAtom, await invokeCmd('cmd_list_folders', args));
jotaiStore.set(websocketRequestsAtom, await listWebsocketRequests(args));
// Then, set the rest
jotaiStore.set(cookieJarsAtom, await invokeCmd('cmd_list_cookie_jars', args));
jotaiStore.set(httpResponsesAtom, await invokeCmd('cmd_list_http_responses', args));
jotaiStore.set(grpcConnectionsAtom, await invokeCmd('cmd_list_grpc_connections', args));
jotaiStore.set(websocketConnectionsAtom, await listWebsocketConnections(args));
jotaiStore.set(environmentsAtom, await invokeCmd('cmd_list_environments', args));
// Single models

View File

@@ -0,0 +1,17 @@
import type { WebsocketConnection } from '@yaakapp-internal/models';
import { atom, useAtomValue } from 'jotai';
import { jotaiStore } from '../lib/jotai';
export const websocketConnectionsAtom = atom<WebsocketConnection[]>([]);
export function useWebsocketConnections() {
return useAtomValue(websocketConnectionsAtom);
}
export function useLatestWebsocketConnection(requestId: string | null): WebsocketConnection | null {
return useWebsocketConnections().find((r) => r.requestId === requestId) ?? null;
}
export function getWebsocketConnection(id: string) {
return jotaiStore.get(websocketConnectionsAtom).find((r) => r.id === id) ?? null;
}

View File

@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import type { WebsocketEvent } from '@yaakapp-internal/models';
import { listWebsocketEvents } from '@yaakapp-internal/ws';
export function websocketEventsQueryKey({ connectionId }: { connectionId: string }) {
return ['websocket_events', { connectionId }];
}
export function useWebsocketEvents(connectionId: string | null) {
return (
useQuery<WebsocketEvent[]>({
enabled: connectionId !== null,
initialData: [],
queryKey: websocketEventsQueryKey({ connectionId: connectionId ?? 'n/a' }),
queryFn: () => {
if (connectionId == null) return [] as WebsocketEvent[];
return listWebsocketEvents({ connectionId });
},
}).data ?? []
);
}

View File

@@ -0,0 +1,13 @@
import type { WebsocketRequest } from '@yaakapp-internal/models';
import { atom, useAtomValue } from 'jotai';
import { jotaiStore } from '../lib/jotai';
export const websocketRequestsAtom = atom<WebsocketRequest[]>([]);
export function useWebsocketRequests() {
return useAtomValue(websocketRequestsAtom);
}
export function getWebsocketRequest(id: string) {
return jotaiStore.get(websocketRequestsAtom).find((r) => r.id === id) ?? null;
}

View File

@@ -32,6 +32,8 @@ function detectFromContent(
content.toLowerCase().startsWith('<html')
) {
return 'html';
} else if (content.startsWith('<')) {
return 'xml';
}
return fallback;

View File

@@ -1,9 +1,9 @@
import type { AnyModel, GrpcRequest, HttpRequest } from '@yaakapp-internal/models';
import type { AnyModel } from '@yaakapp-internal/models';
export function fallbackRequestName(r: HttpRequest | GrpcRequest | AnyModel | null): string {
export function fallbackRequestName(r: AnyModel | null): string {
if (r == null) return '';
if (r.model !== 'grpc_request' && r.model !== 'http_request') {
if (!('url' in r) || r.model === 'plugin') {
return 'name' in r ? r.name : '';
}
@@ -15,7 +15,11 @@ export function fallbackRequestName(r: HttpRequest | GrpcRequest | AnyModel | nu
// Replace variable syntax with variable name
const withoutVariables = r.url.replace(/\$\{\[\s*([^\]\s]+)\s*]}/g, '$1');
if (withoutVariables.trim() === '') {
return r.model === 'http_request' ? 'New HTTP Request' : 'new gRPC Request';
return r.model === 'http_request'
? 'HTTP Request'
: r.model === 'websocket_request'
? 'WebSocket Request'
: 'gRPC Request';
}
// GRPC gets nice short names

View File

@@ -45,6 +45,7 @@
"format-graphql": "^1.5.0",
"framer-motion": "^11.5.4",
"fuzzbunny": "^1.0.1",
"hexy": "^0.3.5",
"history": "^5.3.0",
"jotai": "^2.9.3",
"js-md5": "^0.8.3",
@@ -91,9 +92,9 @@
"postcss-nesting": "^13.0.0",
"react-devtools": "^5.3.1",
"tailwindcss": "^3.4.10",
"vite": "^5.4.6",
"vite-plugin-static-copy": "^1.0.6",
"vite-plugin-svgr": "^4.2.0",
"vite": "6.0.9",
"vite-plugin-static-copy": "^2.2.0",
"vite-plugin-svgr": "^4.3.0",
"vite-plugin-top-level-await": "^1.4.4"
}
}