{stack}
diff --git a/src-web/components/core/FormattedError.tsx b/src-web/components/core/FormattedError.tsx
index 0d95cade..b7889467 100644
--- a/src-web/components/core/FormattedError.tsx
+++ b/src-web/components/core/FormattedError.tsx
@@ -3,12 +3,14 @@ import type { ReactNode } from 'react';
interface Props {
children: ReactNode;
+ className?: string;
}
-export function FormattedError({ children }: Props) {
+export function FormattedError({ children, className }: Props) {
return (
();
+
+ useEffect(() => {
+ if (bodyPath) {
+ setSrc(convertFileSrc(bodyPath));
+ } else if (data) {
+ const blob = new Blob([data], { type: 'audio/mpeg' });
+ const url = URL.createObjectURL(blob);
+ setSrc(url);
+ return () => URL.revokeObjectURL(url);
+ } else {
+ setSrc(undefined);
+ }
+ }, [bodyPath, data]);
// biome-ignore lint/a11y/useMediaCaption: none
return ;
diff --git a/src-web/components/responseViewers/CsvViewer.tsx b/src-web/components/responseViewers/CsvViewer.tsx
index 6664780c..ee115119 100644
--- a/src-web/components/responseViewers/CsvViewer.tsx
+++ b/src-web/components/responseViewers/CsvViewer.tsx
@@ -1,20 +1,17 @@
-import type { HttpResponse } from '@yaakapp-internal/models';
import classNames from 'classnames';
import Papa from 'papaparse';
import { useMemo } from 'react';
-import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from '../core/Table';
interface Props {
- response: HttpResponse;
+ text: string | null;
className?: string;
}
-export function CsvViewer({ response, className }: Props) {
- const body = useResponseBodyText({ response, filter: null });
+export function CsvViewer({ text, className }: Props) {
return (
-
+
);
}
diff --git a/src-web/components/responseViewers/HTMLOrTextViewer.tsx b/src-web/components/responseViewers/HTMLOrTextViewer.tsx
index 90938b81..f1bb5a92 100644
--- a/src-web/components/responseViewers/HTMLOrTextViewer.tsx
+++ b/src-web/components/responseViewers/HTMLOrTextViewer.tsx
@@ -1,7 +1,9 @@
import type { HttpResponse } from '@yaakapp-internal/models';
+import { useMemo, useState } from 'react';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import { languageFromContentType } from '../../lib/contentType';
import { getContentTypeFromHeaders } from '../../lib/model_util';
+import type { EditorProps } from '../core/Editor/Editor';
import { EmptyStateText } from '../EmptyStateText';
import { TextViewer } from './TextViewer';
import { WebPageViewer } from './WebPageViewer';
@@ -22,19 +24,54 @@ export function HTMLOrTextViewer({ response, pretty, textViewerClassName }: Prop
}
if (language === 'html' && pretty) {
- return ;
+ return ;
}
if (rawTextBody.data == null) {
return Empty response;
}
return (
-
+ );
+}
+
+interface HttpTextViewerProps {
+ response: HttpResponse;
+ text: string;
+ language: EditorProps['language'];
+ pretty: boolean;
+ className?: string;
+}
+
+function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {
+ const [currentFilter, setCurrentFilter] = useState(null);
+ const filteredBody = useResponseBodyText({ response, filter: currentFilter });
+
+ const filterCallback = useMemo(
+ () => (filter: string) => {
+ setCurrentFilter(filter);
+ return {
+ data: filteredBody.data,
+ isPending: filteredBody.isPending,
+ error: !!filteredBody.error,
+ };
+ },
+ [filteredBody],
+ );
+
+ return (
+
);
}
diff --git a/src-web/components/responseViewers/JsonViewer.tsx b/src-web/components/responseViewers/JsonViewer.tsx
index 28c5ceaf..603d8b60 100644
--- a/src-web/components/responseViewers/JsonViewer.tsx
+++ b/src-web/components/responseViewers/JsonViewer.tsx
@@ -1,21 +1,15 @@
-import type { HttpResponse } from '@yaakapp-internal/models';
import classNames from 'classnames';
-import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import { JsonAttributeTree } from '../core/JsonAttributeTree';
interface Props {
- response: HttpResponse;
+ text: string;
className?: string;
}
-export function JsonViewer({ response, className }: Props) {
- const rawBody = useResponseBodyText({ response, filter: null });
-
- if (rawBody.isLoading || rawBody.data == null) return null;
-
+export function JsonViewer({ text, className }: Props) {
let parsed = {};
try {
- parsed = JSON.parse(rawBody.data);
+ parsed = JSON.parse(text);
} catch {
// Nothing yet
}
diff --git a/src-web/components/responseViewers/MultipartViewer.tsx b/src-web/components/responseViewers/MultipartViewer.tsx
index 22ae8d4c..c21b2a71 100644
--- a/src-web/components/responseViewers/MultipartViewer.tsx
+++ b/src-web/components/responseViewers/MultipartViewer.tsx
@@ -1,31 +1,57 @@
import { type MultipartPart, parseMultipart } from '@mjackson/multipart-parser';
-import type { HttpResponse } from '@yaakapp-internal/models';
-import { useState } from 'react';
-import { useResponseBodyBytes } from '../../hooks/useResponseBodyText';
-import { getMimeTypeFromContentType, languageFromContentType } from '../../lib/contentType';
-import { getContentTypeFromHeaders } from '../../lib/model_util';
-import { Editor } from '../core/Editor/LazyEditor';
+import { lazy, Suspense, useMemo, useState } from 'react';
+import { languageFromContentType } from '../../lib/contentType';
+import { Banner } from '../core/Banner';
import { Icon } from '../core/Icon';
+import { LoadingIcon } from '../core/LoadingIcon';
import { TabContent, Tabs } from '../core/Tabs/Tabs';
-import { CsvViewerInner } from './CsvViewer';
+import { AudioViewer } from './AudioViewer';
+import { CsvViewer } from './CsvViewer';
import { ImageViewer } from './ImageViewer';
+import { SvgViewer } from './SvgViewer';
+import { TextViewer } from './TextViewer';
+import { VideoViewer } from './VideoViewer';
+import { WebPageViewer } from './WebPageViewer';
+
+const PdfViewer = lazy(() => import('./PdfViewer').then((m) => ({ default: m.PdfViewer })));
interface Props {
- response: HttpResponse;
+ data: Uint8Array;
+ boundary: string;
+ idPrefix?: string;
}
-export function MultipartViewer({ response }: Props) {
- const body = useResponseBodyBytes({ response });
+export function MultipartViewer({ data, boundary, idPrefix = 'multipart' }: Props) {
const [tab, setTab] = useState();
- if (body.data == null) return null;
+ const parseResult = useMemo(() => {
+ try {
+ const maxFileSize = 1024 * 1024 * 10; // 10MB
+ const parsed = parseMultipart(data, { boundary, maxFileSize });
+ const parts = Array.from(parsed);
+ return { parts, error: null };
+ } catch (err) {
+ return { parts: [], error: err instanceof Error ? err.message : String(err) };
+ }
+ }, [data, boundary]);
- const contentTypeHeader = getContentTypeFromHeaders(response.headers);
- const boundary = contentTypeHeader?.split('boundary=')[1] ?? 'unknown';
+ const { parts, error } = parseResult;
- const maxFileSize = 1024 * 1024 * 10; // 10MB
- const parsed = parseMultipart(body.data, { boundary, maxFileSize });
- const parts = Array.from(parsed);
+ if (error) {
+ return (
+
+ Failed to parse multipart data: {error}
+
+ );
+ }
+
+ if (parts.length === 0) {
+ return (
+
+ No multipart parts found
+
+ );
+ }
return (
) : part.filename ? (
-
+
) : null,
}))}
>
{parts.map((part, i) => (
@@ -66,19 +92,47 @@ export function MultipartViewer({ response }: Props) {
}
function Part({ part }: { part: MultipartPart }) {
- const contentType = part.headers.get('content-type');
- const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
+ const mimeType = part.headers.contentType.mediaType ?? null;
+ const contentTypeHeader = part.headers.get('content-type');
+
+ const { uint8Array, content, detectedLanguage } = useMemo(() => {
+ const uint8Array = new Uint8Array(part.arrayBuffer);
+ const content = new TextDecoder().decode(part.arrayBuffer);
+ const detectedLanguage = languageFromContentType(contentTypeHeader, content);
+ return { uint8Array, content, detectedLanguage };
+ }, [part, contentTypeHeader]);
+
+ if (mimeType?.match(/^image\/svg/i)) {
+ return ;
+ }
if (mimeType?.match(/^image/i)) {
return ;
}
- if (mimeType?.match(/csv|tab-separated/i)) {
- const content = new TextDecoder().decode(part.arrayBuffer);
- return ;
+ if (mimeType?.match(/^audio/i)) {
+ return ;
}
- const content = new TextDecoder().decode(part.arrayBuffer);
- const language = languageFromContentType(contentType, content);
- return ;
+ if (mimeType?.match(/^video/i)) {
+ return ;
+ }
+
+ if (mimeType?.match(/csv|tab-separated/i)) {
+ return ;
+ }
+
+ if (mimeType?.match(/^text\/html/i) || detectedLanguage === 'html') {
+ return ;
+ }
+
+ if (mimeType?.match(/pdf/i)) {
+ return (
+ }>
+
+
+ );
+ }
+
+ return ;
}
diff --git a/src-web/components/responseViewers/PdfViewer.tsx b/src-web/components/responseViewers/PdfViewer.tsx
index 1af697b4..d2fd239f 100644
--- a/src-web/components/responseViewers/PdfViewer.tsx
+++ b/src-web/components/responseViewers/PdfViewer.tsx
@@ -3,7 +3,7 @@ import 'react-pdf/dist/Page/AnnotationLayer.css';
import { convertFileSrc } from '@tauri-apps/api/core';
import './PdfViewer.css';
import type { PDFDocumentProxy } from 'pdfjs-dist';
-import { useRef, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { Document, Page } from 'react-pdf';
import { useContainerSize } from '../../hooks/useContainerQuery';
@@ -15,7 +15,8 @@ import('react-pdf').then(({ pdfjs }) => {
});
interface Props {
- bodyPath: string;
+ bodyPath?: string;
+ data?: Uint8Array;
}
const options = {
@@ -23,17 +24,29 @@ const options = {
standardFontDataUrl: '/standard_fonts/',
};
-export function PdfViewer({ bodyPath }: Props) {
+export function PdfViewer({ bodyPath, data }: Props) {
const containerRef = useRef(null);
const [numPages, setNumPages] = useState();
+ const [src, setSrc] = useState();
const { width: containerWidth } = useContainerSize(containerRef);
+ useEffect(() => {
+ if (bodyPath) {
+ setSrc(convertFileSrc(bodyPath));
+ } else if (data) {
+ // Create a copy to avoid "Buffer is already detached" errors
+ // This happens when the ArrayBuffer is transferred/detached elsewhere
+ const dataCopy = new Uint8Array(data);
+ setSrc({ data: dataCopy });
+ } else {
+ setSrc(undefined);
+ }
+ }, [bodyPath, data]);
+
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
setNumPages(nextNumPages);
};
-
- const src = convertFileSrc(bodyPath);
return (
(null);
useEffect(() => {
- if (!rawTextBody.data) {
+ if (!text) {
return setSrc(null);
}
- const blob = new Blob([rawTextBody.data], { type: 'image/svg+xml;charset=utf-8' });
+ const blob = new Blob([text], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
- }, [rawTextBody.data]);
+ }, [text]);
if (src == null) {
return null;
}
- return
;
+ return (
+
+ );
}
diff --git a/src-web/components/responseViewers/TextViewer.tsx b/src-web/components/responseViewers/TextViewer.tsx
index c0095a0e..c03bc7e6 100644
--- a/src-web/components/responseViewers/TextViewer.tsx
+++ b/src-web/components/responseViewers/TextViewer.tsx
@@ -1,11 +1,9 @@
-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 { useDebouncedValue } from '../../hooks/useDebouncedValue';
import { useFormatText } from '../../hooks/useFormatText';
-import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import type { EditorProps } from '../core/Editor/Editor';
import { hyperlink } from '../core/Editor/hyperlink/extension';
import { Editor } from '../core/Editor/LazyEditor';
@@ -15,29 +13,37 @@ import { Input } from '../core/Input';
const extraExtensions = [hyperlink];
interface Props {
- pretty: boolean;
- className?: string;
text: string;
language: EditorProps['language'];
- response: HttpResponse;
- requestId: string;
+ stateKey: string | null;
+ pretty?: boolean;
+ className?: string;
+ onFilter?: (filter: string) => {
+ data: string | null | undefined;
+ isPending: boolean;
+ error: boolean;
+ };
}
const useFilterText = createGlobalState>({});
-export function TextViewer({ language, text, response, requestId, pretty, className }: Props) {
+export function TextViewer({ language, text, stateKey, pretty, className, onFilter }: Props) {
const [filterTextMap, setFilterTextMap] = useFilterText();
- const filterText = filterTextMap[requestId] ?? null;
+ const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null;
const debouncedFilterText = useDebouncedValue(filterText);
const setFilterText = useCallback(
(v: string | null) => {
- setFilterTextMap((m) => ({ ...m, [requestId]: v }));
+ if (!stateKey) return;
+ setFilterTextMap((m) => ({ ...m, [stateKey]: v }));
},
- [setFilterTextMap, requestId],
+ [setFilterTextMap, stateKey],
);
const isSearching = filterText != null;
- const filteredResponse = useResponseBodyText({ response, filter: debouncedFilterText ?? null });
+ const filteredResponse =
+ onFilter && debouncedFilterText
+ ? onFilter(debouncedFilterText)
+ : { data: null, isPending: false, error: false };
const toggleSearch = useCallback(() => {
if (isSearching) {
@@ -47,7 +53,7 @@ export function TextViewer({ language, text, response, requestId, pretty, classN
}
}, [isSearching, setFilterText]);
- const canFilter = language === 'json' || language === 'xml' || language === 'html';
+ const canFilter = onFilter && (language === 'json' || language === 'xml' || language === 'html');
const actions = useMemo(() => {
const nodes: ReactNode[] = [];
@@ -58,7 +64,7 @@ export function TextViewer({ language, text, response, requestId, pretty, classN
nodes.push(
e.key === 'Escape' && toggleSearch()}
onChange={setFilterText}
- stateKey={`filter.${response.id}`}
+ stateKey={stateKey ? `filter.${stateKey}` : null}
/>
,
);
@@ -96,13 +102,12 @@ export function TextViewer({ language, text, response, requestId, pretty, classN
filteredResponse.isPending,
isSearching,
language,
- requestId,
- response,
+ stateKey,
setFilterText,
toggleSearch,
]);
- const formattedBody = useFormatText({ text, language, pretty });
+ const formattedBody = useFormatText({ text, language, pretty: pretty ?? false });
if (formattedBody == null) {
return null;
}
@@ -132,8 +137,7 @@ export function TextViewer({ language, text, response, requestId, pretty, classN
language={language}
actions={actions}
extraExtensions={extraExtensions}
- // State key for storing fold state
- stateKey={`response.body.${response.id}`}
+ stateKey={stateKey}
/>
);
}
diff --git a/src-web/components/responseViewers/VideoViewer.tsx b/src-web/components/responseViewers/VideoViewer.tsx
index 49a14b7a..0bad1975 100644
--- a/src-web/components/responseViewers/VideoViewer.tsx
+++ b/src-web/components/responseViewers/VideoViewer.tsx
@@ -1,11 +1,26 @@
import { convertFileSrc } from '@tauri-apps/api/core';
+import { useEffect, useState } from 'react';
interface Props {
- bodyPath: string;
+ bodyPath?: string;
+ data?: Uint8Array;
}
-export function VideoViewer({ bodyPath }: Props) {
- const src = convertFileSrc(bodyPath);
+export function VideoViewer({ bodyPath, data }: Props) {
+ const [src, setSrc] = useState();
+
+ useEffect(() => {
+ if (bodyPath) {
+ setSrc(convertFileSrc(bodyPath));
+ } else if (data) {
+ const blob = new Blob([data], { type: 'video/mp4' });
+ const url = URL.createObjectURL(blob);
+ setSrc(url);
+ return () => URL.revokeObjectURL(url);
+ } else {
+ setSrc(undefined);
+ }
+ }, [bodyPath, data]);
// biome-ignore lint/a11y/useMediaCaption: none
return ;
diff --git a/src-web/components/responseViewers/WebPageViewer.tsx b/src-web/components/responseViewers/WebPageViewer.tsx
index 5b31395f..502138c2 100644
--- a/src-web/components/responseViewers/WebPageViewer.tsx
+++ b/src-web/components/responseViewers/WebPageViewer.tsx
@@ -1,26 +1,22 @@
-import type { HttpResponse } from '@yaakapp-internal/models';
import { useMemo } from 'react';
-import { useResponseBodyText } from '../../hooks/useResponseBodyText';
interface Props {
- response: HttpResponse;
+ html: string;
+ baseUrl?: string;
}
-export function WebPageViewer({ response }: Props) {
- const { url } = response;
- const body = useResponseBodyText({ response, filter: null }).data ?? '';
-
+export function WebPageViewer({ html, baseUrl }: Props) {
const contentForIframe: string | undefined = useMemo(() => {
- if (body.includes('')) {
- return body.replace(//gi, ``);
+ if (baseUrl && html.includes('')) {
+ return html.replace(//gi, ``);
}
- return body;
- }, [url, body]);
+ return html;
+ }, [baseUrl, html]);
return (