gRPC Support (#20)

This commit is contained in:
Gregory Schier
2024-02-09 05:01:00 -08:00
committed by GitHub
parent 219a6b78da
commit 394beb374e
162 changed files with 6670 additions and 1770 deletions

View File

@@ -1,7 +1,7 @@
import { createBrowserRouter, Navigate, Outlet, RouterProvider } from 'react-router-dom';
import { routePaths, useAppRoutes } from '../hooks/useAppRoutes';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRequests } from '../hooks/useRequests';
import { useHttpRequests } from '../hooks/useHttpRequests';
import { GlobalHooks } from './GlobalHooks';
import Workspace from './Workspace';
import Workspaces from './Workspaces';
@@ -49,7 +49,7 @@ export function AppRouter() {
function WorkspaceOrRedirect() {
const recentRequests = useRecentRequests();
const activeEnvironmentId = useActiveEnvironmentId();
const requests = useRequests();
const requests = useHttpRequests();
const request = requests.find((r) => r.id === recentRequests[0]);
const routes = useAppRoutes();

View File

@@ -1,4 +1,4 @@
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import { useUpdateHttpRequest } from '../hooks/useUpdateHttpRequest';
import type { HttpRequest } from '../lib/models';
import { Input } from './core/Input';
import { VStack } from './core/Stacks';
@@ -9,7 +9,7 @@ interface Props {
}
export function BasicAuth({ requestId, authentication }: Props) {
const updateRequest = useUpdateRequest(requestId);
const updateRequest = useUpdateHttpRequest(requestId);
return (
<VStack className="my-2" space={2}>

View File

@@ -1,4 +1,4 @@
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import { useUpdateHttpRequest } from '../hooks/useUpdateHttpRequest';
import type { HttpRequest } from '../lib/models';
import { Input } from './core/Input';
import { VStack } from './core/Stacks';
@@ -9,7 +9,7 @@ interface Props {
}
export function BearerAuth({ requestId, authentication }: Props) {
const updateRequest = useUpdateRequest(requestId);
const updateRequest = useUpdateHttpRequest(requestId);
return (
<VStack className="my-2" space={2}>

View File

@@ -3,20 +3,23 @@ import { appWindow } from '@tauri-apps/api/window';
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { cookieJarsQueryKey } from '../hooks/useCookieJars';
import { grpcConnectionsQueryKey } from '../hooks/useGrpcConnections';
import { grpcMessagesQueryKey } from '../hooks/useGrpcMessages';
import { grpcRequestsQueryKey } from '../hooks/useGrpcRequests';
import { httpRequestsQueryKey } from '../hooks/useHttpRequests';
import { httpResponsesQueryKey } from '../hooks/useHttpResponses';
import { keyValueQueryKey } from '../hooks/useKeyValue';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
import { requestsQueryKey } from '../hooks/useRequests';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { responsesQueryKey } from '../hooks/useResponses';
import { settingsQueryKey } from '../hooks/useSettings';
import { useSyncWindowTitle } from '../hooks/useSyncWindowTitle';
import { useSyncAppearance } from '../hooks/useSyncAppearance';
import { useSyncWindowTitle } from '../hooks/useSyncWindowTitle';
import { workspacesQueryKey } from '../hooks/useWorkspaces';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { HttpRequest, HttpResponse, Model, Workspace } from '../lib/models';
import type { Model } from '../lib/models';
import { modelsEq } from '../lib/models';
import { setPathname } from '../lib/persistPathname';
@@ -42,43 +45,20 @@ export function GlobalHooks() {
setPathname(location.pathname).catch(console.error);
}, [location.pathname]);
useListenToTauriEvent<Model>('created_model', ({ payload, windowLabel }) => {
useListenToTauriEvent<Model>('upserted_model', ({ payload, windowLabel }) => {
if (shouldIgnoreEvent(payload, windowLabel)) return;
const queryKey =
payload.model === 'http_request'
? requestsQueryKey(payload)
? httpRequestsQueryKey(payload)
: payload.model === 'http_response'
? responsesQueryKey(payload)
: payload.model === 'workspace'
? workspacesQueryKey(payload)
: payload.model === 'key_value'
? keyValueQueryKey(payload)
: payload.model === 'settings'
? settingsQueryKey()
: payload.model === 'cookie_jar'
? cookieJarsQueryKey(payload)
: null;
if (queryKey === null) {
console.log('Unrecognized created model:', payload);
return;
}
if (!shouldIgnoreModel(payload)) {
// Order newest first
queryClient.setQueryData<Model[]>(queryKey, (values) => [payload, ...(values ?? [])]);
}
});
useListenToTauriEvent<Model>('updated_model', ({ payload, windowLabel }) => {
if (shouldIgnoreEvent(payload, windowLabel)) return;
const queryKey =
payload.model === 'http_request'
? requestsQueryKey(payload)
: payload.model === 'http_response'
? responsesQueryKey(payload)
? httpResponsesQueryKey(payload)
: payload.model === 'grpc_connection'
? grpcConnectionsQueryKey(payload)
: payload.model === 'grpc_message'
? grpcMessagesQueryKey(payload)
: payload.model === 'grpc_request'
? grpcRequestsQueryKey(payload)
: payload.model === 'workspace'
? workspacesQueryKey(payload)
: payload.model === 'key_value'
@@ -98,12 +78,19 @@ export function GlobalHooks() {
wasUpdatedExternally(payload.id);
}
const pushToFront = (['http_response', 'grpc_connection'] as Model['model'][]).includes(
payload.model,
);
if (!shouldIgnoreModel(payload)) {
console.time('set query date');
queryClient.setQueryData<Model[]>(queryKey, (values) =>
values?.map((v) => (modelsEq(v, payload) ? payload : v)),
);
console.timeEnd('set query date');
queryClient.setQueryData<Model[]>(queryKey, (values = []) => {
const index = values.findIndex((v) => modelsEq(v, payload)) ?? -1;
if (index >= 0) {
return [...values.slice(0, index), payload, ...values.slice(index + 1)];
} else {
return pushToFront ? [payload, ...(values ?? [])] : [...(values ?? []), payload];
}
});
}
});
@@ -113,11 +100,17 @@ export function GlobalHooks() {
if (shouldIgnoreModel(payload)) return;
if (payload.model === 'workspace') {
queryClient.setQueryData<Workspace[]>(workspacesQueryKey(), removeById(payload));
queryClient.setQueryData(workspacesQueryKey(), removeById(payload));
} else if (payload.model === 'http_request') {
queryClient.setQueryData<HttpRequest[]>(requestsQueryKey(payload), removeById(payload));
queryClient.setQueryData(httpRequestsQueryKey(payload), removeById(payload));
} else if (payload.model === 'http_response') {
queryClient.setQueryData<HttpResponse[]>(responsesQueryKey(payload), removeById(payload));
queryClient.setQueryData(httpResponsesQueryKey(payload), removeById(payload));
} else if (payload.model === 'grpc_request') {
queryClient.setQueryData(grpcRequestsQueryKey(payload), removeById(payload));
} else if (payload.model === 'grpc_connection') {
queryClient.setQueryData(grpcConnectionsQueryKey(payload), removeById(payload));
} else if (payload.model === 'grpc_message') {
queryClient.setQueryData(grpcMessagesQueryKey(payload), removeById(payload));
} else if (payload.model === 'key_value') {
queryClient.setQueryData(keyValueQueryKey(payload), undefined);
} else if (payload.model === 'cookie_jar') {

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]">
<Editor
contentType="application/graphql"
defaultValue={query ?? ''}
@@ -124,19 +124,22 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi
}
{...extraEditorProps}
/>
<Separator variant="primary" />
<p className="pt-1 text-gray-500 text-sm">Variables</p>
<Editor
format={tryFormatJson}
contentType="application/json"
defaultValue={JSON.stringify(variables, null, 2)}
heightMode="auto"
onChange={handleChangeVariables}
placeholder="{}"
useTemplating
autocompleteVariables
{...extraEditorProps}
/>
<div className="grid min-h-[5rem]">
<Separator variant="primary" className="pb-1">
Variables
</Separator>
<Editor
format={tryFormatJson}
contentType="application/json"
defaultValue={JSON.stringify(variables, null, 2)}
heightMode="auto"
onChange={handleChangeVariables}
placeholder="{}"
useTemplating
autocompleteVariables
{...extraEditorProps}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,119 @@
import classNames from 'classnames';
import type { CSSProperties } from 'react';
import React, { useEffect, useMemo } from 'react';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useGrpc } from '../hooks/useGrpc';
import { useGrpcConnections } from '../hooks/useGrpcConnections';
import { useGrpcMessages } from '../hooks/useGrpcMessages';
import { useUpdateGrpcRequest } from '../hooks/useUpdateGrpcRequest';
import { Banner } from './core/Banner';
import { HotKeyList } from './core/HotKeyList';
import { SplitLayout } from './core/SplitLayout';
import { GrpcConnectionMessagesPane } from './GrpcConnectionMessagesPane';
import { GrpcConnectionSetupPane } from './GrpcConnectionSetupPane';
interface Props {
style: CSSProperties;
}
export function GrpcConnectionLayout({ style }: Props) {
const activeRequest = useActiveRequest('grpc_request');
const updateRequest = useUpdateGrpcRequest(activeRequest?.id ?? null);
const connections = useGrpcConnections(activeRequest?.id ?? null);
const activeConnection = connections[0] ?? null;
const messages = useGrpcMessages(activeConnection?.id ?? null);
const grpc = useGrpc(activeRequest, activeConnection);
const services = grpc.reflect.data ?? null;
useEffect(() => {
if (services == null || activeRequest == null) return;
const s = services.find((s) => s.name === activeRequest.service);
if (s == null) {
updateRequest.mutate({
service: services[0]?.name ?? null,
method: services[0]?.methods[0]?.name ?? null,
});
return;
}
const m = s.methods.find((m) => m.name === activeRequest.method);
if (m == null) {
updateRequest.mutate({ method: s.methods[0]?.name ?? null });
return;
}
}, [activeRequest, services, updateRequest]);
const activeMethod = useMemo(() => {
if (services == null || activeRequest == null) return null;
const s = services.find((s) => s.name === activeRequest.service);
if (s == null) return null;
return s.methods.find((m) => m.name === activeRequest.method);
}, [activeRequest, services]);
const methodType:
| 'unary'
| 'server_streaming'
| 'client_streaming'
| 'streaming'
| 'no-schema'
| 'no-method' = useMemo(() => {
if (services == null) return 'no-schema';
if (activeMethod == null) return 'no-method';
if (activeMethod.clientStreaming && activeMethod.serverStreaming) return 'streaming';
if (activeMethod.clientStreaming) return 'client_streaming';
if (activeMethod.serverStreaming) return 'server_streaming';
return 'unary';
}, [activeMethod, services]);
if (activeRequest == null) {
return null;
}
return (
<SplitLayout
name="grpc_layout"
className="p-3 gap-1.5"
style={style}
firstSlot={({ style }) => (
<GrpcConnectionSetupPane
style={style}
activeRequest={activeRequest}
methodType={methodType}
onUnary={grpc.unary.mutate}
onServerStreaming={grpc.serverStreaming.mutate}
onClientStreaming={grpc.clientStreaming.mutate}
onStreaming={grpc.streaming.mutate}
onCommit={grpc.commit.mutate}
onCancel={grpc.cancel.mutate}
onSend={grpc.send.mutate}
services={services ?? null}
reflectionError={grpc.reflect.error as string | undefined}
reflectionLoading={grpc.reflect.isFetching}
/>
)}
secondSlot={({ style }) =>
!grpc.unary.isLoading && (
<div
style={style}
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',
)}
>
{grpc.unary.error ? (
<Banner color="danger" className="m-2">
{grpc.unary.error}
</Banner>
) : messages.length >= 0 ? (
<GrpcConnectionMessagesPane activeRequest={activeRequest} methodType={methodType} />
) : (
<HotKeyList hotkeys={['grpc_request.send', 'sidebar.toggle', 'urlBar.focus']} />
)}
</div>
)
}
/>
);
}

View File

@@ -0,0 +1,127 @@
import classNames from 'classnames';
import { format } from 'date-fns';
import type { CSSProperties } from 'react';
import React, { useMemo, useState } from 'react';
import { useGrpcConnections } from '../hooks/useGrpcConnections';
import { useGrpcMessages } from '../hooks/useGrpcMessages';
import type { GrpcRequest } from '../lib/models';
import { Icon } from './core/Icon';
import { JsonAttributeTree } from './core/JsonAttributeTree';
import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout';
import { HStack } from './core/Stacks';
import { RecentConnectionsDropdown } from './RecentConnectionsDropdown';
interface Props {
style?: CSSProperties;
className?: string;
activeRequest: GrpcRequest;
methodType:
| 'unary'
| 'client_streaming'
| 'server_streaming'
| 'streaming'
| 'no-schema'
| 'no-method';
}
export function GrpcConnectionMessagesPane({ style, methodType, activeRequest }: Props) {
const [activeMessageId, setActiveMessageId] = useState<string | null>(null);
const connections = useGrpcConnections(activeRequest.id ?? null);
const activeConnection = connections[0] ?? null;
const messages = useGrpcMessages(activeConnection?.id ?? null);
const activeMessage = useMemo(
() => messages.find((m) => m.id === activeMessageId) ?? null,
[activeMessageId, messages],
);
return (
<SplitLayout
forceVertical
style={style}
name={methodType === 'unary' ? 'grpc_messages_unary' : 'grpc_messages_streaming'}
defaultRatio={methodType === 'unary' ? 0.75 : 0.3}
minHeightPx={20}
firstSlot={() => (
<div className="w-full grid grid-rows-[auto_minmax(0,1fr)] items-center">
<HStack className="pl-3 mb-1 font-mono" alignItems="center">
<HStack alignItems="center" space={2}>
<span>{messages.filter((m) => !m.isInfo).length} messages</span>
{activeConnection?.elapsed === 0 && (
<Icon icon="refresh" size="sm" spin className="text-gray-600" />
)}
</HStack>
{activeConnection && (
<RecentConnectionsDropdown
connections={connections}
activeConnection={activeConnection}
onPinned={() => {
// todo
}}
/>
)}
</HStack>
<div className="overflow-y-auto h-full">
{...messages.map((m) => (
<HStack
role="button"
key={m.id}
space={2}
onClick={() => {
if (m.id === activeMessageId) setActiveMessageId(null);
else setActiveMessageId(m.id);
}}
alignItems="center"
className={classNames(
'px-2 py-1 font-mono cursor-default group',
m === activeMessage && '!bg-highlight',
)}
>
<Icon
className={
m.isInfo ? 'text-gray-600' : m.isServer ? 'text-blue-600' : 'text-green-600'
}
icon={m.isInfo ? 'info' : m.isServer ? 'arrowBigDownDash' : 'arrowBigUpDash'}
/>
<div
className={classNames(
'w-full truncate text-gray-800 text-2xs group-hover:text-gray-900',
m.id === activeMessageId && 'text-gray-900',
)}
>
{m.message}
</div>
<div
className={classNames(
'text-gray-600 text-2xs group-hover:text-gray-700',
m.id === activeMessageId && 'text-gray-700',
)}
>
{format(m.createdAt, 'HH:mm:ss')}
</div>
</HStack>
))}
</div>
</div>
)}
secondSlot={
activeMessage &&
(() => (
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
<div className="pb-3 px-2">
<Separator />
</div>
<div className="pl-2 overflow-y-auto">
{activeMessage.isInfo ? (
<span>{activeMessage.message}</span>
) : (
<JsonAttributeTree attrValue={JSON.parse(activeMessage?.message ?? '{}')} />
)}
</div>
</div>
))
}
/>
);
}

View File

@@ -0,0 +1,235 @@
import useResizeObserver from '@react-hook/resize-observer';
import classNames from 'classnames';
import type { CSSProperties, FormEvent } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import type { ReflectResponseService } from '../hooks/useGrpc';
import { useGrpcConnections } from '../hooks/useGrpcConnections';
import { useUpdateGrpcRequest } from '../hooks/useUpdateGrpcRequest';
import type { GrpcRequest } from '../lib/models';
import { Button } from './core/Button';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { RadioDropdown } from './core/RadioDropdown';
import { HStack, VStack } from './core/Stacks';
import { GrpcEditor } from './GrpcEditor';
import { UrlBar } from './UrlBar';
interface Props {
style?: CSSProperties;
className?: string;
activeRequest: GrpcRequest;
reflectionError?: string;
reflectionLoading?: boolean;
methodType:
| 'unary'
| 'client_streaming'
| 'server_streaming'
| 'streaming'
| 'no-schema'
| 'no-method';
onUnary: () => void;
onCommit: () => void;
onCancel: () => void;
onSend: (v: { message: string }) => void;
onClientStreaming: () => void;
onServerStreaming: () => void;
onStreaming: () => void;
services: ReflectResponseService[] | null;
}
export function GrpcConnectionSetupPane({
style,
services,
methodType,
activeRequest,
reflectionError,
reflectionLoading,
onStreaming,
onClientStreaming,
onServerStreaming,
onCommit,
onCancel,
onSend,
onUnary,
}: Props) {
const connections = useGrpcConnections(activeRequest.id ?? null);
const updateRequest = useUpdateGrpcRequest(activeRequest?.id ?? null);
const activeConnection = connections[0] ?? null;
const isStreaming = activeConnection?.elapsed === 0;
const [paneSize, setPaneSize] = useState(99999);
const urlContainerEl = useRef<HTMLDivElement>(null);
useResizeObserver<HTMLDivElement>(urlContainerEl.current, (entry) => {
setPaneSize(entry.contentRect.width);
});
const handleChangeUrl = useCallback(
(url: string) => updateRequest.mutateAsync({ url }),
[updateRequest],
);
const handleChangeMessage = useCallback(
(message: string) => updateRequest.mutateAsync({ message }),
[updateRequest],
);
const select = useMemo(() => {
const options =
services?.flatMap((s) =>
s.methods.map((m) => ({
label: `${s.name.split('.', 2).pop() ?? s.name}/${m.name}`,
value: `${s.name}/${m.name}`,
})),
) ?? [];
const value = `${activeRequest?.service ?? ''}/${activeRequest?.method ?? ''}`;
return { value, options };
}, [activeRequest?.method, activeRequest?.service, services]);
const handleChangeService = useCallback(
async (v: string) => {
const [serviceName, methodName] = v.split('/', 2);
if (serviceName == null || methodName == null) throw new Error('Should never happen');
await updateRequest.mutateAsync({
service: serviceName,
method: methodName,
});
},
[updateRequest],
);
const handleConnect = useCallback(
async (e: FormEvent) => {
e.preventDefault();
if (activeRequest == null) return;
if (activeRequest.service == null || activeRequest.method == null) {
alert({
id: 'grpc-invalid-service-method',
title: 'Error',
body: 'Service or method not selected',
});
}
if (methodType === 'streaming') {
onStreaming();
} else if (methodType === 'server_streaming') {
onServerStreaming();
} else if (methodType === 'client_streaming') {
onClientStreaming();
} else {
onUnary();
}
},
[activeRequest, methodType, onStreaming, onServerStreaming, onClientStreaming, onUnary],
);
return (
<VStack space={2} style={style}>
<div
ref={urlContainerEl}
className={classNames(
'grid grid-cols-[minmax(0,1fr)_auto] gap-1.5',
paneSize < 400 && '!grid-cols-1',
)}
>
<UrlBar
url={activeRequest.url ?? ''}
method={null}
submitIcon={null}
forceUpdateKey={activeRequest?.id ?? ''}
placeholder="localhost:50051"
onSubmit={handleConnect}
onUrlChange={handleChangeUrl}
isLoading={false}
/>
<HStack space={1.5}>
<RadioDropdown
value={select.value}
onChange={handleChangeService}
items={select.options.map((o) => ({
label: o.label,
value: o.value,
type: 'default',
shortLabel: o.label,
}))}
extraItems={[
{ type: 'separator' },
{
label: 'Refresh',
type: 'default',
key: 'custom',
leftSlot: <Icon className="text-gray-600" size="sm" icon="refresh" />,
},
]}
>
<Button
size="sm"
variant="border"
rightSlot={<Icon className="text-gray-600" size="sm" icon="chevronDown" />}
disabled={isStreaming || services == null}
className={classNames(
'font-mono text-xs min-w-[5rem] !ring-0',
paneSize < 400 && 'flex-1',
)}
>
{select.options.find((o) => o.value === select.value)?.label ?? 'No Schema'}
</Button>
</RadioDropdown>
{!isStreaming && (
<IconButton
className="border border-highlight"
size="sm"
title={methodType === 'unary' ? 'Send' : 'Connect'}
hotkeyAction={isStreaming ? undefined : 'http_request.send'}
onClick={handleConnect}
disabled={methodType === 'no-schema' || methodType === 'no-method'}
icon={
isStreaming
? 'refresh'
: methodType.includes('streaming')
? 'arrowUpDown'
: 'sendHorizontal'
}
/>
)}
{isStreaming && (
<IconButton
className="border border-highlight"
size="sm"
title="Cancel"
onClick={onCancel}
icon="x"
disabled={!isStreaming}
/>
)}
{methodType === 'client_streaming' && isStreaming && (
<IconButton
className="border border-highlight"
size="sm"
title="to-do"
onClick={onCommit}
icon="check"
/>
)}
{(methodType === 'client_streaming' || methodType === 'streaming') && isStreaming && (
<IconButton
className="border border-highlight"
size="sm"
title="to-do"
hotkeyAction="grpc_request.send"
onClick={() => onSend({ message: activeRequest.message ?? '' })}
icon="sendHorizontal"
/>
)}
</HStack>
</div>
<GrpcEditor
onChange={handleChangeMessage}
services={services}
className="bg-gray-50"
reflectionError={reflectionError}
reflectionLoading={reflectionLoading}
request={activeRequest}
/>
</VStack>
);
}

View File

@@ -0,0 +1,153 @@
import classNames from 'classnames';
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 { 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 { VStack } from './core/Stacks';
import { useDialog } from './DialogContext';
import { GrpcProtoSelection } from './GrpcProtoSelection';
type Props = Pick<EditorProps, 'heightMode' | 'onChange' | 'className'> & {
services: ReflectResponseService[] | null;
reflectionError?: string;
reflectionLoading?: boolean;
request: GrpcRequest;
};
export function GrpcEditor({
services,
reflectionError,
reflectionLoading,
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 === 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>{request.service}</InlineCode> in schema
</>
),
});
return;
}
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>{request.method}</InlineCode> for{' '}
<InlineCode>{request.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>{request.service}</InlineCode> and method{' '}
<InlineCode>{request.method}</InlineCode>
</p>
<FormattedError>{String(err)}</FormattedError>
</VStack>
),
});
}
}, [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"
forceUpdateKey={request.id}
defaultValue={request.message}
format={tryFormatJson}
heightMode="auto"
placeholder="..."
ref={editorViewRef}
actions={[
<div key="reflection" className={classNames(services == null && '!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 }) => {
return (
<VStack space={6} className="pb-5">
<GrpcProtoSelection onDone={hide} requestId={request.id} />
</VStack>
);
},
});
}}
>
{reflectionLoading
? 'Inspecting Schema'
: reflectionUnavailable
? 'Select Proto Files'
: reflectionError
? 'Server Error'
: request.protoFiles.length > 0
? count('File', request.protoFiles.length)
: services != null && request.protoFiles.length === 0
? 'Schema Detected'
: 'Select Schema'}
</Button>
</div>,
]}
{...extraEditorProps}
/>
</div>
);
}

View File

@@ -0,0 +1,148 @@
import { open } from '@tauri-apps/api/dialog';
import { useGrpc } from '../hooks/useGrpc';
import { useGrpcRequest } from '../hooks/useGrpcRequest';
import { useUpdateGrpcRequest } from '../hooks/useUpdateGrpcRequest';
import { count } from '../lib/pluralize';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { FormattedError } from './core/FormattedError';
import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode';
import { Link } from './core/Link';
import { HStack, VStack } from './core/Stacks';
interface Props {
requestId: string;
onDone: () => void;
}
export function GrpcProtoSelection({ requestId }: Props) {
const request = useGrpcRequest(requestId);
const grpc = useGrpc(request, null);
const updateRequest = useUpdateGrpcRequest(request?.id ?? null);
const services = grpc.reflect.data;
const serverReflection = request?.protoFiles.length === 0 && services != null;
let reflectError = grpc.reflect.error ?? null;
const reflectionUnimplemented = `${reflectError}`.match(/unimplemented/i);
if (reflectionUnimplemented) {
reflectError = null;
}
if (request == null) {
return null;
}
return (
<VStack className="flex-col-reverse" space={3}>
{/* Buttons on top so they get focus first */}
<HStack space={2} justifyContent="start" className="flex-row-reverse">
<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));
await updateRequest.mutateAsync({ protoFiles: [...request.protoFiles, ...newFiles] });
await grpc.reflect.refetch();
}}
>
Add File
</Button>
<Button
isLoading={grpc.reflect.isFetching}
disabled={grpc.reflect.isFetching}
color="gray"
size="sm"
onClick={() => grpc.reflect.refetch()}
>
Refresh Schema
</Button>
</HStack>
<VStack space={5}>
{!serverReflection && services != null && services.length > 0 && (
<Banner className="flex flex-col gap-2">
<p>
Found services
{services?.slice(0, 5).map((s, i) => {
return (
<span key={i}>
<InlineCode>{s.name}</InlineCode>
{i === services.length - 1 ? '' : i === services.length - 2 ? ' and ' : ', '}
</span>
);
})}
{services?.length > 5 && count('other', services?.length - 5)}
</p>
</Banner>
)}
{serverReflection && services != null && services.length > 0 && (
<Banner className="flex flex-col gap-2">
<p>
Server reflection found services
{services?.map((s, i) => {
return (
<span key={i}>
<InlineCode>{s.name}</InlineCode>
{i === services.length - 1 ? '' : i === services.length - 2 ? ' and ' : ', '}
</span>
);
})}
. You can override this schema by manually selecting <InlineCode>*.proto</InlineCode>{' '}
files.
</p>
</Banner>
)}
{request.protoFiles.length > 0 && (
<table className="w-full divide-y">
<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={async () => {
await updateRequest.mutateAsync({
protoFiles: request.protoFiles.filter((p) => p !== f),
});
grpc.reflect.remove();
}}
/>
</td>
</tr>
))}
</tbody>
</table>
)}
{reflectError && <FormattedError>{reflectError}</FormattedError>}
{reflectionUnimplemented && request.protoFiles.length === 0 && (
<Banner>
<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> file to get started.
</Banner>
)}
</VStack>
</VStack>
);
}

View File

@@ -0,0 +1,29 @@
import type { CSSProperties } from 'react';
import React from 'react';
import type { HttpRequest } from '../lib/models';
import { SplitLayout } from './core/SplitLayout';
import { RequestPane } from './RequestPane';
import { ResponsePane } from './ResponsePane';
interface Props {
activeRequest: HttpRequest;
style: CSSProperties;
}
export function HttpRequestLayout({ activeRequest, style }: Props) {
return (
<SplitLayout
name="http_layout"
className="p-3 gap-1.5"
style={style}
firstSlot={({ orientation, style }) => (
<RequestPane
style={style}
activeRequest={activeRequest}
fullHeight={orientation === 'horizontal'}
/>
)}
secondSlot={({ style }) => <ResponsePane activeRequest={activeRequest} style={style} />}
/>
);
}

View File

@@ -0,0 +1,60 @@
import { formatDistanceToNowStrict } from 'date-fns';
import { useDeleteGrpcConnection } from '../hooks/useDeleteGrpcConnection';
import { useDeleteGrpcConnections } from '../hooks/useDeleteGrpcConnections';
import type { GrpcConnection } from '../lib/models';
import { count, pluralize } from '../lib/pluralize';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
interface Props {
connections: GrpcConnection[];
activeConnection: GrpcConnection;
onPinned: (r: GrpcConnection) => void;
}
export function RecentConnectionsDropdown({ activeConnection, connections, onPinned }: Props) {
const deleteConnection = useDeleteGrpcConnection(activeConnection?.id ?? null);
const deleteAllConnections = useDeleteGrpcConnections(activeConnection?.requestId);
return (
<Dropdown
items={[
{
key: 'clear-single',
label: 'Clear Connection',
onSelect: deleteConnection.mutate,
disabled: connections.length === 0,
},
{
key: 'clear-all',
label: `Clear ${count('Connection', connections.length)}`,
onSelect: deleteAllConnections.mutate,
hidden: connections.length <= 1,
disabled: connections.length === 0,
},
{ type: 'separator', label: 'History' },
...connections.slice(0, 20).map((c) => ({
key: c.id,
label: (
<HStack space={2} alignItems="center">
{formatDistanceToNowStrict(c.createdAt + 'Z')} ago &bull;{' '}
<span className="font-mono text-xs">{c.elapsed}ms</span>
</HStack>
),
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinned(c),
})),
]}
>
<IconButton
title="Show connection history"
icon="chevronDown"
className="ml-auto"
size="sm"
iconSize="md"
/>
</Dropdown>
);
}

View File

@@ -7,7 +7,7 @@ import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useHotKey } from '../hooks/useHotKey';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRequests } from '../hooks/useRequests';
import { useHttpRequests } from '../hooks/useHttpRequests';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
@@ -19,7 +19,7 @@ export function RecentRequestsDropdown({ className }: Pick<ButtonProps, 'classNa
const activeRequest = useActiveRequest();
const activeWorkspaceId = useActiveWorkspaceId();
const activeEnvironmentId = useActiveEnvironmentId();
const requests = useRequests();
const requests = useHttpRequests();
const routes = useAppRoutes();
const allRecentRequestIds = useRecentRequests();
const recentRequestIds = useMemo(() => allRecentRequestIds.slice(1), [allRecentRequestIds]);

View File

@@ -1,5 +1,5 @@
import { useDeleteResponse } from '../hooks/useDeleteResponse';
import { useDeleteResponses } from '../hooks/useDeleteResponses';
import { useDeleteHttpResponse } from '../hooks/useDeleteHttpResponse';
import { useDeleteHttpResponses } from '../hooks/useDeleteHttpResponses';
import type { HttpResponse } from '../lib/models';
import { Dropdown } from './core/Dropdown';
import { pluralize } from '../lib/pluralize';
@@ -19,8 +19,8 @@ export const RecentResponsesDropdown = function ResponsePane({
responses,
onPinnedResponse,
}: Props) {
const deleteResponse = useDeleteResponse(activeResponse?.id ?? null);
const deleteAllResponses = useDeleteResponses(activeResponse?.requestId);
const deleteResponse = useDeleteHttpResponse(activeResponse?.id ?? null);
const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId);
return (
<Dropdown

View File

@@ -1,10 +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 { useUpdateRequest } from '../hooks/useUpdateRequest';
import { useSendRequest } from '../hooks/useSendRequest';
import { useUpdateHttpRequest } from '../hooks/useUpdateHttpRequest';
import { tryFormatJson } from '../lib/formatters';
import type { HttpHeader, HttpRequest, HttpUrlParameter } from '../lib/models';
import {
@@ -33,131 +34,131 @@ import { UrlBar } from './UrlBar';
import { UrlParametersEditor } from './UrlParameterEditor';
interface Props {
style?: CSSProperties;
style: CSSProperties;
fullHeight: boolean;
className?: string;
activeRequest: HttpRequest;
}
const useActiveTab = createGlobalState<string>('body');
export const RequestPane = memo(function RequestPane({ style, fullHeight, className }: Props) {
const activeRequest = useActiveRequest();
const activeRequestId = activeRequest?.id ?? null;
const updateRequest = useUpdateRequest(activeRequestId);
export const RequestPane = memo(function RequestPane({
style,
fullHeight,
className,
activeRequest,
}: Props) {
const activeRequestId = activeRequest.id;
const updateRequest = useUpdateHttpRequest(activeRequestId);
const [activeTab, setActiveTab] = useActiveTab();
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
const { updateKey: forceUpdateKey } = useRequestUpdateKey(activeRequest?.id ?? null);
const { updateKey: forceUpdateKey } = useRequestUpdateKey(activeRequest.id ?? null);
const tabs: TabItem[] = useMemo(
() =>
activeRequest === null
? []
: [
{
value: 'body',
options: {
value: activeRequest.bodyType,
items: [
{ type: 'separator', label: 'Form Data' },
{ label: 'Url Encoded', value: BODY_TYPE_FORM_URLENCODED },
{ label: 'Multi-Part', value: BODY_TYPE_FORM_MULTIPART },
{ type: 'separator', label: 'Text Content' },
{ label: 'JSON', value: BODY_TYPE_JSON },
{ label: 'XML', value: BODY_TYPE_XML },
{ label: 'GraphQL', value: BODY_TYPE_GRAPHQL },
{ type: 'separator', label: 'Other' },
{ label: 'No Body', shortLabel: 'Body', value: BODY_TYPE_NONE },
],
onChange: async (bodyType) => {
const patch: Partial<HttpRequest> = { bodyType };
if (bodyType === BODY_TYPE_NONE) {
patch.headers = activeRequest?.headers.filter(
(h) => h.name.toLowerCase() !== 'content-type',
);
} else if (
bodyType === BODY_TYPE_FORM_URLENCODED ||
bodyType === BODY_TYPE_FORM_MULTIPART ||
bodyType === BODY_TYPE_JSON ||
bodyType === BODY_TYPE_XML
) {
patch.method = 'POST';
patch.headers = [
...(activeRequest?.headers.filter(
(h) => h.name.toLowerCase() !== 'content-type',
) ?? []),
{
name: 'Content-Type',
value: bodyType,
enabled: true,
},
];
} else if (bodyType == BODY_TYPE_GRAPHQL) {
patch.method = 'POST';
patch.headers = [
...(activeRequest?.headers.filter(
(h) => h.name.toLowerCase() !== 'content-type',
) ?? []),
{
name: 'Content-Type',
value: 'application/json',
enabled: true,
},
];
}
// Force update header editor so any changed headers are reflected
setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);
updateRequest.mutate(patch);
},
},
},
{
value: 'params',
label: (
<div className="flex items-center">
Params
<CountBadge count={activeRequest.urlParameters.filter((p) => p.name).length} />
</div>
),
},
{
value: 'headers',
label: (
<div className="flex items-center">
Headers
<CountBadge count={activeRequest.headers.filter((h) => h.name).length} />
</div>
),
},
{
value: 'auth',
label: 'Auth',
options: {
value: activeRequest.authenticationType,
items: [
{ label: 'Basic Auth', shortLabel: 'Basic', value: AUTH_TYPE_BASIC },
{ label: 'Bearer Token', shortLabel: 'Bearer', value: AUTH_TYPE_BEARER },
{ type: 'separator' },
{ label: 'No Authentication', shortLabel: 'Auth', value: AUTH_TYPE_NONE },
],
onChange: async (authenticationType) => {
let authentication: HttpRequest['authentication'] = activeRequest?.authentication;
if (authenticationType === AUTH_TYPE_BASIC) {
authentication = {
username: authentication.username ?? '',
password: authentication.password ?? '',
};
} else if (authenticationType === AUTH_TYPE_BEARER) {
authentication = {
token: authentication.token ?? '',
};
}
updateRequest.mutate({ authenticationType, authentication });
},
},
},
() => [
{
value: 'body',
options: {
value: activeRequest.bodyType,
items: [
{ type: 'separator', label: 'Form Data' },
{ label: 'Url Encoded', value: BODY_TYPE_FORM_URLENCODED },
{ label: 'Multi-Part', value: BODY_TYPE_FORM_MULTIPART },
{ type: 'separator', label: 'Text Content' },
{ label: 'JSON', value: BODY_TYPE_JSON },
{ label: 'XML', value: BODY_TYPE_XML },
{ label: 'GraphQL', value: BODY_TYPE_GRAPHQL },
{ type: 'separator', label: 'Other' },
{ label: 'No Body', shortLabel: 'Body', value: BODY_TYPE_NONE },
],
onChange: async (bodyType) => {
const patch: Partial<HttpRequest> = { bodyType };
if (bodyType === BODY_TYPE_NONE) {
patch.headers = activeRequest.headers.filter(
(h) => h.name.toLowerCase() !== 'content-type',
);
} else if (
bodyType === BODY_TYPE_FORM_URLENCODED ||
bodyType === BODY_TYPE_FORM_MULTIPART ||
bodyType === BODY_TYPE_JSON ||
bodyType === BODY_TYPE_XML
) {
patch.method = 'POST';
patch.headers = [
...(activeRequest.headers.filter((h) => h.name.toLowerCase() !== 'content-type') ??
[]),
{
name: 'Content-Type',
value: bodyType,
enabled: true,
},
];
} else if (bodyType == BODY_TYPE_GRAPHQL) {
patch.method = 'POST';
patch.headers = [
...(activeRequest.headers.filter((h) => h.name.toLowerCase() !== 'content-type') ??
[]),
{
name: 'Content-Type',
value: 'application/json',
enabled: true,
},
];
}
// Force update header editor so any changed headers are reflected
setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);
updateRequest.mutate(patch);
},
},
},
{
value: 'params',
label: (
<div className="flex items-center">
Params
<CountBadge count={activeRequest.urlParameters.filter((p) => p.name).length} />
</div>
),
},
{
value: 'headers',
label: (
<div className="flex items-center">
Headers
<CountBadge count={activeRequest.headers.filter((h) => h.name).length} />
</div>
),
},
{
value: 'auth',
label: 'Auth',
options: {
value: activeRequest.authenticationType,
items: [
{ label: 'Basic Auth', shortLabel: 'Basic', value: AUTH_TYPE_BASIC },
{ label: 'Bearer Token', shortLabel: 'Bearer', value: AUTH_TYPE_BEARER },
{ type: 'separator' },
{ label: 'No Authentication', shortLabel: 'Auth', value: AUTH_TYPE_NONE },
],
onChange: async (authenticationType) => {
let authentication: HttpRequest['authentication'] = activeRequest.authentication;
if (authenticationType === AUTH_TYPE_BASIC) {
authentication = {
username: authentication.username ?? '',
password: authentication.password ?? '',
};
} else if (authenticationType === AUTH_TYPE_BEARER) {
authentication = {
token: authentication.token ?? '',
};
}
updateRequest.mutate({ authenticationType, authentication });
},
},
},
],
[activeRequest, updateRequest],
);
@@ -178,6 +179,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}
@@ -186,10 +208,14 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
{activeRequest && (
<>
<UrlBar
key={activeRequest.id} // Force-reset the url bar when the active request changes
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}
@@ -240,7 +266,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
placeholder="..."
className="!bg-gray-50"
heightMode={fullHeight ? 'full' : 'auto'}
defaultValue={`${activeRequest?.body?.text ?? ''}`}
defaultValue={`${activeRequest.body?.text ?? ''}`}
contentType="application/json"
onChange={handleBodyTextChange}
format={tryFormatJson}
@@ -253,7 +279,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
placeholder="..."
className="!bg-gray-50"
heightMode={fullHeight ? 'full' : 'auto'}
defaultValue={`${activeRequest?.body?.text ?? ''}`}
defaultValue={`${activeRequest.body?.text ?? ''}`}
contentType="text/xml"
onChange={handleBodyTextChange}
/>
@@ -262,7 +288,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
forceUpdateKey={forceUpdateKey}
baseRequest={activeRequest}
className="!bg-gray-50"
defaultValue={`${activeRequest?.body?.text ?? ''}`}
defaultValue={`${activeRequest.body?.text ?? ''}`}
onChange={handleBodyTextChange}
/>
) : activeRequest.bodyType === BODY_TYPE_FORM_URLENCODED ? (

View File

@@ -1,139 +0,0 @@
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 { 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';
interface Props {
style: CSSProperties;
}
const rqst = { gridArea: 'rqst' };
const resp = { gridArea: 'resp' };
const drag = { 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) {
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()}`);
const width = widthRaw ?? DEFAULT;
const height = heightRaw ?? DEFAULT;
const [isResizing, setIsResizing] = useState<boolean>(false);
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
null,
);
useResizeObserver(containerRef.current, ({ contentRect }) => {
setVertical(contentRect.width < STACK_VERTICAL_WIDTH);
});
const styles = useMemo<CSSProperties>(
() => ({
...style,
gridTemplate: vertical
? `
' ${rqst.gridArea}' minmax(0,${1 - height}fr)
' ${drag.gridArea}' 0
' ${resp.gridArea}' minmax(0,${height}fr)
/ 1fr
`
: `
' ${rqst.gridArea} ${drag.gridArea} ${resp.gridArea}' minmax(0,1fr)
/ ${1 - width}fr 0 ${width}fr
`,
}),
[vertical, width, height, style],
);
const unsub = () => {
if (moveState.current !== null) {
document.documentElement.removeEventListener('mousemove', moveState.current.move);
document.documentElement.removeEventListener('mouseup', moveState.current.up);
}
};
const handleReset = useCallback(
() => (vertical ? setHeight(DEFAULT) : setWidth(DEFAULT)),
[setHeight, vertical, setWidth],
);
const handleResizeStart = useCallback(
(e: ReactMouseEvent<HTMLDivElement>) => {
if (containerRef.current === null) return;
unsub();
const containerRect = containerRef.current.getBoundingClientRect();
const mouseStartX = e.clientX;
const mouseStartY = e.clientY;
const startWidth = containerRect.width * width;
const startHeight = containerRect.height * height;
moveState.current = {
move: (e: MouseEvent) => {
e.preventDefault(); // Prevent text selection and things
if (vertical) {
const maxHeightPx = containerRect.height - MIN_HEIGHT_PX;
const newHeightPx = clamp(
startHeight - (e.clientY - mouseStartY),
MIN_HEIGHT_PX,
maxHeightPx,
);
setHeight(newHeightPx / containerRect.height);
} else {
const maxWidthPx = containerRect.width - MIN_WIDTH_PX;
const newWidthPx = clamp(
startWidth - (e.clientX - mouseStartX),
MIN_WIDTH_PX,
maxWidthPx,
);
setWidth(newWidthPx / containerRect.width);
}
},
up: (e: MouseEvent) => {
e.preventDefault();
unsub();
setIsResizing(false);
},
};
document.documentElement.addEventListener('mousemove', moveState.current.move);
document.documentElement.addEventListener('mouseup', moveState.current.up);
setIsResizing(true);
},
[width, height, vertical, setHeight, setWidth],
);
if (activeRequest === 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} />
<ResizeHandle
style={drag}
isResizing={isResizing}
className={classNames(vertical ? 'translate-y-0.5' : 'translate-x-0.5')}
onResizeStart={handleResizeStart}
onReset={handleReset}
side={vertical ? 'top' : 'left'}
justify="center"
/>
<ResponsePane style={resp} />
</div>
);
});

View File

@@ -1,6 +1,7 @@
import classNames from 'classnames';
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
import React from 'react';
import { Separator } from './core/Separator';
interface ResizeBarProps {
style?: CSSProperties;
@@ -17,6 +18,7 @@ export function ResizeHandle({
style,
justify,
className,
barClassName,
onResizeStart,
onReset,
isResizing,
@@ -28,6 +30,8 @@ export function ResizeHandle({
aria-hidden
draggable
style={style}
onDragStart={onResizeStart}
onDoubleClick={onReset}
className={classNames(
className,
'group z-10 flex',
@@ -39,8 +43,6 @@ export function ResizeHandle({
side === 'left' && 'left-0',
side === 'top' && 'top-0',
)}
onDragStart={onResizeStart}
onDoubleClick={onReset}
>
{/* Show global overlay with cursor style to ensure cursor remains the same when moving quickly */}
{isResizing && (

View File

@@ -2,12 +2,11 @@ import classNames from 'classnames';
import type { CSSProperties } from 'react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { createGlobalState } from 'react-use';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useLatestResponse } from '../hooks/useLatestResponse';
import { useHttpResponses } from '../hooks/useHttpResponses';
import { useLatestHttpResponse } from '../hooks/useLatestHttpResponse';
import { useResponseContentType } from '../hooks/useResponseContentType';
import { useResponses } from '../hooks/useResponses';
import { useResponseViewMode } from '../hooks/useResponseViewMode';
import type { HttpResponse } from '../lib/models';
import type { HttpRequest, HttpResponse } from '../lib/models';
import { isResponseLoading } from '../lib/models';
import { Banner } from './core/Banner';
import { CountBadge } from './core/CountBadge';
@@ -29,15 +28,15 @@ import { WebPageViewer } from './responseViewers/WebPageViewer';
interface Props {
style?: CSSProperties;
className?: string;
activeRequest: HttpRequest;
}
const useActiveTab = createGlobalState<string>('body');
export const ResponsePane = memo(function ResponsePane({ style, className }: Props) {
export const ResponsePane = memo(function ResponsePane({ style, className, activeRequest }: Props) {
const [pinnedResponseId, setPinnedResponseId] = useState<string | null>(null);
const activeRequest = useActiveRequest();
const latestResponse = useLatestResponse(activeRequest?.id ?? null);
const responses = useResponses(activeRequest?.id ?? null);
const latestResponse = useLatestHttpResponse(activeRequest.id);
const responses = useHttpResponses(activeRequest.id);
const activeResponse: HttpResponse | null = pinnedResponseId
? responses.find((r) => r.id === pinnedResponseId) ?? null
: latestResponse ?? null;
@@ -85,10 +84,6 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
[activeResponse?.headers, contentType, setViewMode, viewMode],
);
if (activeRequest === null) {
return null;
}
return (
<div
style={style}
@@ -108,7 +103,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
<>
<span />
<HotKeyList
hotkeys={['request.send', 'request.create', 'sidebar.toggle', 'urlBar.focus']}
hotkeys={['http_request.send', 'http_request.create', 'sidebar.toggle', 'urlBar.focus']}
/>
</>
)}
@@ -179,6 +174,8 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
) : contentType?.match(/csv|tab-separated/) ? (
<CsvViewer className="pb-2" response={activeResponse} />
) : (
// ) : contentType?.startsWith('application/json') ? (
// <JsonViewer response={activeResponse} />
<TextViewer response={activeResponse} pretty={viewMode === 'pretty'} />
)}
</TabContent>

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

@@ -102,7 +102,7 @@ export function SettingsDropdown() {
label: 'Check for Updates',
leftSlot: <Icon icon="update" />,
onSelect: async () => {
const hasUpdate: boolean = await invoke('check_for_updates');
const hasUpdate: boolean = await invoke('cmd_check_for_updates');
if (!hasUpdate) {
alert({
id: 'no-updates',

View File

@@ -7,31 +7,37 @@ import { useKey, useKeyPressEvent } from 'react-use';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveRequestId } from '../hooks/useActiveRequestId';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCreateFolder } from '../hooks/useCreateFolder';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { useDeleteAnyRequest } from '../hooks/useDeleteAnyRequest';
import { useCreateHttpRequest } from '../hooks/useCreateHttpRequest';
import { useDeleteAnyGrpcRequest } from '../hooks/useDeleteAnyGrpcRequest';
import { useDeleteAnyHttpRequest } from '../hooks/useDeleteAnyHttpRequest';
import { useDeleteFolder } from '../hooks/useDeleteFolder';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
import { useDuplicateGrpcRequest } from '../hooks/useDuplicateGrpcRequest';
import { useDuplicateHttpRequest } from '../hooks/useDuplicateHttpRequest';
import { useFolders } from '../hooks/useFolders';
import { useGrpcRequests } from '../hooks/useGrpcRequests';
import { useHotKey } from '../hooks/useHotKey';
import { useHttpRequests } from '../hooks/useHttpRequests';
import { useKeyValue } from '../hooks/useKeyValue';
import { useLatestResponse } from '../hooks/useLatestResponse';
import { useLatestGrpcConnection } from '../hooks/useLatestGrpcConnection';
import { useLatestHttpResponse } from '../hooks/useLatestHttpResponse';
import { usePrompt } from '../hooks/usePrompt';
import { useRequests } from '../hooks/useRequests';
import { useSendManyRequests } from '../hooks/useSendFolder';
import { useSendRequest } from '../hooks/useSendRequest';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useUpdateAnyFolder } from '../hooks/useUpdateAnyFolder';
import { useUpdateAnyRequest } from '../hooks/useUpdateAnyRequest';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import { useUpdateAnyGrpcRequest } from '../hooks/useUpdateAnyGrpcRequest';
import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest';
import { useUpdateGrpcRequest } from '../hooks/useUpdateGrpcRequest';
import { useUpdateHttpRequest } from '../hooks/useUpdateHttpRequest';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { Folder, HttpRequest, Workspace } from '../lib/models';
import type { Folder, GrpcRequest, HttpRequest, Workspace } from '../lib/models';
import { isResponseLoading } from '../lib/models';
import type { DropdownItem } from './core/Dropdown';
import { ContextMenu } from './core/Dropdown';
import { Icon } from './core/Icon';
import { InlineCode } from './core/InlineCode';
@@ -48,7 +54,7 @@ enum ItemTypes {
}
interface TreeNode {
item: Workspace | Folder | HttpRequest;
item: Workspace | Folder | HttpRequest | GrpcRequest;
children: TreeNode[];
depth: number;
}
@@ -56,18 +62,28 @@ interface TreeNode {
export function Sidebar({ className }: Props) {
const { hidden } = useSidebarHidden();
const sidebarRef = useRef<HTMLLIElement>(null);
const activeRequestId = useActiveRequestId();
const activeRequest = useActiveRequest();
const activeEnvironmentId = useActiveEnvironmentId();
const requests = useRequests();
const httpRequests = useHttpRequests();
const grpcRequests = useGrpcRequests();
const folders = useFolders();
const deleteAnyRequest = useDeleteAnyRequest();
const deleteAnyHttpRequest = useDeleteAnyHttpRequest();
const deleteAnyGrpcRequest = useDeleteAnyGrpcRequest();
const activeWorkspace = useActiveWorkspace();
const duplicateRequest = useDuplicateRequest({ id: activeRequestId, navigateAfter: true });
const duplicateHttpRequest = useDuplicateHttpRequest({
id: activeRequest?.id ?? null,
navigateAfter: true,
});
const duplicateGrpcRequest = useDuplicateGrpcRequest({
id: activeRequest?.id ?? null,
navigateAfter: true,
});
const routes = useAppRoutes();
const [hasFocus, setHasFocus] = useState<boolean>(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectedTree, setSelectedTree] = useState<TreeNode | null>(null);
const updateAnyRequest = useUpdateAnyRequest();
const updateAnyHttpRequest = useUpdateAnyHttpRequest();
const updateAnyGrpcRequest = useUpdateAnyGrpcRequest();
const updateAnyFolder = useUpdateAnyFolder();
const [draggingId, setDraggingId] = useState<string | null>(null);
const [hoveredTree, setHoveredTree] = useState<TreeNode | null>(null);
@@ -78,8 +94,12 @@ export function Sidebar({ className }: Props) {
namespace: NAMESPACE_NO_SYNC,
});
useHotKey('request.duplicate', () => {
duplicateRequest.mutate();
useHotKey('http_request.duplicate', async () => {
if (activeRequest?.model === 'http_request') {
await duplicateHttpRequest.mutateAsync();
} else {
await duplicateGrpcRequest.mutateAsync();
}
});
const isCollapsed = useCallback(
@@ -110,7 +130,7 @@ export function Sidebar({ className }: Props) {
// Put requests and folders into a tree structure
const next = (node: TreeNode): TreeNode => {
const childItems = [...requests, ...folders].filter((f) =>
const childItems = [...httpRequests, ...grpcRequests, ...folders].filter((f) =>
node.item.model === 'workspace' ? f.folderId == null : f.folderId === node.item.id,
);
@@ -119,7 +139,7 @@ export function Sidebar({ className }: Props) {
for (const item of childItems) {
treeParentMap[item.id] = node;
node.children.push(next({ item, children: [], depth }));
if (item.model === 'http_request') {
if (item.model !== 'folder') {
selectableRequests.push({ id: item.id, index: selectableRequestIndex++, tree: node });
}
}
@@ -129,7 +149,7 @@ export function Sidebar({ className }: Props) {
const tree = next({ item: activeWorkspace, children: [], depth: 0 });
return { tree, treeParentMap, selectableRequests };
}, [activeWorkspace, requests, folders]);
}, [activeWorkspace, httpRequests, grpcRequests, folders]);
const focusActiveRequest = useCallback(
(
@@ -142,9 +162,10 @@ export function Sidebar({ className }: Props) {
} = {},
) => {
const { forced, noFocusSidebar } = args;
const tree = forced?.tree ?? treeParentMap[activeRequestId ?? 'n/a'] ?? null;
const tree = forced?.tree ?? treeParentMap[activeRequest?.id ?? 'n/a'] ?? null;
const children = tree?.children ?? [];
const id = forced?.id ?? children.find((m) => m.item.id === activeRequestId)?.item.id ?? null;
const id =
forced?.id ?? children.find((m) => m.item.id === activeRequest?.id)?.item.id ?? null;
if (id == null) {
return;
}
@@ -156,11 +177,11 @@ export function Sidebar({ className }: Props) {
sidebarRef.current?.focus();
}
},
[activeRequestId, treeParentMap],
[activeRequest, treeParentMap],
);
const handleSelect = useCallback(
(id: string) => {
async (id: string) => {
const tree = treeParentMap[id ?? 'n/a'] ?? null;
const children = tree?.children ?? [];
const node = children.find((m) => m.item.id === id) ?? null;
@@ -171,7 +192,7 @@ export function Sidebar({ className }: Props) {
const { item } = node;
if (item.model === 'folder') {
collapsed.set((c) => ({ ...c, [item.id]: !c[item.id] }));
await collapsed.set((c) => ({ ...c, [item.id]: !c[item.id] }));
} else {
routes.navigate('request', {
requestId: id,
@@ -205,9 +226,10 @@ export function Sidebar({ className }: Props) {
const selected = selectableRequests.find((r) => r.id === selectedId);
if (selected == null) return;
deleteAnyRequest.mutate(selected.id);
deleteAnyHttpRequest.mutate(selected.id);
deleteAnyGrpcRequest.mutate(selected.id);
},
[deleteAnyRequest, hasFocus, selectableRequests, selectedId],
[deleteAnyHttpRequest, deleteAnyGrpcRequest, hasFocus, selectableRequests, selectedId],
);
useKeyPressEvent('Backspace', handleDeleteKey);
@@ -226,7 +248,7 @@ export function Sidebar({ className }: Props) {
useKeyPressEvent('Enter', (e) => {
if (!hasFocus) return;
const selected = selectableRequests.find((r) => r.id === selectedId);
if (!selected || selected.id === activeRequestId || activeWorkspace == null) {
if (!selected || selected.id === activeRequest?.id || activeWorkspace == null) {
return;
}
@@ -339,9 +361,12 @@ export function Sidebar({ className }: Props) {
if (child.item.model === 'folder') {
const updateFolder = (f: Folder) => ({ ...f, sortPriority, folderId });
return updateAnyFolder.mutateAsync({ id: child.item.id, update: updateFolder });
} else if (child.item.model === 'grpc_request') {
const updateRequest = (r: GrpcRequest) => ({ ...r, sortPriority, folderId });
return updateAnyGrpcRequest.mutateAsync({ id: child.item.id, update: updateRequest });
} else if (child.item.model === 'http_request') {
const updateRequest = (r: HttpRequest) => ({ ...r, sortPriority, folderId });
return updateAnyRequest.mutateAsync({ id: child.item.id, update: updateRequest });
return updateAnyHttpRequest.mutateAsync({ id: child.item.id, update: updateRequest });
}
}),
);
@@ -350,20 +375,24 @@ export function Sidebar({ className }: Props) {
if (child.item.model === 'folder') {
const updateFolder = (f: Folder) => ({ ...f, sortPriority, folderId });
await updateAnyFolder.mutateAsync({ id: child.item.id, update: updateFolder });
} else if (child.item.model === 'grpc_request') {
const updateRequest = (r: GrpcRequest) => ({ ...r, sortPriority, folderId });
await updateAnyGrpcRequest.mutateAsync({ id: child.item.id, update: updateRequest });
} else if (child.item.model === 'http_request') {
const updateRequest = (r: HttpRequest) => ({ ...r, sortPriority, folderId });
await updateAnyRequest.mutateAsync({ id: child.item.id, update: updateRequest });
await updateAnyHttpRequest.mutateAsync({ id: child.item.id, update: updateRequest });
}
}
setDraggingId(null);
},
[
hoveredIndex,
hoveredTree,
handleClearSelected,
hoveredTree,
hoveredIndex,
treeParentMap,
updateAnyFolder,
updateAnyRequest,
updateAnyGrpcRequest,
updateAnyHttpRequest,
],
);
@@ -454,7 +483,9 @@ function SidebarItems({
itemId={child.item.id}
itemName={child.item.name}
itemFallbackName={
child.item.model === 'http_request' ? fallbackRequestName(child.item) : 'New Folder'
child.item.model === 'http_request' || child.item.model === 'grpc_request'
? fallbackRequestName(child.item)
: 'New Folder'
}
itemModel={child.item.model}
onMove={handleMove}
@@ -524,16 +555,18 @@ const SidebarItem = forwardRef(function SidebarItem(
ref: ForwardedRef<HTMLLIElement>,
) {
const activeRequest = useActiveRequest();
const createRequest = useCreateRequest();
const createRequest = useCreateHttpRequest();
const createFolder = useCreateFolder();
const deleteFolder = useDeleteFolder(itemId);
const deleteRequest = useDeleteRequest(itemId);
const duplicateRequest = useDuplicateRequest({ id: itemId, navigateAfter: true });
const duplicateHttpRequest = useDuplicateHttpRequest({ id: itemId, navigateAfter: true });
const duplicateGrpcRequest = useDuplicateGrpcRequest({ id: itemId, navigateAfter: true });
const sendRequest = useSendRequest(itemId);
const sendAndDownloadRequest = useSendRequest(itemId, { download: true });
const sendManyRequests = useSendManyRequests();
const latestResponse = useLatestResponse(itemId);
const updateRequest = useUpdateRequest(itemId);
const latestHttpResponse = useLatestHttpResponse(itemId);
const latestGrpcConnection = useLatestGrpcConnection(itemId);
const updateHttpRequest = useUpdateHttpRequest(itemId);
const updateGrpcRequest = useUpdateGrpcRequest(itemId);
const updateAnyFolder = useUpdateAnyFolder();
const prompt = usePrompt();
const [editing, setEditing] = useState<boolean>(false);
@@ -541,10 +574,15 @@ const SidebarItem = forwardRef(function SidebarItem(
const handleSubmitNameEdit = useCallback(
(el: HTMLInputElement) => {
updateRequest.mutate((r) => ({ ...r, name: el.value }));
if (activeRequest == null) return;
if (activeRequest.model === 'http_request') {
updateHttpRequest.mutate((r) => ({ ...r, name: el.value }));
} else if (activeRequest.model === 'grpc_request') {
updateGrpcRequest.mutate((r) => ({ ...r, name: el.value }));
}
setEditing(false);
},
[updateRequest],
[activeRequest, updateGrpcRequest, updateHttpRequest],
);
const handleFocus = useCallback((el: HTMLInputElement | null) => {
@@ -570,7 +608,7 @@ const SidebarItem = forwardRef(function SidebarItem(
);
const handleStartEditing = useCallback(() => {
if (itemModel !== 'http_request') return;
if (itemModel !== 'http_request' && itemModel !== 'grpc_request') return;
setEditing(true);
}, [setEditing, itemModel]);
@@ -649,23 +687,29 @@ const SidebarItem = forwardRef(function SidebarItem(
},
]
: [
{
key: 'sendRequest',
label: 'Send',
hotKeyAction: 'request.send',
hotKeyLabelOnly: true, // Already bound in URL bar
leftSlot: <Icon icon="sendHorizontal" />,
onSelect: () => sendRequest.mutate(),
},
{ type: 'separator' },
...((itemModel === 'http_request'
? [
{
key: 'sendRequest',
label: 'Send',
hotKeyAction: 'http_request.send',
hotKeyLabelOnly: true, // Already bound in URL bar
leftSlot: <Icon icon="sendHorizontal" />,
onSelect: () => sendRequest.mutate(),
},
{ type: 'separator' },
]
: []) as DropdownItem[]),
{
key: 'duplicateRequest',
label: 'Duplicate',
hotKeyAction: 'request.duplicate',
hotKeyAction: 'http_request.duplicate',
hotKeyLabelOnly: true, // Would trigger for every request (bad)
leftSlot: <Icon icon="copy" />,
onSelect: () => {
duplicateRequest.mutate();
itemModel === 'http_request'
? duplicateHttpRequest.mutate()
: duplicateGrpcRequest.mutate();
},
},
{
@@ -717,15 +761,21 @@ const SidebarItem = forwardRef(function SidebarItem(
) : (
<span className="truncate">{itemName || itemFallbackName}</span>
)}
{latestResponse && (
{latestGrpcConnection ? (
<div className="ml-auto">
{isResponseLoading(latestResponse) ? (
<Icon spin size="sm" icon="update" />
) : (
<StatusTag className="text-2xs dark:opacity-80" response={latestResponse} />
{latestGrpcConnection.elapsed === 0 && (
<Icon spin size="sm" icon="update" className="text-gray-400" />
)}
</div>
)}
) : latestHttpResponse ? (
<div className="ml-auto">
{isResponseLoading(latestHttpResponse) ? (
<Icon spin size="sm" icon="update" className="text-gray-400" />
) : (
<StatusTag className="text-2xs dark:opacity-80" response={latestHttpResponse} />
)}
</div>
) : null}
</button>
</div>
{children}

View File

@@ -1,6 +1,8 @@
import { memo } from 'react';
import { Simulate } from 'react-dom/test-utils';
import { useCreateFolder } from '../hooks/useCreateFolder';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { useCreateGrpcRequest } from '../hooks/useCreateGrpcRequest';
import { useCreateHttpRequest } from '../hooks/useCreateHttpRequest';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { trackEvent } from '../lib/analytics';
import { Dropdown } from './core/Dropdown';
@@ -8,16 +10,21 @@ import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
export const SidebarActions = memo(function SidebarActions() {
const createRequest = useCreateRequest();
const createHttpRequest = useCreateHttpRequest();
const createGrpcRequest = useCreateGrpcRequest();
const createFolder = useCreateFolder();
const { hidden, toggle } = useSidebarHidden();
const { hidden, show, hide } = useSidebarHidden();
return (
<HStack>
<IconButton
onClick={() => {
onClick={async () => {
trackEvent('Sidebar', 'Toggle');
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"
@@ -28,14 +35,19 @@ export const SidebarActions = memo(function SidebarActions() {
<Dropdown
items={[
{
key: 'create-request',
label: 'New Request',
hotKeyAction: 'request.create',
onSelect: () => createRequest.mutate({}),
key: 'create-http-request',
label: 'HTTP Request',
hotKeyAction: 'http_request.create',
onSelect: () => createHttpRequest.mutate({}),
},
{
key: 'create-grpc-request',
label: 'GRPC Request',
onSelect: () => createGrpcRequest.mutate({}),
},
{
key: 'create-folder',
label: 'New Folder',
label: 'Folder',
onSelect: () => createFolder.mutate({}),
},
]}

View File

@@ -1,43 +1,39 @@
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 type { IconProps } from './core/Icon';
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, 'url'> & {
className?: string;
method: HttpRequest['method'] | null;
placeholder: string;
onSubmit: (e: FormEvent) => void;
onUrlChange: (url: string) => void;
submitIcon?: IconProps['icon'] | null;
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,
submitIcon = 'sendHorizontal',
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 +44,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,31 +56,36 @@ 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
size="xs"
iconSize="md"
title="Send Request"
type="submit"
className="w-8 mr-0.5 my-0.5"
icon={loading ? 'update' : 'sendHorizontal'}
spin={loading}
hotkeyAction="request.send"
/>
submitIcon !== null && (
<IconButton
size="xs"
iconSize="md"
title="Send Request"
type="submit"
className="w-8 mr-0.5 my-0.5"
icon={isLoading ? 'update' : submitIcon}
spin={isLoading}
hotkeyAction="http_request.send"
/>
)
}
/>
</form>

View File

@@ -8,14 +8,17 @@ 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 { HotKeyList } from './core/HotKeyList';
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,7 +34,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);
const [isResizing, setIsResizing] = useState<boolean>(false);
@@ -44,7 +47,7 @@ export default function Workspace() {
const shouldHide = windowSize.width <= WINDOW_FLOATING_SIDEBAR_WIDTH;
if (shouldHide && !floating) {
setFloating(true);
hide();
hide().catch(console.error);
} else if (!shouldHide && floating) {
setFloating(false);
}
@@ -69,10 +72,10 @@ export default function Workspace() {
e.preventDefault(); // Prevent text selection and things
const newWidth = startWidth + (e.clientX - mouseStartX);
if (newWidth < 100) {
hide();
await hide();
resetWidth();
} else {
show();
await show();
setWidth(newWidth);
}
},
@@ -163,7 +166,13 @@ export default function Workspace() {
>
<WorkspaceHeader className="pointer-events-none" />
</HeaderSize>
<RequestResponse style={body} />
{activeRequest == null ? (
<HotKeyList hotkeys={['http_request.create', 'sidebar.toggle', 'settings.show']} />
) : activeRequest.model === 'grpc_request' ? (
<GrpcConnectionLayout style={body} />
) : (
<HttpRequestLayout activeRequest={activeRequest} style={body} />
)}
</div>
);
}

View File

@@ -51,23 +51,13 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
),
render: ({ hide }) => {
return (
<HStack space={2} justifyContent="end" alignItems="center" className="mt-4 mb-6">
<HStack
space={2}
justifyContent="start"
alignItems="center"
className="mt-4 mb-6 flex-row-reverse"
>
<Button
className="focus"
color="gray"
rightSlot={<Icon icon="externalLink" />}
onClick={async () => {
hide();
const environmentId = (await getRecentEnvironments(w.id))[0];
await invoke('new_window', {
url: routes.paths.workspace({ workspaceId: w.id, environmentId }),
});
}}
>
New Window
</Button>
<Button
autoFocus
className="focus"
color="gray"
onClick={async () => {
@@ -78,6 +68,20 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
>
This Window
</Button>
<Button
className="focus"
color="gray"
rightSlot={<Icon icon="externalLink" />}
onClick={async () => {
hide();
const environmentId = (await getRecentEnvironments(w.id))[0];
await invoke('cmd_new_window', {
url: routes.paths.workspace({ workspaceId: w.id, environmentId }),
});
}}
>
New Window
</Button>
</HStack>
);
},

View File

@@ -8,6 +8,7 @@ import { Icon } from './Icon';
export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, 'color'> & {
innerClassName?: string;
color?: 'custom' | 'default' | 'gray' | 'primary' | 'secondary' | 'warning' | 'danger';
variant?: 'border' | 'solid';
isLoading?: boolean;
size?: 'sm' | 'md' | 'xs';
justify?: 'start' | 'center';
@@ -27,10 +28,11 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
innerClassName,
children,
forDropdown,
color,
color = 'default',
type = 'button',
justify = 'center',
size = 'md',
variant = 'solid',
leftSlot,
rightSlot,
disabled,
@@ -53,24 +55,45 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
'flex-shrink-0 flex items-center',
'focus-visible-or-class:ring rounded-md',
disabled ? 'pointer-events-none opacity-disabled' : 'pointer-events-auto',
color === 'custom' && 'ring-blue-500/50',
color === 'default' &&
'text-gray-700 enabled:hocus:bg-gray-700/10 enabled:hocus:text-gray-1000 ring-blue-500/50',
color === 'gray' &&
'text-gray-800 bg-highlight enabled:hocus:bg-gray-500/20 enabled:hocus:text-gray-1000 ring-blue-500/50',
color === 'primary' && 'bg-blue-400 text-white enabled:hocus:bg-blue-500 ring-blue-500/50',
color === 'secondary' &&
'bg-violet-400 text-white enabled:hocus:bg-violet-500 ring-violet-500/50',
color === 'warning' &&
'bg-orange-400 text-white enabled:hocus:bg-orange-500 ring-orange-500/50',
color === 'danger' && 'bg-red-400 text-white enabled:hocus:bg-red-500 ring-red-500/50',
justify === 'start' && 'justify-start',
justify === 'center' && 'justify-center',
size === 'md' && 'h-md px-3',
size === 'sm' && 'h-sm px-2.5 text-sm',
size === 'xs' && 'h-xs px-2 text-sm',
// Solids
variant === 'solid' && color === 'custom' && 'ring-blue-500/50',
variant === 'solid' &&
color === 'default' &&
'text-gray-700 enabled:hocus:bg-gray-700/10 enabled:hocus:text-gray-800 ring-blue-500/50',
variant === 'solid' &&
color === 'gray' &&
'text-gray-800 bg-highlight enabled:hocus:text-gray-1000 ring-gray-400',
variant === 'solid' && color === 'primary' && 'bg-blue-400 text-white ring-blue-700',
variant === 'solid' && color === 'secondary' && 'bg-violet-400 text-white ring-violet-700',
variant === 'solid' && color === 'warning' && 'bg-orange-400 text-white ring-orange-700',
variant === 'solid' && color === 'danger' && 'bg-red-400 text-white ring-red-700',
// Borders
variant === 'border' && 'border',
variant === 'border' &&
color === 'default' &&
'border-highlight text-gray-700 enabled:hocus:border-focus enabled:hocus:text-gray-800 ring-blue-500/50',
variant === 'border' &&
color === 'gray' &&
'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',
variant === 'border' &&
color === 'secondary' &&
'border-violet-500/70 text-violet-700 enabled:hocus:border-violet-500 ring-violet-500/50',
variant === 'border' &&
color === 'warning' &&
'border-orange-500/70 text-orange-700 enabled:hocus:border-orange-500 ring-orange-500/50',
variant === 'border' &&
color === 'danger' &&
'border-red-500/70 text-red-700 enabled:hocus:border-red-500 ring-red-500/50',
),
[className, disabled, color, justify, size],
[className, disabled, justify, size, variant, color],
);
const buttonRef = useRef<HTMLButtonElement>(null);
@@ -100,7 +123,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
) : null}
<div
className={classNames(
'max-w-[15em] truncate w-full',
'truncate w-full',
justify === 'start' ? 'text-left' : 'text-center',
innerClassName,
)}

View File

@@ -12,7 +12,7 @@ export function CountBadge({ count, className }: Props) {
aria-hidden
className={classNames(
className,
'opacity-70 border border-highlight text-3xs rounded mb-0.5 px-1 ml-1 h-4 font-mono',
'opacity-70 border border-highlight text-4xs rounded mb-0.5 px-1 ml-1 h-4 font-mono',
)}
>
{count}

View File

@@ -399,7 +399,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
{items.map((item, i) => {
if (item.type === 'separator') {
return (
<Separator key={i} className="ml-2 my-1.5">
<Separator key={i} className={classNames('my-1.5', item.label && 'ml-2')}>
{item.label}
</Separator>
);
@@ -473,7 +473,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
className={classNames(
className,
'min-w-[8rem] outline-none px-2 mx-1.5 flex text-sm text-gray-700 whitespace-nowrap',
'focus:bg-highlight focus:text-gray-900 rounded',
'focus:bg-highlight focus:text-gray-800 rounded',
item.variant === 'danger' && 'text-red-600',
item.variant === 'notify' && 'text-pink-600',
)}

View File

@@ -38,7 +38,7 @@ export interface EditorProps {
className?: string;
heightMode?: 'auto' | 'full';
contentType?: string | null;
forceUpdateKey?: string;
forceUpdateKey?: string | number;
autoFocus?: boolean;
autoSelect?: boolean;
defaultValue?: string | null;

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,11 +1,11 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
interface Props {
children: string;
children: ReactNode;
}
export function FormattedError({ children }: Props) {
console.log('ERROR', children);
return (
<pre
className={classNames(

View File

@@ -5,33 +5,43 @@ import { memo } from 'react';
const icons = {
archive: lucide.ArchiveIcon,
arrowBigDownDash: lucide.ArrowBigDownDashIcon,
arrowBigUpDash: lucide.ArrowBigUpDashIcon,
arrowDown: lucide.ArrowDownIcon,
arrowDownToDot: lucide.ArrowDownToDotIcon,
arrowUp: lucide.ArrowUpIcon,
arrowUpDown: lucide.ArrowUpDownIcon,
arrowUpFromDot: lucide.ArrowUpFromDotIcon,
box: lucide.BoxIcon,
cake: lucide.CakeIcon,
chat: lucide.MessageSquare,
check: lucide.CheckIcon,
chevronDown: lucide.ChevronDownIcon,
chevronRight: lucide.ChevronRightIcon,
cookie: lucide.CookieIcon,
code: lucide.CodeIcon,
cookie: lucide.CookieIcon,
copy: lucide.CopyIcon,
download: lucide.DownloadIcon,
folderInput: lucide.FolderInputIcon,
folderOutput: lucide.FolderOutputIcon,
externalLink: lucide.ExternalLinkIcon,
eye: lucide.EyeIcon,
eyeClosed: lucide.EyeOffIcon,
filter: lucide.FilterIcon,
flask: lucide.FlaskConicalIcon,
folderInput: lucide.FolderInputIcon,
folderOutput: lucide.FolderOutputIcon,
gripVertical: lucide.GripVerticalIcon,
info: lucide.InfoIcon,
keyboard: lucide.KeyboardIcon,
leftPanelHidden: lucide.PanelLeftOpenIcon,
leftPanelVisible: lucide.PanelLeftCloseIcon,
magicWand: lucide.Wand2Icon,
moreVertical: lucide.MoreVerticalIcon,
pencil: lucide.PencilIcon,
plug: lucide.Plug,
plus: lucide.PlusIcon,
plusCircle: lucide.PlusCircleIcon,
question: lucide.ShieldQuestionIcon,
refresh: lucide.RefreshCwIcon,
sendHorizontal: lucide.SendHorizonalIcon,
settings2: lucide.Settings2Icon,
settings: lucide.SettingsIcon,
@@ -47,7 +57,7 @@ const icons = {
export interface IconProps {
icon: keyof typeof icons;
className?: string;
size?: 'xs' | 'sm' | 'md';
size?: 'xs' | 'sm' | 'md' | 'lg';
spin?: boolean;
}
@@ -57,7 +67,8 @@ export const Icon = memo(function Icon({ icon, spin, size = 'md', className }: I
<Component
className={classNames(
className,
'text-inherit',
'text-inherit flex-shrink-0',
size === 'lg' && 'h-5 w-5',
size === 'md' && 'h-4 w-4',
size === 'sm' && 'h-3.5 w-3.5',
size === 'xs' && 'h-3 w-3',

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-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,122 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { useMemo, useState } from 'react';
import { Icon } from './Icon';
interface Props {
depth?: number;
attrValue: any;
attrKey?: string | number;
attrKeyJsonPath?: string;
}
export const JsonAttributeTree = ({ depth = 0, attrKey, attrValue, attrKeyJsonPath }: Props) => {
attrKeyJsonPath = attrKeyJsonPath ?? `${attrKey}`;
const [isExpanded, setIsExpanded] = useState(true);
const toggleExpanded = () => setIsExpanded((v) => !v);
const { isExpandable, children, label, labelClassName } = useMemo<{
isExpandable: boolean;
children: ReactNode;
label?: string;
labelClassName?: string;
}>(() => {
const jsonType = Object.prototype.toString.call(attrValue);
if (jsonType === '[object Object]') {
return {
children: isExpanded
? Object.keys(attrValue)
.sort((a, b) => a.localeCompare(b))
.flatMap((k) => (
<JsonAttributeTree
depth={depth + 1}
attrValue={attrValue[k]}
attrKey={k}
attrKeyJsonPath={joinObjectKey(attrKeyJsonPath, k)}
/>
))
: null,
isExpandable: true,
label: isExpanded ? '{ }' : `{⋯}`,
labelClassName: 'text-gray-600',
};
} else if (jsonType === '[object Array]') {
return {
children: isExpanded
? attrValue.flatMap((v: any, i: number) => (
<JsonAttributeTree
depth={depth + 1}
attrValue={v}
attrKey={i}
attrKeyJsonPath={joinArrayKey(attrKeyJsonPath, i)}
/>
))
: null,
isExpandable: true,
label: isExpanded ? '[ ]' : `[⋯]`,
labelClassName: 'text-gray-600',
};
} else {
return {
children: null,
isExpandable: false,
label: jsonType === '[object String]' ? `"${attrValue}"` : `${attrValue}`,
labelClassName: classNames(
jsonType === '[object Boolean]' && 'text-pink-600',
jsonType === '[object Number]' && 'text-blue-600',
jsonType === '[object String]' && 'text-yellow-600',
jsonType === '[object Null]' && 'text-red-600',
),
};
}
}, [attrValue, attrKeyJsonPath, isExpanded, depth]);
const labelEl = (
<span className={classNames(labelClassName, 'select-text group-hover:text-gray-800')}>
{label}
</span>
);
return (
<div className={classNames(/*depth === 0 && '-ml-4',*/ 'font-mono text-2xs')}>
<div className="flex items-center">
{isExpandable ? (
<button className="group relative flex items-center pl-4 w-full" onClick={toggleExpanded}>
<Icon
size="xs"
icon="chevronRight"
className={classNames(
'left-0 absolute transition-transform text-gray-600 flex items-center',
'group-hover:text-gray-900',
isExpanded ? 'rotate-90' : '',
)}
/>
<span className="text-violet-600 mr-1.5 whitespace-nowrap">
{attrKey === undefined ? '$' : attrKey}:
</span>
{labelEl}
</button>
) : (
<>
<span className="text-violet-600 mr-1.5 pl-4 whitespace-nowrap select-text">
{attrKey}:
</span>
{labelEl}
</>
)}
</div>
{children && <div className="ml-4 whitespace-nowrap">{children}</div>}
</div>
);
};
function joinObjectKey(baseKey: string | undefined, key: string): string {
const quotedKey = key.match(/^[a-z0-9_]+$/i) ? key : `\`${key}\``;
if (baseKey == null) return quotedKey;
else return `${baseKey}.${quotedKey}`;
}
function joinArrayKey(baseKey: string | undefined, index: number): string {
return `${baseKey ?? ''}[${index}]`;
}

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

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

View File

@@ -1,10 +1,11 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
interface Props {
orientation?: 'horizontal' | 'vertical';
variant?: 'primary' | 'secondary';
className?: string;
children?: string;
children?: ReactNode;
}
export function Separator({

View File

@@ -0,0 +1,169 @@
import useResizeObserver from '@react-hook/resize-observer';
import classNames from 'classnames';
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useLocalStorage } from 'react-use';
import { useActiveRequestId } from '../../hooks/useActiveRequestId';
import { useActiveWorkspaceId } from '../../hooks/useActiveWorkspaceId';
import { clamp } from '../../lib/clamp';
import { ResizeHandle } from '../ResizeHandle';
import { HotKeyList } from './HotKeyList';
interface SlotProps {
orientation: 'horizontal' | 'vertical';
style: CSSProperties;
}
interface Props {
name: string;
firstSlot: (props: SlotProps) => ReactNode;
secondSlot: null | ((props: SlotProps) => ReactNode);
style?: CSSProperties;
className?: string;
defaultRatio?: number;
minHeightPx?: number;
minWidthPx?: number;
forceVertical?: boolean;
}
const areaL = { gridArea: 'left' };
const areaR = { gridArea: 'right' };
const areaD = { gridArea: 'drag' };
const STACK_VERTICAL_WIDTH = 700;
export function SplitLayout({
style,
firstSlot,
secondSlot,
className,
name,
forceVertical,
defaultRatio = 0.5,
minHeightPx = 10,
minWidthPx = 10,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [vertical, setVertical] = useState<boolean>(false);
const [widthRaw, setWidth] = useLocalStorage<number>(`${name}_width::${useActiveWorkspaceId()}`);
const [heightRaw, setHeight] = useLocalStorage<number>(
`${name}_height::${useActiveWorkspaceId()}`,
);
const width = widthRaw ?? defaultRatio;
let height = heightRaw ?? defaultRatio;
const [isResizing, setIsResizing] = useState<boolean>(false);
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
null,
);
if (!secondSlot) {
height = 0;
minHeightPx = 0;
}
useResizeObserver(containerRef.current, ({ contentRect }) => {
setVertical(contentRect.width < STACK_VERTICAL_WIDTH);
});
const styles = useMemo<CSSProperties>(() => {
return {
...style,
gridTemplate:
forceVertical || vertical
? `
' ${areaL.gridArea}' minmax(0,${1 - height}fr)
' ${areaD.gridArea}' 0
' ${areaR.gridArea}' minmax(${minHeightPx}px,${height}fr)
/ 1fr
`
: `
' ${areaL.gridArea} ${areaD.gridArea} ${areaR.gridArea}' minmax(0,1fr)
/ ${1 - width}fr 0 ${width}fr
`,
};
}, [style, vertical, height, minHeightPx, width]);
const unsub = () => {
if (moveState.current !== null) {
document.documentElement.removeEventListener('mousemove', moveState.current.move);
document.documentElement.removeEventListener('mouseup', moveState.current.up);
}
};
const handleReset = useCallback(
() => (vertical ? setHeight(defaultRatio) : setWidth(defaultRatio)),
[vertical, setHeight, defaultRatio, setWidth],
);
const handleResizeStart = useCallback(
(e: ReactMouseEvent<HTMLDivElement>) => {
if (containerRef.current === null) return;
unsub();
const containerRect = containerRef.current.getBoundingClientRect();
const mouseStartX = e.clientX;
const mouseStartY = e.clientY;
const startWidth = containerRect.width * width;
const startHeight = containerRect.height * height;
moveState.current = {
move: (e: MouseEvent) => {
e.preventDefault(); // Prevent text selection and things
if (vertical) {
const maxHeightPx = containerRect.height - minHeightPx;
const newHeightPx = clamp(
startHeight - (e.clientY - mouseStartY),
minHeightPx,
maxHeightPx,
);
setHeight(newHeightPx / containerRect.height);
} else {
const maxWidthPx = containerRect.width - minWidthPx;
const newWidthPx = clamp(
startWidth - (e.clientX - mouseStartX),
minWidthPx,
maxWidthPx,
);
setWidth(newWidthPx / containerRect.width);
}
},
up: (e: MouseEvent) => {
e.preventDefault();
unsub();
setIsResizing(false);
},
};
document.documentElement.addEventListener('mousemove', moveState.current.move);
document.documentElement.addEventListener('mouseup', moveState.current.up);
setIsResizing(true);
},
[width, height, vertical, minHeightPx, setHeight, minWidthPx, setWidth],
);
const activeRequestId = useActiveRequestId();
if (activeRequestId === null) {
return <HotKeyList hotkeys={['http_request.create', 'sidebar.toggle']} />;
}
return (
<div ref={containerRef} className={classNames(className, 'grid w-full h-full')} style={styles}>
{firstSlot({ style: areaL, orientation: vertical ? 'vertical' : 'horizontal' })}
{secondSlot && (
<>
<ResizeHandle
style={areaD}
isResizing={isResizing}
barClassName={'bg-red-300'}
className={classNames(vertical ? 'translate-y-0.5' : 'translate-x-0.5')}
onResizeStart={handleResizeStart}
onReset={handleReset}
side={vertical ? 'top' : 'left'}
justify="center"
/>
{secondSlot({ style: areaR, orientation: vertical ? 'vertical' : 'horizontal' })}
</>
)}
</div>
);
}

View File

@@ -6,6 +6,7 @@ const gapClasses = {
0: 'gap-0',
0.5: 'gap-0.5',
1: 'gap-1',
1.5: 'gap-1.5',
2: 'gap-2',
3: 'gap-3',
4: 'gap-4',
@@ -56,7 +57,7 @@ export const VStack = forwardRef(function VStack(
type BaseStackProps = HTMLAttributes<HTMLElement> & {
as?: ComponentType | 'ul' | 'label' | 'form';
space?: keyof typeof gapClasses;
alignItems?: 'start' | 'center' | 'stretch';
alignItems?: 'start' | 'center' | 'stretch' | 'end';
justifyContent?: 'start' | 'center' | 'end' | 'between';
};
@@ -75,6 +76,7 @@ const BaseStack = forwardRef(function BaseStack(
alignItems === 'center' && 'items-center',
alignItems === 'start' && 'items-start',
alignItems === 'stretch' && 'items-stretch',
alignItems === 'end' && 'items-end',
justifyContent === 'start' && 'justify-start',
justifyContent === 'center' && 'justify-center',
justifyContent === 'end' && 'justify-end',

View File

@@ -0,0 +1,25 @@
import classNames from 'classnames';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import type { HttpResponse } from '../../lib/models';
import { JsonAttributeTree } from '../core/JsonAttributeTree';
interface Props {
response: HttpResponse;
className?: string;
}
export function JsonViewer({ response, className }: Props) {
const rawBody = useResponseBodyText(response) ?? '';
let parsed = {};
try {
parsed = JSON.parse(rawBody);
} catch (e) {
// foo
}
return (
<div className={classNames(className, 'overflow-x-auto h-full')}>
<JsonAttributeTree attrValue={parsed} />
</div>
);
}