mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-01-11 22:40:26 +01:00
Merge branch 'multipart-viewer'
This commit is contained in:
@@ -34,6 +34,7 @@ import { CsvViewer } from './responseViewers/CsvViewer';
|
||||
import { EventStreamViewer } from './responseViewers/EventStreamViewer';
|
||||
import { HTMLOrTextViewer } from './responseViewers/HTMLOrTextViewer';
|
||||
import { ImageViewer } from './responseViewers/ImageViewer';
|
||||
import { MultipartViewer } from './responseViewers/MultipartViewer';
|
||||
import { SvgViewer } from './responseViewers/SvgViewer';
|
||||
import { VideoViewer } from './responseViewers/VideoViewer';
|
||||
|
||||
@@ -222,6 +223,8 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
<EnsureCompleteResponse response={activeResponse} Component={AudioViewer} />
|
||||
) : mimeType?.match(/^video/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} Component={VideoViewer} />
|
||||
) : mimeType?.match(/^multipart/i) ? (
|
||||
<MultipartViewer response={activeResponse} />
|
||||
) : mimeType?.match(/pdf/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} Component={PdfViewer} />
|
||||
) : mimeType?.match(/csv|tab-separated/i) ? (
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
FileCodeIcon,
|
||||
FileIcon,
|
||||
FileTextIcon,
|
||||
FilterIcon,
|
||||
FlameIcon,
|
||||
@@ -187,7 +188,9 @@ const icons = {
|
||||
external_link: ExternalLinkIcon,
|
||||
eye: EyeIcon,
|
||||
eye_closed: EyeOffIcon,
|
||||
file: FileIcon,
|
||||
file_code: FileCodeIcon,
|
||||
file_text: FileTextIcon,
|
||||
filter: FilterIcon,
|
||||
flame: FlameIcon,
|
||||
flask: FlaskConicalIcon,
|
||||
|
||||
@@ -3,6 +3,7 @@ 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;
|
||||
@@ -11,31 +12,42 @@ interface Props {
|
||||
|
||||
export function CsvViewer({ response, className }: Props) {
|
||||
const body = useResponseBodyText({ response, filter: null });
|
||||
return (
|
||||
<div className="overflow-auto h-full">
|
||||
<CsvViewerInner text={body.data ?? null} className={className} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CsvViewerInner({ text, className }: { text: string | null; className?: string }) {
|
||||
const parsed = useMemo(() => {
|
||||
if (body.data == null) return null;
|
||||
return Papa.parse<string[]>(body.data);
|
||||
}, [body]);
|
||||
if (text == null) return null;
|
||||
return Papa.parse<Record<string, string>>(text, { header: true, skipEmptyLines: true });
|
||||
}, [text]);
|
||||
|
||||
if (parsed === null) return null;
|
||||
|
||||
return (
|
||||
<div className="overflow-auto h-full">
|
||||
<table className={classNames(className, 'text-sm')}>
|
||||
<tbody>
|
||||
<Table className={classNames(className, 'text-sm')}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{parsed.meta.fields?.map((field) => (
|
||||
<TableHeaderCell key={field}>{field}</TableHeaderCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{parsed.data.map((row, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: none
|
||||
<tr key={i} className={classNames('border-l border-t', i > 0 && 'border-b')}>
|
||||
{row.map((col, j) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: none
|
||||
<td key={j} className="border-r px-1.5">
|
||||
{col}
|
||||
</td>
|
||||
<TableRow key={i}>
|
||||
{parsed.meta.fields?.map((key) => (
|
||||
<TableCell key={key}>{row[key] ?? ''}</TableCell>
|
||||
))}
|
||||
</tr>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,39 @@
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import classNames from 'classnames';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
bodyPath: string;
|
||||
}
|
||||
type Props = { className?: string } & (
|
||||
| {
|
||||
bodyPath: string;
|
||||
}
|
||||
| {
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
);
|
||||
|
||||
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" />;
|
||||
export function ImageViewer({ className, ...props }: Props) {
|
||||
const [src, setSrc] = useState<string>();
|
||||
const bodyPath = 'bodyPath' in props ? props.bodyPath : null;
|
||||
const data = 'data' in props ? props.data : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (bodyPath != null) {
|
||||
setSrc(convertFileSrc(bodyPath));
|
||||
} else if (data != null) {
|
||||
const blob = new Blob([data], { type: 'image/png' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setSrc(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
} else {
|
||||
setSrc(undefined);
|
||||
}
|
||||
}, [bodyPath, data]);
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt="Response preview"
|
||||
className={classNames(className, 'max-w-full max-h-full')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
83
src-web/components/responseViewers/MultipartViewer.tsx
Normal file
83
src-web/components/responseViewers/MultipartViewer.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
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 { Icon } from '../core/Icon';
|
||||
import { TabContent, Tabs } from '../core/Tabs/Tabs';
|
||||
import { CsvViewerInner } from './CsvViewer';
|
||||
import { ImageViewer } from './ImageViewer';
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
}
|
||||
|
||||
export function MultipartViewer({ response }: Props) {
|
||||
const body = useResponseBodyBytes({ response });
|
||||
const [tab, setTab] = useState<string>();
|
||||
|
||||
if (body.data == null) return null;
|
||||
|
||||
const contentTypeHeader = getContentTypeFromHeaders(response.headers);
|
||||
const boundary = contentTypeHeader?.split('boundary=')[1] ?? 'unknown';
|
||||
|
||||
const parsed = parseMultipart(body.data, { boundary });
|
||||
const parts = Array.from(parsed);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={tab}
|
||||
addBorders
|
||||
label="Multipart"
|
||||
layout="horizontal"
|
||||
tabListClassName="border-r border-r-border"
|
||||
onChangeValue={setTab}
|
||||
tabs={parts.map((part) => ({
|
||||
label: part.name ?? '',
|
||||
value: part.name ?? '',
|
||||
rightSlot:
|
||||
part.filename && part.headers.contentType.mediaType?.startsWith('image/') ? (
|
||||
<div className="h-5 w-5 overflow-auto flex items-center justify-end">
|
||||
<ImageViewer
|
||||
data={part.arrayBuffer}
|
||||
className="ml-auto w-auto rounded overflow-hidden"
|
||||
/>
|
||||
</div>
|
||||
) : part.filename ? (
|
||||
<Icon icon="file_text" />
|
||||
) : null,
|
||||
}))}
|
||||
>
|
||||
{parts.map((part, i) => (
|
||||
<TabContent
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: Nothing else to key on
|
||||
key={response.id + part.name + i}
|
||||
value={part.name ?? ''}
|
||||
className="pl-3 !pt-0"
|
||||
>
|
||||
<Part part={part} />
|
||||
</TabContent>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
function Part({ part }: { part: MultipartPart }) {
|
||||
const contentType = part.headers.get('content-type');
|
||||
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
|
||||
|
||||
if (mimeType?.match(/^image/i)) {
|
||||
return <ImageViewer data={part.arrayBuffer} className="pb-2" />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/csv|tab-separated/i)) {
|
||||
const content = new TextDecoder().decode(part.arrayBuffer);
|
||||
return <CsvViewerInner text={content} />;
|
||||
}
|
||||
|
||||
const content = new TextDecoder().decode(part.arrayBuffer);
|
||||
const language = languageFromContentType(contentType, content);
|
||||
return <Editor readOnly defaultValue={content} language={language} stateKey={null} />;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import { getResponseBodyText } from '../lib/responseBody';
|
||||
import { getResponseBodyBytes, getResponseBodyText } from '../lib/responseBody';
|
||||
|
||||
export function useResponseBodyText({
|
||||
response,
|
||||
@@ -21,3 +21,11 @@ export function useResponseBodyText({
|
||||
queryFn: () => getResponseBodyText({ response, filter }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useResponseBodyBytes({ response }: { response: HttpResponse }) {
|
||||
return useQuery({
|
||||
placeholderData: (prev) => prev, // Keep previous data on refetch
|
||||
queryKey: ['response_body_bytes', response.id, response.updatedAt, response.contentLength],
|
||||
queryFn: () => getResponseBodyBytes(response),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readFile } from '@tauri-apps/plugin-fs';
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import type { FilterResponse } from '@yaakapp-internal/plugins';
|
||||
import type { ServerSentEvent } from '@yaakapp-internal/sse';
|
||||
@@ -30,3 +31,10 @@ export async function getResponseBodyEventSource(
|
||||
filePath: response.bodyPath,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getResponseBodyBytes(
|
||||
response: HttpResponse,
|
||||
): Promise<Uint8Array<ArrayBuffer> | null> {
|
||||
if (!response.bodyPath) return null;
|
||||
return readFile(response.bodyPath);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@gilbarbara/deep-equal": "^0.3.1",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.3",
|
||||
"@mjackson/multipart-parser": "^0.10.1",
|
||||
"@prantlf/jsonlint": "^16.0.0",
|
||||
"@replit/codemirror-emacs": "^6.1.0",
|
||||
"@replit/codemirror-vim": "^6.3.0",
|
||||
|
||||
Reference in New Issue
Block a user