mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-19 16:21:13 +01:00
gRPC Support (#20)
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
119
src-web/components/GrpcConnectionLayout.tsx
Normal file
119
src-web/components/GrpcConnectionLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
127
src-web/components/GrpcConnectionMessagesPane.tsx
Normal file
127
src-web/components/GrpcConnectionMessagesPane.tsx
Normal 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>
|
||||
))
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
235
src-web/components/GrpcConnectionSetupPane.tsx
Normal file
235
src-web/components/GrpcConnectionSetupPane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
src-web/components/GrpcEditor.tsx
Normal file
153
src-web/components/GrpcEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
src-web/components/GrpcProtoSelection.tsx
Normal file
148
src-web/components/GrpcProtoSelection.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
29
src-web/components/HttpRequestLayout.tsx
Normal file
29
src-web/components/HttpRequestLayout.tsx
Normal 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} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
60
src-web/components/RecentConnectionsDropdown.tsx
Normal file
60
src-web/components/RecentConnectionsDropdown.tsx
Normal 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 •{' '}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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({}),
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
122
src-web/components/core/JsonAttributeTree.tsx
Normal file
122
src-web/components/core/JsonAttributeTree.tsx
Normal 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}]`;
|
||||
}
|
||||
35
src-web/components/core/Link.tsx
Normal file
35
src-web/components/core/Link.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
169
src-web/components/core/SplitLayout.tsx
Normal file
169
src-web/components/core/SplitLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
25
src-web/components/responseViewers/JsonViewer.tsx
Normal file
25
src-web/components/responseViewers/JsonViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user