Proto selection UI/models

This commit is contained in:
Gregory Schier
2024-02-06 12:29:23 -08:00
parent c85a11edf1
commit 562a36d616
28 changed files with 382 additions and 154 deletions

View File

@@ -90,7 +90,7 @@ export function GrpcConnectionLayout({ style }: Props) {
onReflectRefetch={grpc.reflect.refetch}
services={services ?? null}
reflectionError={grpc.reflect.error as string | undefined}
reflectionLoading={grpc.reflect.isLoading}
reflectionLoading={grpc.reflect.isFetching}
/>
)}
secondSlot={({ style }) =>

View File

@@ -6,9 +6,7 @@ import type { ReflectResponseService } from '../hooks/useGrpc';
import { useGrpcConnections } from '../hooks/useGrpcConnections';
import { useUpdateGrpcRequest } from '../hooks/useUpdateGrpcRequest';
import type { GrpcRequest } from '../lib/models';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { FormattedError } from './core/FormattedError';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { RadioDropdown } from './core/RadioDropdown';
@@ -77,6 +75,11 @@ export function GrpcConnectionSetupPane({
[updateRequest],
);
const handleSelectProtoFiles = useCallback(
(paths: string[]) => updateRequest.mutateAsync({ protoFiles: paths }),
[updateRequest],
);
const select = useMemo(() => {
const options =
services?.flatMap((s) =>
@@ -225,17 +228,14 @@ export function GrpcConnectionSetupPane({
</HStack>
</div>
<GrpcEditor
forceUpdateKey={activeRequest?.id ?? ''}
url={activeRequest.url ?? ''}
defaultValue={activeRequest.message}
onChange={handleChangeMessage}
service={activeRequest.service}
services={services}
method={activeRequest.method}
className="bg-gray-50"
reflectionError={reflectionError}
reflectionLoading={reflectionLoading}
onReflect={onReflectRefetch}
onSelectProtoFiles={handleSelectProtoFiles}
request={activeRequest}
/>
</VStack>
);

View File

@@ -1,70 +1,72 @@
import { open } from '@tauri-apps/api/dialog';
import type { EditorView } from 'codemirror';
import { updateSchema } from 'codemirror-json-schema';
import { useEffect, useRef } from 'react';
import { useAlert } from '../hooks/useAlert';
import type { ReflectResponseService } from '../hooks/useGrpc';
import { tryFormatJson } from '../lib/formatters';
import type { GrpcRequest } from '../lib/models';
import { count } from '../lib/pluralize';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import type { EditorProps } from './core/Editor';
import { Editor } from './core/Editor';
import { FormattedError } from './core/FormattedError';
import { InlineCode } from './core/InlineCode';
import { Link } from './core/Link';
import { HStack, VStack } from './core/Stacks';
import { useDialog } from './DialogContext';
import { GrpcProtoSelection } from './GrpcProtoSelection';
type Props = Pick<
EditorProps,
'heightMode' | 'onChange' | 'defaultValue' | 'className' | 'forceUpdateKey'
> & {
url: string;
service: string | null;
method: string | null;
type Props = Pick<EditorProps, 'heightMode' | 'onChange' | 'className'> & {
services: ReflectResponseService[] | null;
reflectionError?: string;
reflectionLoading?: boolean;
request: GrpcRequest;
onReflect: () => void;
onSelectProtoFiles: (paths: string[]) => void;
};
export function GrpcEditor({
service,
method,
services,
defaultValue,
reflectionError,
reflectionLoading,
onReflect,
onSelectProtoFiles,
request,
...extraEditorProps
}: Props) {
const editorViewRef = useRef<EditorView>(null);
const alert = useAlert();
const dialog = useDialog();
// Find the schema for the selected service and method and update the editor
useEffect(() => {
if (editorViewRef.current == null || services === null) return;
const s = services?.find((s) => s.name === service);
if (service != null && s == null) {
const s = services.find((s) => s.name === request.service);
if (request.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
Failed to find service <InlineCode>{request.service}</InlineCode> in schema
</>
),
});
return;
}
const schema = s?.methods.find((m) => m.name === method)?.schema;
if (method != null && schema == null) {
const schema = s?.methods.find((m) => m.name === request.method)?.schema;
if (request.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
Failed to find method <InlineCode>{request.method}</InlineCode> for{' '}
<InlineCode>{request.service}</InlineCode> in schema
</>
),
});
@@ -84,66 +86,98 @@ export function GrpcEditor({
body: (
<VStack space={4}>
<p>
For service <InlineCode>{service}</InlineCode> and method{' '}
<InlineCode>{method}</InlineCode>
For service <InlineCode>{request.service}</InlineCode> and method{' '}
<InlineCode>{request.method}</InlineCode>
</p>
<FormattedError>{String(err)}</FormattedError>
</VStack>
),
});
console.log('Failed to parse method schema', method, schema);
}
}, [alert, services, method, service]);
}, [alert, services, request.method, request.service]);
const reflectionUnavailable = reflectionError?.match(/unimplemented/i);
reflectionError = reflectionUnavailable ? undefined : reflectionError;
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}
forceUpdateKey={request.id}
defaultValue={request.message}
format={tryFormatJson}
heightMode="auto"
placeholder="..."
ref={editorViewRef}
actions={
reflectionError || reflectionLoading
? [
<div key="introspection" className="!opacity-100">
<Button
key="introspection"
size="xs"
color={reflectionError ? 'danger' : 'gray'}
isLoading={reflectionLoading}
onClick={() => {
dialog.show({
title: 'Introspection Failed',
size: 'dynamic',
id: 'introspection-failed',
render: () => (
<>
<FormattedError>{reflectionError ?? 'unknown'}</FormattedError>
<HStack className="w-full my-4" space={2} justifyContent="end">
<Button color="gray">Select .proto</Button>
actions={[
<div key="reflection" className="!opacity-100">
<Button
size="xs"
color={
reflectionLoading
? 'gray'
: reflectionUnavailable
? 'secondary'
: reflectionError
? 'danger'
: 'gray'
}
isLoading={reflectionLoading}
onClick={() => {
dialog.show({
title: 'Configure Schema',
size: 'md',
id: 'reflection-failed',
render: ({ hide }) => (
<VStack space={6} className="pb-5">
{reflectionError && <FormattedError>{reflectionError}</FormattedError>}
{reflectionUnavailable && request.protoFiles.length === 0 && (
<Banner>
<VStack space={3}>
<p>
<InlineCode>{request.url}</InlineCode> doesn&apos;t implement{' '}
<Link href="https://github.com/grpc/grpc/blob/9aa3c5835a4ed6afae9455b63ed45c761d695bca/doc/server-reflection.md">
Server Reflection
</Link>{' '}
. Please manually add the <InlineCode>.proto</InlineCode> files to get
started.
</p>
<div>
<Button
size="xs"
color="gray"
variant="border"
onClick={() => {
dialog.hide('introspection-failed');
hide();
onReflect();
}}
color="secondary"
>
Try Again
Retry Reflection
</Button>
</HStack>
</>
),
});
}}
>
{reflectionError ? 'Reflection Failed' : 'Reflecting'}
</Button>
</div>,
]
: []
}
</div>
</VStack>
</Banner>
)}
<GrpcProtoSelection requestId={request.id} />
</VStack>
),
});
}}
>
{reflectionLoading
? 'Inspecting Schema'
: reflectionUnavailable
? 'Select Proto Files'
: reflectionError
? 'Server Error'
: services != null
? 'Proto Schema'
: request.protoFiles.length > 0
? count('Proto File', request.protoFiles.length)
: 'Select Schema'}
</Button>
</div>,
]}
{...extraEditorProps}
/>
</div>

View File

@@ -0,0 +1,79 @@
import { open } from '@tauri-apps/api/dialog';
import { useGrpcRequest } from '../hooks/useGrpcRequest';
import { useUpdateGrpcRequest } from '../hooks/useUpdateGrpcRequest';
import { Button } from './core/Button';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
export function GrpcProtoSelection({ requestId }: { requestId: string }) {
const request = useGrpcRequest(requestId);
const updateRequest = useUpdateGrpcRequest(request?.id ?? null);
if (request == null) {
return null;
}
return (
<div>
{request.protoFiles.length > 0 && (
<table className="w-full divide-y mb-3">
<thead>
<tr>
<th className="text-gray-600">
<span className="font-mono text-sm">*.proto</span> Files
</th>
<th></th>
</tr>
</thead>
<tbody className="divide-y">
{request.protoFiles.map((f, i) => (
<tr key={f + i} className="group">
<td className="pl-1 text-sm font-mono">{f.split('/').pop()}</td>
<td className="w-0 py-0.5">
<IconButton
title="Remove file"
size="sm"
icon="trash"
className="ml-auto opacity-30 transition-opacity group-hover:opacity-100"
onClick={() => {
updateRequest.mutate({
protoFiles: request.protoFiles.filter((p) => p !== f),
});
}}
/>
</td>
</tr>
))}
</tbody>
</table>
)}
<HStack space={2} justifyContent="end">
<Button
color="gray"
size="sm"
onClick={async () => {
updateRequest.mutate({ protoFiles: [] });
}}
>
Clear Files
</Button>
<Button
color="primary"
size="sm"
onClick={async () => {
const files = await open({
title: 'Select Proto Files',
multiple: true,
filters: [{ name: 'Proto Files', extensions: ['proto'] }],
});
if (files == null || typeof files === 'string') return;
const newFiles = files.filter((f) => !request.protoFiles.includes(f));
updateRequest.mutate({ protoFiles: [...request.protoFiles, ...newFiles] });
}}
>
Add Files
</Button>
</HStack>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { memo } from 'react';
import { Simulate } from 'react-dom/test-utils';
import { useCreateFolder } from '../hooks/useCreateFolder';
import { useCreateGrpcRequest } from '../hooks/useCreateGrpcRequest';
import { useCreateHttpRequest } from '../hooks/useCreateHttpRequest';
@@ -12,14 +13,18 @@ export const SidebarActions = memo(function SidebarActions() {
const createHttpRequest = useCreateHttpRequest();
const createGrpcRequest = useCreateGrpcRequest();
const createFolder = useCreateFolder();
const { hidden, toggle } = useSidebarHidden();
const { hidden, show, hide } = useSidebarHidden();
return (
<HStack>
<IconButton
onClick={async () => {
trackEvent('Sidebar', 'Toggle');
await toggle();
// NOTE: We're not using `toggle` because it may be out of sync
// from changes in other windows
if (hidden) await show();
else await hide();
}}
className="pointer-events-auto"
size="sm"

View File

@@ -60,7 +60,6 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
size === 'md' && 'h-md px-3',
size === 'sm' && 'h-sm px-2.5 text-sm',
size === 'xs' && 'h-xs px-2 text-sm',
variant === 'border' && 'border',
// Solids
variant === 'solid' && color === 'custom' && 'ring-blue-500/50',
variant === 'solid' &&
@@ -82,12 +81,13 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
color === 'danger' &&
'bg-red-400 text-white enabled:hocus:bg-red-500 ring-red-500/50',
// Borders
variant === 'border' && 'border',
variant === 'border' &&
color === 'default' &&
'border-highlight text-gray-700 enabled:hocus:border-focus enabled:hocus:text-gray-1000 ring-blue-500/50',
'border-highlight text-gray-700 enabled:hocus:border-focus enabled:hocus:text-gray-800 ring-blue-500/50',
variant === 'border' &&
color === 'gray' &&
'border-highlight enabled:hocus:bg-gray-500/20 enabled:hocus:text-gray-1000 ring-blue-500/50',
'border-gray-500/70 text-gray-700 enabled:hocus:bg-gray-500/20 enabled:hocus:text-gray-800 ring-blue-500/50',
variant === 'border' &&
color === 'primary' &&
'border-blue-500/70 text-blue-700 enabled:hocus:border-blue-500 ring-blue-500/50',

View File

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

View File

@@ -0,0 +1,35 @@
import classNames from 'classnames';
import type { HTMLAttributes } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { Icon } from './Icon';
interface Props extends HTMLAttributes<HTMLAnchorElement> {
href: string;
}
export function Link({ href, children, className, ...other }: Props) {
const isExternal = href.match(/^https?:\/\//);
className = classNames(className, 'relative underline hover:text-violet-600');
if (isExternal) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={classNames(className, 'pr-4')}
{...other}
>
<span className="underline">{children}</span>
<Icon className="inline absolute right-0.5 top-0.5" size="xs" icon="externalLink" />
</a>
);
}
return (
<RouterLink to={href} className={className} {...other}>
{children}
</RouterLink>
);
}

View File

@@ -1,8 +1,9 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { emit } from '@tauri-apps/api/event';
import { useCallback } from 'react';
import { minPromiseMillis } from '../lib/minPromiseMillis';
import type { GrpcConnection, GrpcMessage, GrpcRequest } from '../lib/models';
import { useDebouncedValue } from './useDebouncedValue';
export interface ReflectResponseService {
name: string;
@@ -50,11 +51,16 @@ export function useGrpc(req: GrpcRequest | null, conn: GrpcConnection | null) {
mutationFn: async () => await emit(`grpc_client_msg_${conn?.id ?? 'none'}`, 'Commit'),
});
const reflect = useQuery<ReflectResponseService[]>({
queryKey: ['grpc_reflect', req?.url ?? 'n/a'],
const debouncedUrl = useDebouncedValue<string>(req?.url ?? 'n/a', 1000);
const reflect = useQuery<ReflectResponseService[] | null>({
enabled: req != null && req.protoFiles.length === 0,
queryKey: ['grpc_reflect', debouncedUrl],
queryFn: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return (await invoke('cmd_grpc_reflect', { requestId })) as ReflectResponseService[];
console.log('REFLECTING...');
return (await minPromiseMillis(
invoke('cmd_grpc_reflect', { requestId }),
1000,
)) as ReflectResponseService[];
},
});

View File

@@ -0,0 +1,7 @@
import type { GrpcRequest } from '../lib/models';
import { useGrpcRequests } from './useGrpcRequests';
export function useGrpcRequest(id: string | null): GrpcRequest | null {
const requests = useGrpcRequests();
return requests.find((r) => r.id === id) ?? null;
}

View File

@@ -1,7 +1,7 @@
import type { HttpRequest } from '../lib/models';
import { useHttpRequests } from './useHttpRequests';
export function useRequest(id: string | null): HttpRequest | null {
export function useHttpRequest(id: string | null): HttpRequest | null {
const requests = useHttpRequests();
return requests.find((r) => r.id === id) ?? null;
}

View File

@@ -28,6 +28,7 @@ export function useKeyValue<T extends Object | null>({
const query = useQuery<T>({
queryKey: keyValueQueryKey({ namespace, key }),
queryFn: async () => getKeyValue({ namespace, key, fallback: defaultValue }),
refetchOnWindowFocus: false,
});
const mutate = useMutation<void, unknown, T>({

View File

@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { GrpcRequest } from '../lib/models';
import { sleep } from '../lib/sleep';
import { getGrpcRequest } from '../lib/store';
import { grpcRequestsQueryKey } from './useGrpcRequests';

View File

@@ -3,8 +3,10 @@ import type { GrpcRequest } from '../lib/models';
import { useUpdateAnyGrpcRequest } from './useUpdateAnyGrpcRequest';
export function useUpdateGrpcRequest(id: string | null) {
const updateAnyRequest = useUpdateAnyGrpcRequest();
const updateAnyGrpcRequest = useUpdateAnyGrpcRequest();
return useMutation<void, unknown, Partial<GrpcRequest> | ((r: GrpcRequest) => GrpcRequest)>({
mutationFn: async (update) => updateAnyRequest.mutateAsync({ id: id ?? 'n/a', update }),
mutationFn: async (update) => {
return updateAnyGrpcRequest.mutateAsync({ id: id ?? 'n/a', update });
},
});
}

View File

@@ -1,9 +1,19 @@
import { sleep } from './sleep';
/** Ensures a promise takes at least a certain number of milliseconds to resolve */
export async function minPromiseMillis<T>(promise: Promise<T>, millis: number) {
const start = Date.now();
const result = await promise;
let result;
let err;
try {
result = await promise;
} catch (e) {
err = e;
}
const delayFor = millis - (Date.now() - start);
await sleep(delayFor);
return result;
if (err) throw err;
else return result;
}

View File

@@ -114,6 +114,7 @@ export interface GrpcRequest extends BaseModel {
service: string | null;
method: string | null;
message: string;
protoFiles: string[];
}
export interface GrpcMessage extends BaseModel {

View File

@@ -28,6 +28,15 @@
@apply select-none cursor-default;
}
a,
a * {
@apply cursor-pointer !important;
}
table th {
@apply text-left;
}
.hide-scrollbars {
&::-webkit-scrollbar-corner,
&::-webkit-scrollbar {