Initial frontend for gRPC UI

This commit is contained in:
Gregory Schier
2024-01-30 16:43:54 -08:00
parent c51d5c5377
commit c64f1108f0
16 changed files with 650 additions and 132 deletions

View File

@@ -72,7 +72,7 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi
const dialog = useDialog();
return (
<div className="pb-2 h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
<div className="h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
<Editor
contentType="application/graphql"
defaultValue={query ?? ''}
@@ -125,7 +125,7 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi
{...extraEditorProps}
/>
<Separator variant="primary" />
<p className="pt-1 text-gray-500 text-sm">Variables</p>
<p className="py-1 text-gray-500 text-sm">Variables</p>
<Editor
format={tryFormatJson}
contentType="application/json"

View File

@@ -0,0 +1,81 @@
import type { Props } from 'focus-trap-react';
import type { CSSProperties, FormEvent } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useGrpc } from '../hooks/useGrpc';
import { useKeyValue } from '../hooks/useKeyValue';
import { Editor } from './core/Editor';
import { SplitLayout } from './core/SplitLayout';
import { VStack } from './core/Stacks';
import { GrpcEditor } from './GrpcEditor';
import { UrlBar } from './UrlBar';
interface Props {
style: CSSProperties;
}
export function GrpcConnectionLayout({ style }: Props) {
const url = useKeyValue<string>({ namespace: 'debug', key: 'grpc_url', defaultValue: '' });
const message = useKeyValue<string>({
namespace: 'debug',
key: 'grpc_message',
defaultValue: '',
});
const [resp, setResp] = useState<string>('');
const grpc = useGrpc(url.value ?? null);
const handleConnect = useCallback(
async (e: FormEvent) => {
e.preventDefault();
setResp(
await grpc.callUnary.mutateAsync({
service: 'helloworld.Greeter',
method: 'SayHello',
message: message.value ?? '',
}),
);
},
[grpc.callUnary, message.value],
);
useEffect(() => {
console.log('REFLECT SCHEMA', grpc.schema);
}, [grpc.schema]);
if (url.isLoading || url.value == null) {
return null;
}
return (
<SplitLayout
style={style}
leftSlot={() => (
<VStack space={2}>
<UrlBar
id="foo"
url={url.value ?? ''}
method={null}
placeholder="localhost:50051"
onSubmit={handleConnect}
isLoading={false}
onUrlChange={url.set}
forceUpdateKey={''}
/>
<GrpcEditor
url={url.value ?? ''}
defaultValue={message.value}
onChange={message.set}
className="bg-gray-50"
/>
</VStack>
)}
rightSlot={() => (
<Editor
className="bg-gray-50 border border-highlight"
contentType="application/json"
defaultValue={resp}
readOnly
forceUpdateKey={resp}
/>
)}
/>
);
}

View File

@@ -0,0 +1,40 @@
import type { EditorView } from 'codemirror';
import { updateSchema } from 'codemirror-json-schema';
import { useEffect, useRef } from 'react';
import { useGrpc } from '../hooks/useGrpc';
import { tryFormatJson } from '../lib/formatters';
import type { EditorProps } from './core/Editor';
import { Editor } from './core/Editor';
type Props = Pick<
EditorProps,
'heightMode' | 'onChange' | 'defaultValue' | 'className' | 'forceUpdateKey'
> & {
url: string;
};
export function GrpcEditor({ url, defaultValue, ...extraEditorProps }: Props) {
const editorViewRef = useRef<EditorView>(null);
const { schema } = useGrpc(url);
useEffect(() => {
if (editorViewRef.current == null || schema == null) return;
const foo = schema[0].methods[0].schema;
console.log('UPDATE SCHEMA', foo);
updateSchema(editorViewRef.current, JSON.parse(foo));
}, [schema]);
return (
<div className="h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
<Editor
contentType="application/grpc"
defaultValue={defaultValue}
format={tryFormatJson}
heightMode="auto"
placeholder="..."
ref={editorViewRef}
{...extraEditorProps}
/>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import type { CSSProperties } from 'react';
import React from 'react';
import { SplitLayout } from './core/SplitLayout';
import { RequestPane } from './RequestPane';
import { ResponsePane } from './ResponsePane';
interface Props {
style: CSSProperties;
}
export function HttpRequestLayout({ style }: Props) {
return (
<SplitLayout
style={style}
leftSlot={({ orientation, style }) => (
<RequestPane style={style} fullHeight={orientation === 'horizontal'} />
)}
rightSlot={({ style }) => <ResponsePane style={style} />}
/>
);
}

View File

@@ -1,9 +1,11 @@
import classNames from 'classnames';
import type { CSSProperties } from 'react';
import type { CSSProperties, FormEvent } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { createGlobalState } from 'react-use';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useIsResponseLoading } from '../hooks/useIsResponseLoading';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useSendRequest } from '../hooks/useSendRequest';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import { tryFormatJson } from '../lib/formatters';
import type { HttpHeader, HttpRequest, HttpUrlParameter } from '../lib/models';
@@ -33,7 +35,7 @@ import { UrlBar } from './UrlBar';
import { UrlParametersEditor } from './UrlParameterEditor';
interface Props {
style?: CSSProperties;
style: CSSProperties;
fullHeight: boolean;
className?: string;
}
@@ -178,6 +180,27 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
[updateRequest],
);
const sendRequest = useSendRequest(activeRequest?.id ?? null);
const handleSend = useCallback(
async (e: FormEvent) => {
e.preventDefault();
await sendRequest.mutateAsync();
},
[sendRequest],
);
const handleMethodChange = useCallback(
(method: string) => updateRequest.mutate({ method }),
[updateRequest],
);
const handleUrlChange = useCallback(
(url: string) => updateRequest.mutate({ url }),
[updateRequest],
);
const isLoading = useIsResponseLoading(activeRequestId ?? null);
const { updateKey } = useRequestUpdateKey(activeRequestId ?? null);
return (
<div
style={style}
@@ -190,6 +213,12 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
id={activeRequest.id}
url={activeRequest.url}
method={activeRequest.method}
placeholder="https://example.com"
onSubmit={handleSend}
onMethodChange={handleMethodChange}
onUrlChange={handleUrlChange}
forceUpdateKey={updateKey}
isLoading={isLoading}
/>
<Tabs
value={activeTab}

View File

@@ -1,43 +1,36 @@
import type { EditorView } from 'codemirror';
import type { FormEvent } from 'react';
import { memo, useCallback, useRef, useState } from 'react';
import { memo, useRef, useState } from 'react';
import { useHotKey } from '../hooks/useHotKey';
import { useIsResponseLoading } from '../hooks/useIsResponseLoading';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useSendRequest } from '../hooks/useSendRequest';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpRequest } from '../lib/models';
import { IconButton } from './core/IconButton';
import { Input } from './core/Input';
import { RequestMethodDropdown } from './RequestMethodDropdown';
type Props = Pick<HttpRequest, 'id' | 'url' | 'method'> & {
type Props = Pick<HttpRequest, 'id' | 'url'> & {
className?: string;
method: HttpRequest['method'] | null;
placeholder: string;
onSubmit: (e: FormEvent) => void;
onUrlChange: (url: string) => void;
onMethodChange?: (method: string) => void;
isLoading: boolean;
forceUpdateKey: string;
};
export const UrlBar = memo(function UrlBar({ id: requestId, url, method, className }: Props) {
export const UrlBar = memo(function UrlBar({
forceUpdateKey,
onUrlChange,
url,
method,
placeholder,
className,
onSubmit,
onMethodChange,
isLoading,
}: Props) {
const inputRef = useRef<EditorView>(null);
const sendRequest = useSendRequest(requestId);
const updateRequest = useUpdateRequest(requestId);
const [isFocused, setIsFocused] = useState<boolean>(false);
const handleMethodChange = useCallback(
(method: string) => updateRequest.mutate({ method }),
[updateRequest],
);
const handleUrlChange = useCallback(
(url: string) => updateRequest.mutate({ url }),
[updateRequest],
);
const loading = useIsResponseLoading(requestId);
const { updateKey } = useRequestUpdateKey(requestId);
const handleSubmit = useCallback(
async (e: FormEvent) => {
e.preventDefault();
sendRequest.mutate();
},
[sendRequest],
);
useHotKey('urlBar.focus', () => {
const head = inputRef.current?.state.doc.length ?? 0;
@@ -48,7 +41,7 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
});
return (
<form onSubmit={handleSubmit} className={className}>
<form onSubmit={onSubmit} className={className}>
<Input
autocompleteVariables
ref={inputRef}
@@ -60,19 +53,22 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
className="px-0 py-0.5"
name="url"
label="Enter URL"
forceUpdateKey={updateKey}
forceUpdateKey={forceUpdateKey}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
containerClassName="shadow shadow-gray-100 dark:shadow-gray-50"
onChange={handleUrlChange}
onChange={onUrlChange}
defaultValue={url}
placeholder="https://example.com"
placeholder={placeholder}
leftSlot={
<RequestMethodDropdown
method={method}
onChange={handleMethodChange}
className="mx-0.5 my-0.5"
/>
method != null &&
onMethodChange != null && (
<RequestMethodDropdown
method={method}
onChange={onMethodChange}
className="mx-0.5 my-0.5"
/>
)
}
rightSlot={
<IconButton
@@ -81,8 +77,8 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
title="Send Request"
type="submit"
className="w-8 mr-0.5 my-0.5"
icon={loading ? 'update' : 'sendHorizontal'}
spin={loading}
icon={isLoading ? 'update' : 'sendHorizontal'}
spin={isLoading}
hotkeyAction="request.send"
/>
}

View File

@@ -8,14 +8,16 @@ import type {
} from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useWindowSize } from 'react-use';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useIsFullscreen } from '../hooks/useIsFullscreen';
import { useOsInfo } from '../hooks/useOsInfo';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useSidebarWidth } from '../hooks/useSidebarWidth';
import { Button } from './core/Button';
import { HStack } from './core/Stacks';
import { GrpcConnectionLayout } from './GrpcConnectionLayout';
import { HttpRequestLayout } from './HttpRequestLayout';
import { Overlay } from './Overlay';
import { RequestResponse } from './RequestResponse';
import { ResizeHandle } from './ResizeHandle';
import { Sidebar } from './Sidebar';
import { SidebarActions } from './SidebarActions';
@@ -31,6 +33,7 @@ const WINDOW_FLOATING_SIDEBAR_WIDTH = 600;
export default function Workspace() {
const { setWidth, width, resetWidth } = useSidebarWidth();
const { hide, show, hidden } = useSidebarHidden();
const activeRequest = useActiveRequest();
const windowSize = useWindowSize();
const [floating, setFloating] = useState<boolean>(false);
@@ -163,7 +166,11 @@ export default function Workspace() {
>
<WorkspaceHeader className="pointer-events-none" />
</HeaderSize>
<RequestResponse style={body} />
{activeRequest?.name.includes('gRPC') ? (
<GrpcConnectionLayout style={body} />
) : (
<HttpRequestLayout style={body} />
)}
</div>
);
}

View File

@@ -10,7 +10,6 @@ import { json } from '@codemirror/lang-json';
import { xml } from '@codemirror/lang-xml';
import type { LanguageSupport } from '@codemirror/language';
import {
bracketMatching,
foldGutter,
foldKeymap,
HighlightStyle,
@@ -32,6 +31,7 @@ import {
} from '@codemirror/view';
import { tags as t } from '@lezer/highlight';
import { graphql, graphqlLanguageSupport } from 'cm6-graphql';
import { jsonSchema } from 'codemirror-json-schema';
import type { Environment, Workspace } from '../../../lib/models';
import type { EditorProps } from './index';
import { text } from './text/extension';
@@ -83,6 +83,7 @@ export const myHighlightStyle = HighlightStyle.define([
// ]);
const syntaxExtensions: Record<string, LanguageSupport> = {
'application/grpc': jsonSchema() as any, // TODO: Fix this
'application/graphql': graphqlLanguageSupport(),
'application/json': json(),
'application/javascript': javascript(),
@@ -119,7 +120,6 @@ export const baseExtensions = [
history(),
dropCursor(),
drawSelection(),
bracketMatching(),
// TODO: Figure out how to debounce showing of autocomplete in a good way
// debouncedAutocompletionDisplay({ millis: 1000 }),
// autocompletion({ closeOnBlur: true, interactionDelay: 200, activateOnTyping: false }),

View File

@@ -1,32 +1,36 @@
import useResizeObserver from '@react-hook/resize-observer';
import classNames from 'classnames';
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useLocalStorage } from 'react-use';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { clamp } from '../lib/clamp';
import { HotKeyList } from './core/HotKeyList';
import { RequestPane } from './RequestPane';
import { ResizeHandle } from './ResizeHandle';
import { ResponsePane } from './ResponsePane';
import { useActiveRequestId } from '../../hooks/useActiveRequestId';
import { useActiveWorkspaceId } from '../../hooks/useActiveWorkspaceId';
import { clamp } from '../../lib/clamp';
import { ResizeHandle } from '../ResizeHandle';
import { HotKeyList } from './HotKeyList';
interface Props {
interface SlotProps {
orientation: 'horizontal' | 'vertical';
style: CSSProperties;
}
const rqst = { gridArea: 'rqst' };
const resp = { gridArea: 'resp' };
const drag = { gridArea: 'drag' };
interface Props {
style: CSSProperties;
leftSlot: (props: SlotProps) => ReactNode;
rightSlot: (props: SlotProps) => ReactNode;
}
const areaL = { gridArea: 'left' };
const areaR = { gridArea: 'right' };
const areaD = { gridArea: 'drag' };
const DEFAULT = 0.5;
const MIN_WIDTH_PX = 10;
const MIN_HEIGHT_PX = 30;
const STACK_VERTICAL_WIDTH = 700;
export const RequestResponse = memo(function RequestResponse({ style }: Props) {
export function SplitLayout({ style, leftSlot, rightSlot }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const activeRequest = useActiveRequest();
const [vertical, setVertical] = useState<boolean>(false);
const [widthRaw, setWidth] = useLocalStorage<number>(`body_width::${useActiveWorkspaceId()}`);
const [heightRaw, setHeight] = useLocalStorage<number>(`body_height::${useActiveWorkspaceId()}`);
@@ -46,13 +50,13 @@ export const RequestResponse = memo(function RequestResponse({ style }: Props) {
...style,
gridTemplate: vertical
? `
' ${rqst.gridArea}' minmax(0,${1 - height}fr)
' ${drag.gridArea}' 0
' ${resp.gridArea}' minmax(0,${height}fr)
' ${areaL.gridArea}' minmax(0,${1 - height}fr)
' ${areaD.gridArea}' 0
' ${areaR.gridArea}' minmax(0,${height}fr)
/ 1fr
`
: `
' ${rqst.gridArea} ${drag.gridArea} ${resp.gridArea}' minmax(0,1fr)
' ${areaL.gridArea} ${areaD.gridArea} ${areaR.gridArea}' minmax(0,1fr)
/ ${1 - width}fr 0 ${width}fr
`,
}),
@@ -117,15 +121,16 @@ export const RequestResponse = memo(function RequestResponse({ style }: Props) {
[width, height, vertical, setHeight, setWidth],
);
if (activeRequest === null) {
const activeRequestId = useActiveRequestId();
if (activeRequestId === null) {
return <HotKeyList hotkeys={['request.create', 'sidebar.toggle']} />;
}
return (
<div ref={containerRef} className="grid gap-1.5 w-full h-full p-3" style={styles}>
<RequestPane style={rqst} fullHeight={!vertical} />
{leftSlot({ style: areaL, orientation: vertical ? 'vertical' : 'horizontal' })}
<ResizeHandle
style={drag}
style={areaD}
isResizing={isResizing}
className={classNames(vertical ? 'translate-y-0.5' : 'translate-x-0.5')}
onResizeStart={handleResizeStart}
@@ -133,7 +138,7 @@ export const RequestResponse = memo(function RequestResponse({ style }: Props) {
side={vertical ? 'top' : 'left'}
justify="center"
/>
<ResponsePane style={resp} />
{rightSlot({ style: areaR, orientation: vertical ? 'vertical' : 'horizontal' })}
</div>
);
});
}

View File

@@ -1,17 +1,17 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
import type { HttpRequest } from '../lib/models';
import type { CookieJar } from '../lib/models';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { cookieJarsQueryKey } from './useCookieJars';
import { usePrompt } from './usePrompt';
import { requestsQueryKey } from './useRequests';
export function useCreateCookieJar() {
const workspaceId = useActiveWorkspaceId();
const queryClient = useQueryClient();
const prompt = usePrompt();
return useMutation<HttpRequest>({
return useMutation<CookieJar>({
mutationFn: async () => {
if (workspaceId === null) {
throw new Error("Cannot create cookie jar when there's no active workspace");
@@ -26,10 +26,10 @@ export function useCreateCookieJar() {
return invoke('create_cookie_jar', { workspaceId, name });
},
onSettled: () => trackEvent('CookieJar', 'Create'),
onSuccess: async (request) => {
queryClient.setQueryData<HttpRequest[]>(
requestsQueryKey({ workspaceId: request.workspaceId }),
(requests) => [...(requests ?? []), request],
onSuccess: async (cookieJar) => {
queryClient.setQueryData<CookieJar[]>(
cookieJarsQueryKey({ workspaceId: cookieJar.workspaceId }),
(items) => [...(items ?? []), cookieJar],
);
},
});

35
src-web/hooks/useGrpc.ts Normal file
View File

@@ -0,0 +1,35 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
export function useGrpc(url: string | null) {
const callUnary = useMutation<
string,
unknown,
{ service: string; method: string; message: string }
>({
mutationKey: ['grpc_call_reflect', url],
mutationFn: async ({ service, method, message }) => {
if (url === null) throw new Error('No URL provided');
return (await invoke('grpc_call_unary', {
endpoint: url,
service,
method,
message,
})) as string;
},
});
const reflect = useQuery<string | null>({
queryKey: ['grpc_reflect', url ?? ''],
queryFn: async () => {
if (url === null) return null;
console.log('GETTING SCHEMA', url);
return (await invoke('grpc_reflect', { endpoint: url })) as string;
},
});
return {
callUnary,
schema: reflect.data,
};
}

View File

@@ -11,20 +11,6 @@ import { setAppearanceOnDocument } from './lib/theme/window';
import { appWindow } from '@tauri-apps/api/window';
import { type } from '@tauri-apps/api/os';
try {
const services: any = await invoke('grpc_reflect', { endpoint: 'http://localhost:50051' });
console.log('SERVICES', services);
const response = await invoke('grpc_call_unary', {
endpoint: 'http://localhost:50051',
service: services[0].name,
method: services[0].methods[0].name,
message: '{"name": "Greg"}',
});
console.log('RESPONSE', response);
} catch (err) {
console.log('ERROR', err);
}
// Hide decorations here because it doesn't work in Rust for some reason (bug?)
const osType = await type();
if (osType !== 'Darwin') {