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

@@ -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;
}