mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-17 23:13:51 +01:00
Response Streaming (#124)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import type { HttpRequest } from '@yaakapp-internal/models';
|
||||
import type { HttpRequest, HttpResponse } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import React, { memo, useCallback, useMemo } from 'react';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders';
|
||||
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
|
||||
@@ -88,6 +88,8 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
|
||||
[activeResponse?.headers, contentType, setViewMode, viewMode],
|
||||
);
|
||||
|
||||
const isLoading = isResponseLoading(activeResponse);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
@@ -103,10 +105,6 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
|
||||
<HotKeyList
|
||||
hotkeys={['http_request.send', 'http_request.create', 'sidebar.focus', 'urlBar.focus']}
|
||||
/>
|
||||
) : isResponseLoading(activeResponse) ? (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<Icon size="lg" className="opacity-disabled" spin icon="refresh" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
|
||||
<HStack
|
||||
@@ -119,27 +117,21 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
|
||||
{activeResponse && (
|
||||
<HStack
|
||||
space={2}
|
||||
alignItems="center"
|
||||
className={classNames(
|
||||
'cursor-default select-none',
|
||||
'whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm',
|
||||
)}
|
||||
>
|
||||
{isLoading && <Icon size="sm" icon="refresh" spin />}
|
||||
<StatusTag showReason response={activeResponse} />
|
||||
{activeResponse.elapsed > 0 && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<DurationTag
|
||||
headers={activeResponse.elapsedHeaders}
|
||||
total={activeResponse.elapsed}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!!activeResponse.contentLength && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<SizeTag contentLength={activeResponse.contentLength} />
|
||||
</>
|
||||
)}
|
||||
<span>•</span>
|
||||
<DurationTag
|
||||
headers={activeResponse.elapsedHeaders}
|
||||
total={activeResponse.elapsed}
|
||||
/>
|
||||
<span>•</span>
|
||||
<SizeTag contentLength={activeResponse.contentLength ?? 0} />
|
||||
|
||||
<div className="ml-auto">
|
||||
<RecentResponsesDropdown
|
||||
@@ -172,13 +164,13 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
|
||||
<EmptyStateText>Empty Body</EmptyStateText>
|
||||
</div>
|
||||
) : contentType?.startsWith('image') ? (
|
||||
<ImageViewer className="pb-2" response={activeResponse} />
|
||||
<EnsureCompleteResponse response={activeResponse} render={ImageViewer} />
|
||||
) : contentType?.startsWith('audio') ? (
|
||||
<AudioViewer response={activeResponse} />
|
||||
<EnsureCompleteResponse response={activeResponse} render={AudioViewer} />
|
||||
) : contentType?.startsWith('video') ? (
|
||||
<VideoViewer response={activeResponse} />
|
||||
<EnsureCompleteResponse response={activeResponse} render={VideoViewer} />
|
||||
) : contentType?.match(/pdf/) ? (
|
||||
<PdfViewer response={activeResponse} />
|
||||
<EnsureCompleteResponse response={activeResponse} render={PdfViewer} />
|
||||
) : contentType?.match(/csv|tab-separated/) ? (
|
||||
<CsvViewer className="pb-2" response={activeResponse} />
|
||||
) : (
|
||||
@@ -204,3 +196,26 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function EnsureCompleteResponse({
|
||||
response,
|
||||
render,
|
||||
}: {
|
||||
response: HttpResponse;
|
||||
render: (v: { bodyPath: string }) => ReactNode;
|
||||
}) {
|
||||
if (response.bodyPath === null) {
|
||||
return <div>Empty response body</div>;
|
||||
}
|
||||
|
||||
// Wait until the response has been fully-downloaded
|
||||
if (response.state !== 'closed') {
|
||||
return (
|
||||
<EmptyStateText>
|
||||
<Icon icon="refresh" spin />
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
|
||||
return render({ bodyPath: response.bodyPath });
|
||||
}
|
||||
|
||||
@@ -1,30 +1,34 @@
|
||||
import classNames from 'classnames';
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
response: Pick<HttpResponse, 'status' | 'statusReason' | 'error'>;
|
||||
response: HttpResponse;
|
||||
className?: string;
|
||||
showReason?: boolean;
|
||||
}
|
||||
|
||||
export function StatusTag({ response, className, showReason }: Props) {
|
||||
const { status } = response;
|
||||
const label = status < 100 ? 'ERR' : status;
|
||||
const { status, state } = response;
|
||||
const label = status < 100 ? 'ERROR' : status;
|
||||
const category = `${status}`[0];
|
||||
const isInitializing = state === 'initialized';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
className,
|
||||
'font-mono',
|
||||
category === '0' && 'text-danger',
|
||||
category === '1' && 'text-info',
|
||||
category === '2' && 'text-success',
|
||||
category === '3' && 'text-primary',
|
||||
category === '4' && 'text-warning',
|
||||
category === '5' && 'text-danger',
|
||||
!isInitializing && category === '0' && 'text-danger',
|
||||
!isInitializing && category === '1' && 'text-info',
|
||||
!isInitializing && category === '2' && 'text-success',
|
||||
!isInitializing && category === '3' && 'text-primary',
|
||||
!isInitializing && category === '4' && 'text-warning',
|
||||
!isInitializing && category === '5' && 'text-danger',
|
||||
isInitializing && 'text-text-subtle',
|
||||
)}
|
||||
>
|
||||
{label} {showReason && response.statusReason && response.statusReason}
|
||||
{isInitializing ? 'CONNECTING' : label}{' '}
|
||||
{showReason && response.statusReason && response.statusReason}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import React from 'react';
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
bodyPath: string;
|
||||
}
|
||||
|
||||
export function AudioViewer({ response }: Props) {
|
||||
if (response.bodyPath === null) {
|
||||
return <div>Empty response body</div>;
|
||||
}
|
||||
|
||||
const src = convertFileSrc(response.bodyPath);
|
||||
export function AudioViewer({ bodyPath }: Props) {
|
||||
const src = convertFileSrc(bodyPath);
|
||||
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
return <audio className="w-full" controls src={src}></audio>;
|
||||
|
||||
@@ -3,7 +3,9 @@ import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import { getContentTypeHeader } from '../../lib/model_util';
|
||||
import { Banner } from '../core/Banner';
|
||||
import { Button } from '../core/Button';
|
||||
import { Icon } from '../core/Icon';
|
||||
import { InlineCode } from '../core/InlineCode';
|
||||
import { EmptyStateText } from '../EmptyStateText';
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
@@ -12,6 +14,16 @@ interface Props {
|
||||
export function BinaryViewer({ response }: Props) {
|
||||
const saveResponse = useSaveResponse(response);
|
||||
const contentType = getContentTypeHeader(response.headers) ?? 'unknown';
|
||||
|
||||
// Wait until the response has been fully-downloaded
|
||||
if (response.state !== 'closed') {
|
||||
return (
|
||||
<EmptyStateText>
|
||||
<Icon icon="refresh" spin />
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Banner color="primary" className="h-full flex flex-col gap-3">
|
||||
<p>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import { useContentTypeFromHeaders } from '../../hooks/useContentTypeFromHeaders';
|
||||
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
|
||||
import { isJSON, languageFromContentType } from '../../lib/contentType';
|
||||
import { useSaveResponse } from '../../hooks/useSaveResponse';
|
||||
import { languageFromContentType } from '../../lib/contentType';
|
||||
import { BinaryViewer } from './BinaryViewer';
|
||||
import { TextViewer } from './TextViewer';
|
||||
import { WebPageViewer } from './WebPageViewer';
|
||||
@@ -13,25 +14,34 @@ interface Props {
|
||||
}
|
||||
|
||||
export function HTMLOrTextViewer({ response, pretty, textViewerClassName }: Props) {
|
||||
const rawBody = useResponseBodyText(response);
|
||||
let language = languageFromContentType(useContentTypeFromHeaders(response.headers));
|
||||
const rawTextBody = useResponseBodyText(response);
|
||||
const language = languageFromContentType(
|
||||
useContentTypeFromHeaders(response.headers),
|
||||
rawTextBody.data ?? '',
|
||||
);
|
||||
const saveResponse = useSaveResponse(response);
|
||||
|
||||
// A lot of APIs return JSON with `text/html` content type, so interpret as JSON if so
|
||||
if (language === 'html' && isJSON(rawBody.data ?? '')) {
|
||||
language = 'json';
|
||||
}
|
||||
|
||||
if (rawBody.isLoading) {
|
||||
if (rawTextBody.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (rawBody.data == null) {
|
||||
// Wasn't able to decode as text, so it must be binary
|
||||
if (rawTextBody.data == null) {
|
||||
return <BinaryViewer response={response} />;
|
||||
}
|
||||
|
||||
if (language === 'html' && pretty) {
|
||||
return <WebPageViewer response={response} />;
|
||||
} else {
|
||||
return <TextViewer response={response} pretty={pretty} className={textViewerClassName} />;
|
||||
return (
|
||||
<TextViewer
|
||||
language={language}
|
||||
text={rawTextBody.data}
|
||||
pretty={pretty}
|
||||
className={textViewerClassName}
|
||||
onSaveResponse={saveResponse.mutate}
|
||||
responseId={response.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,11 @@
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import classNames from 'classnames';
|
||||
import { useState } from 'react';
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
className?: string;
|
||||
bodyPath: string;
|
||||
}
|
||||
|
||||
export function ImageViewer({ response, className }: Props) {
|
||||
const bytes = response.contentLength ?? 0;
|
||||
const [show, setShow] = useState(bytes < 3 * 1000 * 1000);
|
||||
|
||||
if (response.bodyPath === null) {
|
||||
return <div>Empty response body</div>;
|
||||
}
|
||||
|
||||
const src = convertFileSrc(response.bodyPath);
|
||||
if (!show) {
|
||||
return (
|
||||
<>
|
||||
<div className="italic text-text-subtlest">
|
||||
Response body is too large to preview.{' '}
|
||||
<button className="cursor-pointer underline hover:text" onClick={() => setShow(true)}>
|
||||
Show anyway
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt="Response preview"
|
||||
className={classNames(className, 'max-w-full max-h-full')}
|
||||
/>
|
||||
);
|
||||
export function ImageViewer({ bodyPath }: Props) {
|
||||
const src = convertFileSrc(bodyPath);
|
||||
return <img src={src} alt="Response preview" className="max-w-full max-h-full pb-2" />;
|
||||
}
|
||||
|
||||
@@ -2,15 +2,14 @@ import useResizeObserver from '@react-hook/resize-observer';
|
||||
import 'react-pdf/dist/Page/TextLayer.css';
|
||||
import 'react-pdf/dist/Page/AnnotationLayer.css';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import './PdfViewer.css';
|
||||
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Document, Page } from 'react-pdf';
|
||||
import { useDebouncedState } from '../../hooks/useDebouncedState';
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import './PdfViewer.css';
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
bodyPath: string;
|
||||
}
|
||||
|
||||
const options = {
|
||||
@@ -18,7 +17,7 @@ const options = {
|
||||
standardFontDataUrl: '/standard_fonts/',
|
||||
};
|
||||
|
||||
export function PdfViewer({ response }: Props) {
|
||||
export function PdfViewer({ bodyPath }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerWidth, setContainerWidth] = useDebouncedState<number>(0, 100);
|
||||
const [numPages, setNumPages] = useState<number>();
|
||||
@@ -31,11 +30,7 @@ export function PdfViewer({ response }: Props) {
|
||||
setNumPages(nextNumPages);
|
||||
};
|
||||
|
||||
if (response.bodyPath === null) {
|
||||
return <div>Empty response body</div>;
|
||||
}
|
||||
|
||||
const src = convertFileSrc(response.bodyPath);
|
||||
const src = convertFileSrc(bodyPath);
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full overflow-y-auto">
|
||||
<Document
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { createGlobalState } from 'react-use';
|
||||
import { useContentTypeFromHeaders } from '../../hooks/useContentTypeFromHeaders';
|
||||
import { useCopy } from '../../hooks/useCopy';
|
||||
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 { isJSON, languageFromContentType } from '../../lib/contentType';
|
||||
import { tryFormatJson, tryFormatXml } from '../../lib/formatters';
|
||||
import { CopyButton } from '../CopyButton';
|
||||
import { Banner } from '../core/Banner';
|
||||
import { Button } from '../core/Button';
|
||||
import type { EditorProps } from '../core/Editor';
|
||||
import { Editor } from '../core/Editor';
|
||||
import { hyperlink } from '../core/Editor/hyperlink/extension';
|
||||
import { IconButton } from '../core/IconButton';
|
||||
@@ -21,46 +18,43 @@ import { InlineCode } from '../core/InlineCode';
|
||||
import { Input } from '../core/Input';
|
||||
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;
|
||||
pretty: boolean;
|
||||
className?: string;
|
||||
text: string;
|
||||
language: EditorProps['language'];
|
||||
responseId: string;
|
||||
onSaveResponse: () => void;
|
||||
}
|
||||
|
||||
const useFilterText = createGlobalState<Record<string, string | null>>({});
|
||||
|
||||
export function TextViewer({ response, pretty, className }: Props) {
|
||||
export function TextViewer({
|
||||
language,
|
||||
text,
|
||||
responseId,
|
||||
pretty,
|
||||
className,
|
||||
onSaveResponse,
|
||||
}: Props) {
|
||||
const [filterTextMap, setFilterTextMap] = useFilterText();
|
||||
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
|
||||
const filterText = filterTextMap[response.id] ?? null;
|
||||
const filterText = filterTextMap[responseId] ?? null;
|
||||
const copy = useCopy();
|
||||
const debouncedFilterText = useDebouncedValue(filterText, 200);
|
||||
const setFilterText = useCallback(
|
||||
(v: string | null) => {
|
||||
setFilterTextMap((m) => ({ ...m, [response.id]: v }));
|
||||
setFilterTextMap((m) => ({ ...m, [responseId]: v }));
|
||||
},
|
||||
[setFilterTextMap, response],
|
||||
[setFilterTextMap, responseId],
|
||||
);
|
||||
|
||||
const rawBody = useResponseBodyText(response);
|
||||
const saveResponse = useSaveResponse(response);
|
||||
let language = languageFromContentType(useContentTypeFromHeaders(response.headers));
|
||||
|
||||
// A lot of APIs return JSON with `text/html` content type, so interpret as JSON if so
|
||||
if (language === 'html' && isJSON(rawBody.data ?? '')) {
|
||||
language = 'json';
|
||||
}
|
||||
|
||||
const isSearching = filterText != null;
|
||||
|
||||
const filteredResponse = useFilterResponse({
|
||||
filter: debouncedFilterText ?? '',
|
||||
responseId: response.id,
|
||||
});
|
||||
const filteredResponse = useFilterResponse({ filter: debouncedFilterText ?? '', responseId });
|
||||
|
||||
const toggleSearch = useCallback(() => {
|
||||
if (isSearching) {
|
||||
@@ -81,7 +75,7 @@ export function TextViewer({ response, pretty, className }: Props) {
|
||||
nodes.push(
|
||||
<div key="input" className="w-full !opacity-100">
|
||||
<Input
|
||||
key={response.id}
|
||||
key={responseId}
|
||||
validate={!filteredResponse.error}
|
||||
hideLabel
|
||||
autoFocus
|
||||
@@ -116,20 +110,12 @@ export function TextViewer({ response, pretty, className }: Props) {
|
||||
filteredResponse.error,
|
||||
isSearching,
|
||||
language,
|
||||
response.id,
|
||||
responseId,
|
||||
setFilterText,
|
||||
toggleSearch,
|
||||
]);
|
||||
|
||||
if (rawBody.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (rawBody.data == null) {
|
||||
return <BinaryViewer response={response} />;
|
||||
}
|
||||
|
||||
if (!showLargeResponse && (response.contentLength ?? 0) > LARGE_RESPONSE_BYTES) {
|
||||
if (!showLargeResponse && text.length > LARGE_RESPONSE_BYTES) {
|
||||
return (
|
||||
<Banner color="primary" className="h-full flex flex-col gap-3">
|
||||
<p>
|
||||
@@ -143,15 +129,10 @@ export function TextViewer({ response, pretty, className }: Props) {
|
||||
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
|
||||
Reveal Response
|
||||
</Button>
|
||||
<Button variant="border" size="xs" onClick={() => saveResponse.mutate()}>
|
||||
<Button variant="border" size="xs" onClick={onSaveResponse}>
|
||||
Save to File
|
||||
</Button>
|
||||
<CopyButton
|
||||
variant="border"
|
||||
size="xs"
|
||||
onClick={() => saveResponse.mutate()}
|
||||
text={rawBody.data}
|
||||
/>
|
||||
<CopyButton variant="border" size="xs" onClick={() => copy(text)} text={text} />
|
||||
</HStack>
|
||||
</Banner>
|
||||
);
|
||||
@@ -159,10 +140,10 @@ export function TextViewer({ response, pretty, className }: Props) {
|
||||
|
||||
const formattedBody =
|
||||
pretty && language === 'json'
|
||||
? tryFormatJson(rawBody.data)
|
||||
? tryFormatJson(text)
|
||||
: pretty && (language === 'xml' || language === 'html')
|
||||
? tryFormatXml(rawBody.data)
|
||||
: rawBody.data;
|
||||
? tryFormatXml(text)
|
||||
: text;
|
||||
|
||||
let body;
|
||||
if (isSearching && filterText?.length > 0) {
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import React from 'react';
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
bodyPath: string;
|
||||
}
|
||||
|
||||
export function VideoViewer({ response }: Props) {
|
||||
if (response.bodyPath === null) {
|
||||
return <div>Empty response body</div>;
|
||||
}
|
||||
|
||||
const src = convertFileSrc(response.bodyPath);
|
||||
export function VideoViewer({ bodyPath }: Props) {
|
||||
const src = convertFileSrc(bodyPath);
|
||||
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
return <video className="w-full" controls src={src}></video>;
|
||||
|
||||
@@ -4,7 +4,8 @@ import { getResponseBodyText } from '../lib/responseBody';
|
||||
|
||||
export function useResponseBodyText(response: HttpResponse) {
|
||||
return useQuery<string | null>({
|
||||
queryKey: ['response-body-text', response.id, response?.updatedAt],
|
||||
placeholderData: (prev) => prev, // Keep previous data on refetch
|
||||
queryKey: ['response-body-text', response.id, response.updatedAt, response.contentLength],
|
||||
queryFn: () => getResponseBodyText(response),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
import type { EditorProps } from '../components/core/Editor';
|
||||
|
||||
export function languageFromContentType(contentType: string | null): EditorProps['language'] {
|
||||
export function languageFromContentType(
|
||||
contentType: string | null,
|
||||
content: string | null = null,
|
||||
): EditorProps['language'] {
|
||||
const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
|
||||
if (justContentType.includes('json')) {
|
||||
return 'json';
|
||||
} else if (justContentType.includes('xml')) {
|
||||
return 'xml';
|
||||
} else if (justContentType.includes('html')) {
|
||||
return 'html';
|
||||
return detectFromContent(content);
|
||||
} else if (justContentType.includes('javascript')) {
|
||||
return 'javascript';
|
||||
} else {
|
||||
return 'text';
|
||||
}
|
||||
|
||||
return detectFromContent(content);
|
||||
}
|
||||
|
||||
export function isJSON(text: string): boolean {
|
||||
try {
|
||||
JSON.parse(text);
|
||||
return true;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
return text.startsWith('{') || text.startsWith('[');
|
||||
}
|
||||
|
||||
function detectFromContent(content: string | null): EditorProps['language'] {
|
||||
if (content == null) return 'text';
|
||||
|
||||
if (content.startsWith('{') || content.startsWith('[')) {
|
||||
return 'json';
|
||||
} else if (content.startsWith('<!DOCTYPE') || content.startsWith('<html')) {
|
||||
return 'html';
|
||||
}
|
||||
return 'text';
|
||||
}
|
||||
|
||||
@@ -32,8 +32,11 @@ export function cookieDomain(cookie: Cookie): string {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function isResponseLoading(response: HttpResponse | GrpcConnection): boolean {
|
||||
return response.elapsed === 0;
|
||||
export function isResponseLoading(
|
||||
response: Pick<HttpResponse | GrpcConnection, 'state'> | null,
|
||||
): boolean {
|
||||
if (response == null) return false;
|
||||
return response.state !== 'closed';
|
||||
}
|
||||
|
||||
export function modelsEq(a: AnyModel, b: AnyModel) {
|
||||
|
||||
Reference in New Issue
Block a user