Support binary responses!

This commit is contained in:
Gregory Schier
2023-04-13 18:48:40 -07:00
parent f8c5960156
commit c941cb6989
25 changed files with 455 additions and 231 deletions

View File

@@ -1,9 +0,0 @@
interface Props {
data: string;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function ImageView({ data }: Props) {
// const dataUri = `data:image/png;base64,${window.btoa(data)}`;
return <div>Image preview not supported until binary response support is added</div>;
}

View File

@@ -5,26 +5,26 @@ import { createGlobalState } from 'react-use';
import { useActiveRequestId } from '../hooks/useActiveRequestId';
import { useDeleteResponse } from '../hooks/useDeleteResponse';
import { useDeleteResponses } from '../hooks/useDeleteResponses';
import { useResponseContentType } from '../hooks/useResponseContentType';
import { useResponses } from '../hooks/useResponses';
import { useResponseViewMode } from '../hooks/useResponseViewMode';
import { tryFormatJson } from '../lib/formatters';
import type { HttpResponse } from '../lib/models';
import { isResponseLoading } from '../lib/models';
import { pluralize } from '../lib/pluralize';
import { Banner } from './core/Banner';
import { CountBadge } from './core/CountBadge';
import { Dropdown } from './core/Dropdown';
import { Editor } from './core/Editor';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
import { StatusTag } from './core/StatusTag';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { Webview } from './core/Webview';
import { EmptyStateText } from './EmptyStateText';
import { ImageView } from './ImageView';
import { ResponseHeaders } from './ResponseHeaders';
import { ImageViewer } from './responseViewers/ImageViewer';
import { TextViewer } from './responseViewers/TextViewer';
import { WebPageViewer } from './responseViewers/WebPageViewer';
interface Props {
style?: CSSProperties;
@@ -48,12 +48,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
// Unset pinned response when a new one comes in
useEffect(() => setPinnedResponseId(null), [responses.length]);
const contentType = useMemo(
() =>
activeResponse?.headers.find((h) => h.name.toLowerCase() === 'content-type')?.value ??
'text/plain',
[activeResponse],
);
const contentType = useResponseContentType(activeResponse);
const tabs: TabItem[] = useMemo(
() => [
@@ -84,9 +79,6 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
[activeResponse?.headers, setViewMode, viewMode],
);
// Don't render until we know the view mode
if (viewMode === undefined) return null;
return (
<div
style={style}
@@ -119,10 +111,10 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
<span>{activeResponse.elapsed}ms</span>
</>
)}
{activeResponse.body.length > 0 && (
{activeResponse.contentLength && (
<>
<span>&bull;</span>
<span>{(activeResponse.body.length / 1000).toFixed(1)} KB</span>
<span>{(activeResponse.contentLength / 1000).toFixed(1)} KB</span>
</>
)}
</HStack>
@@ -169,49 +161,29 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
)}
</HStack>
{
<Tabs
value={activeTab}
onChangeValue={setActiveTab}
label="Response"
tabs={tabs}
className="ml-3 mr-1"
tabListClassName="mt-1.5"
>
<TabContent value="headers">
<ResponseHeaders headers={activeResponse?.headers ?? []} />
</TabContent>
<TabContent value="body">
{!activeResponse.body ? (
<EmptyStateText>Empty Body</EmptyStateText>
) : viewMode === 'pretty' && contentType.includes('html') ? (
<Webview
body={activeResponse.body}
contentType={contentType}
url={activeResponse.url}
/>
) : viewMode === 'pretty' && contentType.includes('json') ? (
<Editor
readOnly
forceUpdateKey={`pretty::${activeResponse.updatedAt}`}
className="bg-gray-50 dark:!bg-gray-100"
defaultValue={tryFormatJson(activeResponse?.body)}
contentType={contentType}
/>
) : contentType.startsWith('image') ? (
<ImageView data={activeResponse?.body} />
) : activeResponse?.body ? (
<Editor
readOnly
forceUpdateKey={activeResponse.updatedAt}
className="bg-gray-50 dark:!bg-gray-100"
defaultValue={activeResponse?.body}
contentType={contentType}
/>
) : null}
</TabContent>
</Tabs>
}
<Tabs
value={activeTab}
onChangeValue={setActiveTab}
label="Response"
tabs={tabs}
className="ml-3 mr-1"
tabListClassName="mt-1.5"
>
<TabContent value="headers">
<ResponseHeaders headers={activeResponse?.headers ?? []} />
</TabContent>
<TabContent value="body">
{!activeResponse.contentLength ? (
<EmptyStateText>Empty Body</EmptyStateText>
) : viewMode === 'pretty' && contentType?.includes('html') ? (
<WebPageViewer response={activeResponse} />
) : contentType?.startsWith('image') ? (
<ImageViewer response={activeResponse} />
) : (
<TextViewer response={activeResponse} pretty={viewMode === 'pretty'} />
)}
</TabContent>
</Tabs>
</>
)}
</div>

View File

@@ -1,4 +1,5 @@
import { useRouteError } from 'react-router-dom';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { Button } from './core/Button';
import { Heading } from './core/Heading';
import { VStack } from './core/Stacks';
@@ -8,6 +9,7 @@ export default function RouteError() {
const stringified = JSON.stringify(error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const message = (error as any).message ?? stringified;
const routes = useAppRoutes();
return (
<div className="flex items-center justify-center h-full">
<VStack space={5} className="max-w-[30rem] !h-auto">
@@ -16,7 +18,12 @@ export default function RouteError() {
{message}
</pre>
<VStack space={2}>
<Button to="/" color="primary">
<Button
color="primary"
onClick={() => {
routes.navigate('workspaces');
}}
>
Go Home
</Button>
<Button color="secondary" onClick={() => window.location.reload()}>

View File

@@ -67,8 +67,8 @@ export const Sidebar = memo(function Sidebar({ className }: Props) {
const handleFocus = useCallback(() => {
if (hasFocus) return;
focusActiveRequest(selectedIndex ?? 0);
}, [focusActiveRequest, hasFocus, selectedIndex]);
focusActiveRequest();
}, [focusActiveRequest, hasFocus]);
const handleBlur = useCallback(() => setHasFocus(false), []);

View File

@@ -75,7 +75,6 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
className="w-8 mr-0.5"
icon={loading ? 'update' : 'paperPlane'}
spin={loading}
disabled={loading}
/>
}
/>

View File

@@ -137,11 +137,11 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceDropdown({ classN
];
}, [
workspaces,
activeWorkspace?.name,
deleteWorkspace.mutate,
activeWorkspaceId,
dialog,
routes,
prompt,
activeWorkspace?.name,
updateWorkspace,
createWorkspace,
]);

View File

@@ -24,10 +24,10 @@ export interface EditorProps {
type?: 'text' | 'password';
className?: string;
heightMode?: 'auto' | 'full';
contentType?: string;
contentType?: string | null;
forceUpdateKey?: string;
autoFocus?: boolean;
defaultValue?: string;
defaultValue?: string | null;
placeholder?: string;
tooltipContainer?: HTMLElement;
useTemplating?: boolean;

View File

@@ -32,7 +32,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButt
) {
const [confirmed, setConfirmed] = useTimedBoolean();
const handleClick = useCallback(
(e: MouseEvent<HTMLElement>) => {
(e: MouseEvent<HTMLButtonElement>) => {
if (showConfirm) setConfirmed();
onClick?.(e);
},

View File

@@ -0,0 +1,15 @@
import { convertFileSrc } from '@tauri-apps/api/tauri';
import type { HttpResponse } from '../../lib/models';
interface Props {
response: HttpResponse;
}
export function ImageViewer({ response }: Props) {
if (response.bodyPath === null) {
return <div>Empty response body</div>;
}
const src = convertFileSrc(response.bodyPath);
return <img src={src} alt="Response preview" />;
}

View File

@@ -0,0 +1,26 @@
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import { useResponseContentType } from '../../hooks/useResponseContentType';
import { tryFormatJson } from '../../lib/formatters';
import type { HttpResponse } from '../../lib/models';
import { Editor } from '../core/Editor';
interface Props {
response: HttpResponse;
pretty: boolean;
}
export function TextViewer({ response, pretty }: Props) {
const contentType = useResponseContentType(response);
const rawBody = useResponseBodyText(response) ?? '';
const body = pretty && contentType?.includes('json') ? tryFormatJson(rawBody) : rawBody;
return (
<Editor
readOnly
forceUpdateKey={body}
className="bg-gray-50 dark:!bg-gray-100"
defaultValue={body}
contentType={contentType}
/>
);
}

View File

@@ -1,19 +1,21 @@
import { useMemo } from 'react';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import type { HttpResponse } from '../../lib/models';
interface Props {
body: string;
contentType: string;
url: string;
response: HttpResponse;
}
export function Webview({ body, url, contentType }: Props) {
export function WebPageViewer({ response }: Props) {
const { url } = response;
const body = useResponseBodyText(response) ?? '';
const contentForIframe: string | undefined = useMemo(() => {
if (!contentType.includes('html')) return;
if (body.includes('<head>')) {
return body.replace(/<head>/gi, `<head><base href="${url}"/>`);
}
return body;
}, [url, body, contentType]);
}, [url, body]);
return (
<div className="h-full pb-3">