Refactor desktop app into separate client and proxy apps

This commit is contained in:
Gregory Schier
2026-03-06 09:23:19 -08:00
parent e26705f016
commit 6915778c06
613 changed files with 1356 additions and 812 deletions

View File

@@ -0,0 +1,27 @@
import { convertFileSrc } from '@tauri-apps/api/core';
import { useEffect, useState } from 'react';
interface Props {
bodyPath?: string;
data?: Uint8Array;
}
export function AudioViewer({ bodyPath, data }: Props) {
const [src, setSrc] = useState<string>();
useEffect(() => {
if (bodyPath) {
setSrc(convertFileSrc(bodyPath));
} else if (data) {
const blob = new Blob([new Uint8Array(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 <audio className="w-full" controls src={src} />;
}

View File

@@ -0,0 +1,39 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import { useSaveResponse } from '../../hooks/useSaveResponse';
import { getContentTypeFromHeaders } from '../../lib/model_util';
import { Banner } from '../core/Banner';
import { Button } from '../core/Button';
import { InlineCode } from '../core/InlineCode';
import { LoadingIcon } from '../core/LoadingIcon';
import { EmptyStateText } from '../EmptyStateText';
interface Props {
response: HttpResponse;
}
export function BinaryViewer({ response }: Props) {
const saveResponse = useSaveResponse(response);
const contentType = getContentTypeFromHeaders(response.headers) ?? 'unknown';
// Wait until the response has been fully-downloaded
if (response.state !== 'closed') {
return (
<EmptyStateText>
<LoadingIcon size="sm" />
</EmptyStateText>
);
}
return (
<Banner color="primary" className="h-full flex flex-col gap-3">
<p>
Content type <InlineCode>{contentType}</InlineCode> cannot be previewed
</p>
<div>
<Button variant="border" size="sm" onClick={() => saveResponse.mutate()}>
Save to File
</Button>
</div>
</Banner>
);
}

View File

@@ -0,0 +1,50 @@
import classNames from 'classnames';
import Papa from 'papaparse';
import { useMemo } from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from '../core/Table';
interface Props {
text: string | null;
className?: string;
}
export function CsvViewer({ text, className }: Props) {
return (
<div className="overflow-auto h-full">
<CsvViewerInner text={text} className={className} />
</div>
);
}
export function CsvViewerInner({ text, className }: { text: string | null; className?: string }) {
const parsed = useMemo(() => {
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')}>
<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
<TableRow key={i}>
{parsed.meta.fields?.map((key) => (
<TableCell key={key}>{row[key] ?? ''}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,157 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import type { ServerSentEvent } from '@yaakapp-internal/sse';
import classNames from 'classnames';
import { Fragment, useMemo, useState } from 'react';
import { useFormatText } from '../../hooks/useFormatText';
import { useResponseBodyEventSource } from '../../hooks/useResponseBodyEventSource';
import { isJSON } from '../../lib/contentType';
import { Button } from '../core/Button';
import type { EditorProps } from '../core/Editor/Editor';
import { Editor } from '../core/Editor/LazyEditor';
import { EventDetailHeader, EventViewer } from '../core/EventViewer';
import { EventViewerRow } from '../core/EventViewerRow';
import { Icon } from '../core/Icon';
import { InlineCode } from '../core/InlineCode';
import { HStack, VStack } from '../core/Stacks';
interface Props {
response: HttpResponse;
}
export function EventStreamViewer({ response }: Props) {
return (
<Fragment
key={response.id} // force a refresh when the response changes
>
<ActualEventStreamViewer response={response} />
</Fragment>
);
}
function ActualEventStreamViewer({ response }: Props) {
const [showLarge, setShowLarge] = useState<boolean>(false);
const [showingLarge, setShowingLarge] = useState<boolean>(false);
const events = useResponseBodyEventSource(response);
return (
<EventViewer
events={events.data ?? []}
getEventKey={(_, index) => String(index)}
error={events.error ? String(events.error) : null}
splitLayoutName="sse_events"
defaultRatio={0.4}
renderRow={({ event, index, isActive, onClick }) => (
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon color="info" title="Server Message" icon="arrow_big_down_dash" />}
content={
<HStack space={2} className="items-center">
<EventLabels event={event} index={index} isActive={isActive} />
<span className="truncate text-xs">{event.data.slice(0, 1000)}</span>
</HStack>
}
/>
)}
renderDetail={({ event, index, onClose }) => (
<EventDetail
event={event}
index={index}
showLarge={showLarge}
showingLarge={showingLarge}
setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge}
onClose={onClose}
/>
)}
/>
);
}
function EventDetail({
event,
index,
showLarge,
showingLarge,
setShowLarge,
setShowingLarge,
onClose,
}: {
event: ServerSentEvent;
index: number;
showLarge: boolean;
showingLarge: boolean;
setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void;
onClose: () => void;
}) {
const language = useMemo<'text' | 'json'>(() => {
if (!event?.data) return 'text';
return isJSON(event?.data) ? 'json' : 'text';
}, [event?.data]);
return (
<div className="flex flex-col h-full">
<EventDetailHeader
title="Message Received"
prefix={<EventLabels event={event} index={index} />}
onClose={onClose}
/>
{!showLarge && event.data.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden
<div>
<Button
onClick={() => {
setShowingLarge(true);
setTimeout(() => {
setShowLarge(true);
setShowingLarge(false);
}, 500);
}}
isLoading={showingLarge}
color="secondary"
variant="border"
size="xs"
>
Try Showing
</Button>
</div>
</VStack>
) : (
<FormattedEditor language={language} text={event.data} />
)}
</div>
);
}
function FormattedEditor({ text, language }: { text: string; language: EditorProps['language'] }) {
const formatted = useFormatText({ text, language, pretty: true });
if (formatted == null) return null;
return <Editor readOnly defaultValue={formatted} language={language} stateKey={null} />;
}
function EventLabels({
className,
event,
index,
isActive,
}: {
event: ServerSentEvent;
index: number;
className?: string;
isActive?: boolean;
}) {
return (
<HStack space={1.5} alignItems="center" className={className}>
<InlineCode className={classNames('py-0', isActive && 'bg-text-subtlest text-text')}>
{event.id ?? index}
</InlineCode>
{event.eventType && (
<InlineCode className={classNames('py-0', isActive && 'bg-text-subtlest text-text')}>
{event.eventType}
</InlineCode>
)}
</HStack>
);
}

View File

@@ -0,0 +1,77 @@
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';
interface Props {
response: HttpResponse;
pretty: boolean;
textViewerClassName?: string;
}
export function HTMLOrTextViewer({ response, pretty, textViewerClassName }: Props) {
const rawTextBody = useResponseBodyText({ response, filter: null });
const contentType = getContentTypeFromHeaders(response.headers);
const language = languageFromContentType(contentType, rawTextBody.data ?? '');
if (rawTextBody.isLoading || response.state === 'initialized') {
return null;
}
if (language === 'html' && pretty) {
return <WebPageViewer html={rawTextBody.data ?? ''} baseUrl={response.url} />;
}
if (rawTextBody.data == null) {
return <EmptyStateText>Empty response</EmptyStateText>;
}
return (
<HttpTextViewer
response={response}
text={rawTextBody.data}
language={language}
pretty={pretty}
className={textViewerClassName}
/>
);
}
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<string | null>(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 (
<TextViewer
text={text}
language={language}
stateKey={`response.body.${response.id}`}
pretty={pretty}
className={className}
onFilter={filterCallback}
/>
);
}

View File

@@ -0,0 +1,39 @@
import { convertFileSrc } from '@tauri-apps/api/core';
import classNames from 'classnames';
import { useEffect, useState } from 'react';
type Props = { className?: string } & (
| {
bodyPath: string;
}
| {
data: ArrayBuffer;
}
);
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')}
/>
);
}

View File

@@ -0,0 +1,22 @@
import classNames from 'classnames';
import { JsonAttributeTree } from '../core/JsonAttributeTree';
interface Props {
text: string;
className?: string;
}
export function JsonViewer({ text, className }: Props) {
let parsed = {};
try {
parsed = JSON.parse(text);
} catch {
// Nothing yet
}
return (
<div className={classNames(className, 'overflow-x-auto h-full')}>
<JsonAttributeTree attrValue={parsed} />
</div>
);
}

View File

@@ -0,0 +1,138 @@
import { type MultipartPart, parseMultipart } from '@mjackson/multipart-parser';
import { lazy, Suspense, useMemo } 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 { 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 {
data: Uint8Array;
boundary: string;
idPrefix?: string;
}
export function MultipartViewer({ data, boundary, idPrefix = 'multipart' }: Props) {
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 { parts, error } = parseResult;
if (error) {
return (
<Banner color="danger" className="m-3">
Failed to parse multipart data: {error}
</Banner>
);
}
if (parts.length === 0) {
return (
<Banner color="info" className="m-3">
No multipart parts found
</Banner>
);
}
return (
<Tabs
addBorders
label="Multipart"
layout="horizontal"
tabListClassName="border-r border-r-border -ml-3"
tabs={parts.map((part, i) => ({
label: part.name ?? '',
value: tabValue(part, i),
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" />
) : null,
}))}
>
{parts.map((part, i) => (
<TabContent
// biome-ignore lint/suspicious/noArrayIndexKey: Nothing else to key on
key={idPrefix + part.name + i}
value={tabValue(part, i)}
className="pl-3 !pt-0"
>
<Part part={part} />
</TabContent>
))}
</Tabs>
);
}
function Part({ part }: { part: MultipartPart }) {
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 <SvgViewer text={content} className="pb-2" />;
}
if (mimeType?.match(/^image/i)) {
return <ImageViewer data={part.arrayBuffer} className="pb-2" />;
}
if (mimeType?.match(/^audio/i)) {
return <AudioViewer data={uint8Array} />;
}
if (mimeType?.match(/^video/i)) {
return <VideoViewer data={uint8Array} />;
}
if (mimeType?.match(/csv|tab-separated/i)) {
return <CsvViewer text={content} className="bg-primary h-10 w-10" />;
}
if (mimeType?.match(/^text\/html/i) || detectedLanguage === 'html') {
return <WebPageViewer html={content} />;
}
if (mimeType?.match(/pdf/i)) {
return (
<Suspense fallback={<LoadingIcon />}>
<PdfViewer data={uint8Array} />
</Suspense>
);
}
return <TextViewer text={content} language={detectedLanguage} stateKey={null} />;
}
function tabValue(part: MultipartPart, i: number) {
return `${part.name ?? ''}::${i}`;
}

View File

@@ -0,0 +1,3 @@
.react-pdf__Document * {
user-select: text;
}

View File

@@ -0,0 +1,72 @@
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 { useEffect, useRef, useState } from 'react';
import { Document, Page } from 'react-pdf';
import { useContainerSize } from '../../hooks/useContainerQuery';
import('react-pdf').then(({ pdfjs }) => {
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
});
interface Props {
bodyPath?: string;
data?: Uint8Array;
}
const options = {
cMapUrl: '/cmaps/',
standardFontDataUrl: '/standard_fonts/',
};
export function PdfViewer({ bodyPath, data }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [numPages, setNumPages] = useState<number>();
const [src, setSrc] = useState<string | { data: Uint8Array }>();
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);
};
return (
<div ref={containerRef} className="w-full h-full overflow-y-auto">
<Document
file={src}
options={options}
onLoadSuccess={onDocumentLoadSuccess}
externalLinkTarget="_blank"
externalLinkRel="noopener noreferrer"
>
{Array.from(new Array(numPages), (_, index) => (
<Page
className="mb-6 select-all"
renderTextLayer
renderAnnotationLayer
key={`page_${index + 1}`}
pageNumber={index + 1}
width={containerWidth}
/>
))}
</Document>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { useEffect, useState } from 'react';
interface Props {
text: string;
className?: string;
}
export function SvgViewer({ text, className }: Props) {
const [src, setSrc] = useState<string | null>(null);
useEffect(() => {
if (!text) {
return setSrc(null);
}
const blob = new Blob([text], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
}, [text]);
if (src == null) {
return null;
}
return (
<img src={src} alt="Response preview" className={className ?? 'max-w-full max-h-full pb-2'} />
);
}

View File

@@ -0,0 +1,151 @@
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 type { EditorProps } from '../core/Editor/Editor';
import { hyperlink } from '../core/Editor/hyperlink/extension';
import { Editor } from '../core/Editor/LazyEditor';
import { IconButton } from '../core/IconButton';
import { Input } from '../core/Input';
const extraExtensions = [hyperlink];
interface Props {
text: string;
language: EditorProps['language'];
stateKey: string | null;
pretty?: boolean;
className?: string;
onFilter?: (filter: string) => {
data: string | null | undefined;
isPending: boolean;
error: boolean;
};
}
const useFilterText = createGlobalState<Record<string, string | null>>({});
export function TextViewer({ language, text, stateKey, pretty, className, onFilter }: Props) {
const [filterTextMap, setFilterTextMap] = useFilterText();
const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null;
const debouncedFilterText = useDebouncedValue(filterText);
const setFilterText = useCallback(
(v: string | null) => {
if (!stateKey) return;
setFilterTextMap((m) => ({ ...m, [stateKey]: v }));
},
[setFilterTextMap, stateKey],
);
const isSearching = filterText != null;
const filteredResponse =
onFilter && debouncedFilterText
? onFilter(debouncedFilterText)
: { data: null, isPending: false, error: false };
const toggleSearch = useCallback(() => {
if (isSearching) {
setFilterText(null);
} else {
setFilterText('');
}
}, [isSearching, setFilterText]);
const canFilter = onFilter && (language === 'json' || language === 'xml' || language === 'html');
const actions = useMemo<ReactNode[]>(() => {
const nodes: ReactNode[] = [];
if (!canFilter) return nodes;
if (isSearching) {
nodes.push(
<div key="input" className="w-full !opacity-100">
<Input
key={stateKey ?? 'filter'}
validate={!filteredResponse.error}
hideLabel
autoFocus
containerClassName="bg-surface"
size="sm"
placeholder={language === 'json' ? 'JSONPath expression' : 'XPath expression'}
label="Filter expression"
name="filter"
defaultValue={filterText}
onKeyDown={(e) => e.key === 'Escape' && toggleSearch()}
onChange={setFilterText}
stateKey={stateKey ? `filter.${stateKey}` : null}
/>
</div>,
);
}
nodes.push(
<IconButton
key="icon"
size="sm"
isLoading={filteredResponse.isPending}
icon={isSearching ? 'x' : 'filter'}
title={isSearching ? 'Close filter' : 'Filter response'}
onClick={toggleSearch}
className={classNames('border !border-border-subtle', isSearching && '!opacity-100')}
/>,
);
return nodes;
}, [
canFilter,
filterText,
filteredResponse.error,
filteredResponse.isPending,
isSearching,
language,
stateKey,
setFilterText,
toggleSearch,
]);
const formattedBody = useFormatText({ text, language, pretty: pretty ?? false });
if (formattedBody == null) {
return null;
}
let body: string;
if (isSearching && filterText?.length > 0) {
if (filteredResponse.error) {
body = '';
} else {
body = filteredResponse.data != null ? filteredResponse.data : '';
}
} else {
body = formattedBody;
}
// Decode unicode sequences in the text to readable characters
if (language === 'json' && pretty) {
body = decodeUnicodeLiterals(body);
body = body.replace(/\\\//g, '/'); // Hide unnecessary escaping of '/' by some older frameworks
}
return (
<Editor
readOnly
className={className}
defaultValue={body}
language={language}
actions={actions}
extraExtensions={extraExtensions}
stateKey={stateKey}
/>
);
}
/** Convert \uXXXX to actual Unicode characters */
function decodeUnicodeLiterals(text: string): string {
return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => {
const charCode = Number.parseInt(hex, 16);
return String.fromCharCode(charCode);
});
}

View File

@@ -0,0 +1,27 @@
import { convertFileSrc } from '@tauri-apps/api/core';
import { useEffect, useState } from 'react';
interface Props {
bodyPath?: string;
data?: Uint8Array;
}
export function VideoViewer({ bodyPath, data }: Props) {
const [src, setSrc] = useState<string>();
useEffect(() => {
if (bodyPath) {
setSrc(convertFileSrc(bodyPath));
} else if (data) {
const blob = new Blob([new Uint8Array(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 <video className="w-full" controls src={src} />;
}

View File

@@ -0,0 +1,28 @@
import { useMemo } from 'react';
interface Props {
html: string;
baseUrl?: string;
}
export function WebPageViewer({ html, baseUrl }: Props) {
const contentForIframe: string | undefined = useMemo(() => {
if (baseUrl && html.includes('<head>')) {
return html.replace(/<head>/gi, `<head><base href="${baseUrl}"/>`);
}
return html;
}, [baseUrl, html]);
return (
<div className="h-full pb-3">
<iframe
key={html ? 'has-body' : 'no-body'}
title="Yaak response preview"
srcDoc={contentForIframe}
sandbox="allow-scripts allow-forms"
referrerPolicy="no-referrer"
className="h-full w-full rounded-lg border border-border-subtle"
/>
</div>
);
}