Hacky server streaming done

This commit is contained in:
Gregory Schier
2024-01-31 22:13:46 -08:00
parent 5c44df7b00
commit a05fc5fd20
15 changed files with 546 additions and 119 deletions

View File

@@ -1,13 +1,19 @@
import type { Props } from 'focus-trap-react';
import classNames from 'classnames';
import type { CSSProperties, FormEvent } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useAlert } from '../hooks/useAlert';
import { useGrpc } from '../hooks/useGrpc';
import { useKeyValue } from '../hooks/useKeyValue';
import { Banner } from './core/Banner';
import { Editor } from './core/Editor';
import { HotKeyList } from './core/HotKeyList';
import { Icon } from './core/Icon';
import { Select } from './core/Select';
import { SplitLayout } from './core/SplitLayout';
import { VStack } from './core/Stacks';
import { HStack, VStack } from './core/Stacks';
import { GrpcEditor } from './GrpcEditor';
import { UrlBar } from './UrlBar';
import { format } from 'date-fns';
interface Props {
style: CSSProperties;
@@ -15,6 +21,17 @@ interface Props {
export function GrpcConnectionLayout({ style }: Props) {
const url = useKeyValue<string>({ namespace: 'debug', key: 'grpc_url', defaultValue: '' });
const alert = useAlert();
const service = useKeyValue<string | null>({
namespace: 'debug',
key: 'grpc_service',
defaultValue: null,
});
const method = useKeyValue<string | null>({
namespace: 'debug',
key: 'grpc_method',
defaultValue: null,
});
const message = useKeyValue<string>({
namespace: 'debug',
key: 'grpc_message',
@@ -22,23 +39,90 @@ export function GrpcConnectionLayout({ style }: Props) {
});
const [resp, setResp] = useState<string>('');
const grpc = useGrpc(url.value ?? null);
const activeMethod = useMemo(() => {
if (grpc.schema == null) return null;
const s = grpc.schema.find((s) => s.name === service.value);
if (s == null) return null;
return s.methods.find((m) => m.name === method.value);
}, [grpc.schema, method.value, service.value]);
const handleConnect = useCallback(
async (e: FormEvent) => {
e.preventDefault();
setResp(
await grpc.callUnary.mutateAsync({
service: 'helloworld.Greeter',
method: 'SayHello',
if (activeMethod == null) return;
if (service.value == null || method.value == null) {
alert({
id: 'grpc-invalid-service-method',
title: 'Error',
body: 'Service or method not selected',
});
}
if (activeMethod.serverStreaming && !activeMethod.clientStreaming) {
await grpc.serverStreaming.mutateAsync({
service: service.value ?? 'n/a',
method: method.value ?? 'n/a',
message: message.value ?? '',
}),
);
});
} else {
setResp(
await grpc.unary.mutateAsync({
service: service.value ?? 'n/a',
method: method.value ?? 'n/a',
message: message.value ?? '',
}),
);
}
},
[grpc.callUnary, message.value],
[
activeMethod,
alert,
grpc.serverStreaming,
grpc.unary,
message.value,
method.value,
service.value,
],
);
useEffect(() => {
console.log('REFLECT SCHEMA', grpc.schema);
}, [grpc.schema]);
if (grpc.schema == null) return;
const s = grpc.schema.find((s) => s.name === service.value);
if (s == null) {
service.set(grpc.schema[0]?.name ?? null);
method.set(grpc.schema[0]?.methods[0]?.name ?? null);
return;
}
const m = s.methods.find((m) => m.name === method.value);
if (m == null) {
method.set(s.methods[0]?.name ?? null);
return;
}
}, [grpc.schema, method, service]);
const handleChangeService = useCallback(
(v: string) => {
const [serviceName, methodName] = v.split('/', 2);
if (serviceName == null || methodName == null) throw new Error('Should never happen');
method.set(methodName);
service.set(serviceName);
},
[method, service],
);
const select = useMemo(() => {
const options =
grpc.schema?.flatMap((s) =>
s.methods.map((m) => ({
label: `${s.name.split('.', 2).pop() ?? s.name}/${m.name}`,
value: `${s.name}/${m.name}`,
})),
) ?? [];
const value = `${service.value ?? ''}/${method.value ?? ''}`;
return { value, options };
}, [grpc.schema, method.value, service.value]);
if (url.isLoading || url.value == null) {
return null;
@@ -49,33 +133,84 @@ export function GrpcConnectionLayout({ style }: Props) {
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={''}
/>
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] gap-1.5">
<UrlBar
id="foo"
url={url.value ?? ''}
method={null}
forceUpdateKey="to-do"
placeholder="localhost:50051"
onSubmit={handleConnect}
isLoading={grpc.unary.isLoading}
onUrlChange={url.set}
submitIcon={
!activeMethod?.clientStreaming && activeMethod?.serverStreaming
? 'arrowDownToDot'
: activeMethod?.clientStreaming && !activeMethod?.serverStreaming
? 'arrowUpFromDot'
: activeMethod?.clientStreaming && activeMethod?.serverStreaming
? 'arrowUpDown'
: 'sendHorizontal'
}
/>
<Select
hideLabel
name="service"
label="Service"
size="sm"
value={select.value}
onChange={handleChangeService}
options={select.options}
/>
</div>
<GrpcEditor
forceUpdateKey={[service, method].join('::')}
url={url.value ?? ''}
defaultValue={message.value}
onChange={message.set}
service={service.value ?? null}
method={method.value ?? null}
className="bg-gray-50"
/>
</VStack>
)}
rightSlot={() => (
<Editor
className="bg-gray-50 border border-highlight"
contentType="application/json"
defaultValue={resp}
readOnly
forceUpdateKey={resp}
/>
)}
rightSlot={() =>
!grpc.unary.isLoading && (
<div
className={classNames(
'max-h-full h-full grid grid-rows-[minmax(0,1fr)] grid-cols-1',
'bg-gray-50 dark:bg-gray-100 rounded-md border border-highlight',
'shadow shadow-gray-100 dark:shadow-gray-0 relative py-1',
)}
>
{grpc.unary.error ? (
<Banner color="danger" className="m-2">
{grpc.unary.error}
</Banner>
) : grpc.messages.length > 0 ? (
<VStack className="h-full overflow-y-auto">
{[...grpc.messages].reverse().map((m, i) => (
<HStack key={m.time.getTime()} space={3} className="px-2 py-1 font-mono text-xs">
<Icon icon="arrowDownToDot" />
<div>{format(m.time, 'HH:mm:ss')}</div>
<div>{m.message}</div>
</HStack>
))}
</VStack>
) : resp ? (
<Editor
className="bg-gray-50 dark:bg-gray-100"
contentType="application/json"
defaultValue={resp}
readOnly
forceUpdateKey={resp}
/>
) : (
<HotKeyList hotkeys={['grpc.send', 'sidebar.toggle', 'urlBar.focus']} />
)}
</div>
)
}
/>
);
}

View File

@@ -1,28 +1,83 @@
import type { EditorView } from 'codemirror';
import { updateSchema } from 'codemirror-json-schema';
import { useEffect, useRef } from 'react';
import { useAlert } from '../hooks/useAlert';
import { useGrpc } from '../hooks/useGrpc';
import { tryFormatJson } from '../lib/formatters';
import type { EditorProps } from './core/Editor';
import { Editor } from './core/Editor';
import { FormattedError } from './core/FormattedError';
import { InlineCode } from './core/InlineCode';
import { VStack } from './core/Stacks';
type Props = Pick<
EditorProps,
'heightMode' | 'onChange' | 'defaultValue' | 'className' | 'forceUpdateKey'
> & {
url: string;
service: string | null;
method: string | null;
};
export function GrpcEditor({ url, defaultValue, ...extraEditorProps }: Props) {
export function GrpcEditor({ url, service, method, defaultValue, ...extraEditorProps }: Props) {
const editorViewRef = useRef<EditorView>(null);
const { schema } = useGrpc(url);
const grpc = useGrpc(url);
const alert = useAlert();
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]);
if (editorViewRef.current == null || grpc.schema == null) return;
const s = grpc.schema?.find((s) => s.name === service);
if (service != null && s == null) {
alert({
id: 'grpc-find-service-error',
title: "Couldn't Find Service",
body: (
<>
Failed to find service <InlineCode>{service}</InlineCode> in schema
</>
),
});
return;
}
const schema = s?.methods.find((m) => m.name === method)?.schema;
if (method != null && schema == null) {
alert({
id: 'grpc-find-schema-error',
title: "Couldn't Find Method",
body: (
<>
Failed to find method <InlineCode>{method}</InlineCode> for{' '}
<InlineCode>{service}</InlineCode> in schema
</>
),
});
return;
}
if (schema == null) {
return;
}
try {
updateSchema(editorViewRef.current, JSON.parse(schema));
} catch (err) {
alert({
id: 'grpc-parse-schema-error',
title: 'Failed to Parse Schema',
body: (
<VStack space={4}>
<p>
For service <InlineCode>{service}</InlineCode> and method{' '}
<InlineCode>{method}</InlineCode>
</p>
<FormattedError>{String(err)}</FormattedError>
</VStack>
),
});
console.log('Failed to parse method schema', method, schema);
}
}, [alert, grpc.schema, method, service]);
return (
<div className="h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">

View File

@@ -29,11 +29,20 @@ export const SettingsDialog = () => {
size="sm"
value={settings.appearance}
onChange={(appearance) => updateSettings.mutateAsync({ ...settings, appearance })}
options={{
system: 'System',
light: 'Light',
dark: 'Dark',
}}
options={[
{
label: 'System',
value: 'system',
},
{
label: 'Light',
value: 'light',
},
{
label: 'Dark',
value: 'dark',
},
]}
/>
<Select
@@ -44,10 +53,16 @@ export const SettingsDialog = () => {
size="sm"
value={settings.updateChannel}
onChange={(updateChannel) => updateSettings.mutateAsync({ ...settings, updateChannel })}
options={{
stable: 'Release',
beta: 'Early Bird (Beta)',
}}
options={[
{
label: 'Release',
value: 'stable',
},
{
label: 'Early Bird (Beta)',
value: 'beta',
},
]}
/>
<Separator className="my-4" />

View File

@@ -3,6 +3,7 @@ import type { FormEvent } from 'react';
import { memo, useRef, useState } from 'react';
import { useHotKey } from '../hooks/useHotKey';
import type { HttpRequest } from '../lib/models';
import type { IconProps } from './core/Icon';
import { IconButton } from './core/IconButton';
import { Input } from './core/Input';
import { RequestMethodDropdown } from './RequestMethodDropdown';
@@ -13,6 +14,7 @@ type Props = Pick<HttpRequest, 'id' | 'url'> & {
placeholder: string;
onSubmit: (e: FormEvent) => void;
onUrlChange: (url: string) => void;
submitIcon?: IconProps['icon'];
onMethodChange?: (method: string) => void;
isLoading: boolean;
forceUpdateKey: string;
@@ -27,6 +29,7 @@ export const UrlBar = memo(function UrlBar({
className,
onSubmit,
onMethodChange,
submitIcon = 'sendHorizontal',
isLoading,
}: Props) {
const inputRef = useRef<EditorView>(null);
@@ -77,7 +80,7 @@ export const UrlBar = memo(function UrlBar({
title="Send Request"
type="submit"
className="w-8 mr-0.5 my-0.5"
icon={isLoading ? 'update' : 'sendHorizontal'}
icon={isLoading ? 'update' : submitIcon}
spin={isLoading}
hotkeyAction="request.send"
/>

View File

@@ -29,6 +29,7 @@ const icons = {
magicWand: lucide.Wand2Icon,
moreVertical: lucide.MoreVerticalIcon,
pencil: lucide.PencilIcon,
plug: lucide.Plug,
plus: lucide.PlusIcon,
plusCircle: lucide.PlusCircleIcon,
question: lucide.ShieldQuestionIcon,
@@ -39,6 +40,9 @@ const icons = {
trash: lucide.TrashIcon,
update: lucide.RefreshCcwIcon,
upload: lucide.UploadIcon,
arrowUpFromDot: lucide.ArrowUpFromDotIcon,
arrowDownToDot: lucide.ArrowDownToDotIcon,
arrowUpDown: lucide.ArrowUpDownIcon,
x: lucide.XIcon,
empty: (props: HTMLAttributes<HTMLSpanElement>) => <span {...props} />,

View File

@@ -6,7 +6,8 @@ export function InlineCode({ className, ...props }: HTMLAttributes<HTMLSpanEleme
<code
className={classNames(
className,
'font-mono text-sm bg-highlight border-0 border-gray-200 px-1.5 py-0.5 rounded text-gray-800 shadow-inner',
'font-mono text-sm bg-highlight border-0 border-gray-200',
'px-1.5 py-0.5 rounded text-gray-800 shadow-inner',
)}
{...props}
/>

View File

@@ -6,10 +6,11 @@ interface Props<T extends string> {
labelPosition?: 'top' | 'left';
labelClassName?: string;
hideLabel?: boolean;
value: string;
options: Record<T, string>;
value: T;
options: { label: string; value: T }[];
onChange: (value: T) => void;
size?: 'xs' | 'sm' | 'md' | 'lg';
className?: string;
}
export function Select<T extends string>({
@@ -21,12 +22,14 @@ export function Select<T extends string>({
value,
options,
onChange,
className,
size = 'md',
}: Props<T>) {
const id = `input-${name}`;
return (
<div
className={classNames(
className,
'w-full',
'pointer-events-auto', // Just in case we're placing in disabled parent
labelPosition === 'left' && 'flex items-center gap-2',
@@ -48,7 +51,7 @@ export function Select<T extends string>({
style={selectBackgroundStyles}
onChange={(e) => onChange(e.target.value as T)}
className={classNames(
'font-mono text-xs border w-full px-2 outline-none bg-transparent',
'font-mono text-xs border w-full outline-none bg-transparent pl-2 pr-7',
'border-highlight focus:border-focus',
size === 'xs' && 'h-xs',
size === 'sm' && 'h-sm',
@@ -56,8 +59,8 @@ export function Select<T extends string>({
size === 'lg' && 'h-lg',
)}
>
{Object.entries<string>(options).map(([value, label]) => (
<option key={value} value={value}>
{options.map(({ label, value }) => (
<option key={label} value={value}>
{label}
</option>
))}
@@ -68,7 +71,7 @@ export function Select<T extends string>({
const selectBackgroundStyles = {
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`,
backgroundPosition: 'right 0.5rem center',
backgroundPosition: 'right 0.3rem center',
backgroundRepeat: 'no-repeat',
backgroundSize: '1.5em 1.5em',
};