mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-17 23:14:03 +01:00
Hacky server streaming done
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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} />,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user