From 38e0882dd16d84f725f0321ba0136b6e8e3e83bd Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 23 Jul 2024 08:59:15 -0700 Subject: [PATCH] Better handling of large responses --- src-web/components/CopyButton.tsx | 24 +++++++++++ .../components/responseViewers/TextViewer.tsx | 40 +++++++++++++++++-- src-web/hooks/useClipboardText.ts | 6 +-- src-web/hooks/useSaveResponse.tsx | 2 +- src-web/hooks/useTimedBoolean.ts | 2 +- 5 files changed, 66 insertions(+), 8 deletions(-) create mode 100644 src-web/components/CopyButton.tsx diff --git a/src-web/components/CopyButton.tsx b/src-web/components/CopyButton.tsx new file mode 100644 index 00000000..049df735 --- /dev/null +++ b/src-web/components/CopyButton.tsx @@ -0,0 +1,24 @@ +import { useClipboardText } from '../hooks/useClipboardText'; +import { useTimedBoolean } from '../hooks/useTimedBoolean'; +import type { ButtonProps } from './core/Button'; +import { Button } from './core/Button'; + +interface Props extends ButtonProps { + text: string; +} + +export function CopyButton({ text, ...props }: Props) { + const [, copy] = useClipboardText({ disableToast: true }); + const [copied, setCopied] = useTimedBoolean(); + return ( + + ); +} diff --git a/src-web/components/responseViewers/TextViewer.tsx b/src-web/components/responseViewers/TextViewer.tsx index 830c882c..1e7c354f 100644 --- a/src-web/components/responseViewers/TextViewer.tsx +++ b/src-web/components/responseViewers/TextViewer.tsx @@ -6,16 +6,24 @@ import { useContentTypeFromHeaders } from '../../hooks/useContentTypeFromHeaders import { useDebouncedValue } from '../../hooks/useDebouncedValue'; import { useFilterResponse } from '../../hooks/useFilterResponse'; import { useResponseBodyText } from '../../hooks/useResponseBodyText'; +import { useSaveResponse } from '../../hooks/useSaveResponse'; +import { useToggle } from '../../hooks/useToggle'; import { tryFormatJson, tryFormatXml } from '../../lib/formatters'; import type { HttpResponse } from '../../lib/models'; +import { CopyButton } from '../CopyButton'; +import { Banner } from '../core/Banner'; +import { Button } from '../core/Button'; import { Editor } from '../core/Editor'; import { hyperlink } from '../core/Editor/hyperlink/extension'; import { IconButton } from '../core/IconButton'; +import { InlineCode } from '../core/InlineCode'; import { Input } from '../core/Input'; -import { EmptyStateText } from '../EmptyStateText'; +import { SizeTag } from '../core/SizeTag'; +import { HStack } from '../core/Stacks'; import { BinaryViewer } from './BinaryViewer'; const extraExtensions = [hyperlink]; +const LARGE_RESPONSE_BYTES = 2 * 1000 * 1000; interface Props { response: HttpResponse; @@ -27,6 +35,7 @@ const useFilterText = createGlobalState>({}); export function TextViewer({ response, pretty, className }: Props) { const [filterTextMap, setFilterTextMap] = useFilterText(); + const [showLargeResponse, toggleShowLargeResponse] = useToggle(); const filterText = filterTextMap[response.id] ?? null; const debouncedFilterText = useDebouncedValue(filterText, 200); const setFilterText = useCallback( @@ -36,6 +45,7 @@ export function TextViewer({ response, pretty, className }: Props) { [setFilterTextMap, response], ); + const saveResponse = useSaveResponse(response); const contentType = useContentTypeFromHeaders(response.headers); const rawBody = useResponseBodyText(response); const isSearching = filterText != null; @@ -117,8 +127,32 @@ export function TextViewer({ response, pretty, className }: Props) { return ; } - if ((response.contentLength ?? 0) > 2 * 1000 * 1000) { - return Cannot preview text responses larger than 2MB; + if (!showLargeResponse && (response.contentLength ?? 0) > LARGE_RESPONSE_BYTES / 1000) { + return ( + +

+ Showing responses over{' '} + + + {' '} + may impact performance +

+ + + + saveResponse.mutate()} + text={rawBody.data} + /> + +
+ ); } const formattedBody = diff --git a/src-web/hooks/useClipboardText.ts b/src-web/hooks/useClipboardText.ts index 50122a20..a00e3e28 100644 --- a/src-web/hooks/useClipboardText.ts +++ b/src-web/hooks/useClipboardText.ts @@ -6,7 +6,7 @@ import { createGlobalState } from 'react-use'; const useClipboardTextState = createGlobalState(''); -export function useClipboardText() { +export function useClipboardText({ disableToast }: { disableToast?: boolean } = {}) { const [value, setValue] = useClipboardTextState(); const focused = useWindowFocus(); const toast = useToast(); @@ -18,7 +18,7 @@ export function useClipboardText() { const setText = useCallback( (text: string) => { writeText(text).catch(console.error); - if (text != '') { + if (text != '' && !disableToast) { toast.show({ id: 'copied', variant: 'copied', @@ -27,7 +27,7 @@ export function useClipboardText() { } setValue(text); }, - [setValue, toast], + [disableToast, setValue, toast], ); return [value, setText] as const; diff --git a/src-web/hooks/useSaveResponse.tsx b/src-web/hooks/useSaveResponse.tsx index caa9e50f..8cc8a4cf 100644 --- a/src-web/hooks/useSaveResponse.tsx +++ b/src-web/hooks/useSaveResponse.tsx @@ -20,7 +20,7 @@ export function useSaveResponse(response: HttpResponse) { const contentType = getContentTypeHeader(response.headers) ?? 'unknown'; const ext = mime.getExtension(contentType); - const slug = slugify(request.name ?? 'response', { lower: true }); + const slug = slugify(request.name || 'response', { lower: true }); const filepath = await save({ defaultPath: ext ? `${slug}.${ext}` : slug, title: 'Save Response', diff --git a/src-web/hooks/useTimedBoolean.ts b/src-web/hooks/useTimedBoolean.ts index 2a52477f..fc26f2c4 100644 --- a/src-web/hooks/useTimedBoolean.ts +++ b/src-web/hooks/useTimedBoolean.ts @@ -2,7 +2,7 @@ import { useRef, useState } from 'react'; import { useUnmount } from 'react-use'; /** Returns a boolean that is true for a given number of milliseconds. */ -export function useTimedBoolean(millis = 1000): [boolean, () => void] { +export function useTimedBoolean(millis = 1500): [boolean, () => void] { const [value, setValue] = useState(false); const timeout = useRef(null); const reset = () => timeout.current && clearTimeout(timeout.current);