Skip to content
63 changes: 32 additions & 31 deletions src/main/event/stream-events.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { ipcMain } from 'electron';
import { createReadStream, ReadStream } from 'node:fs';
import { TrufosRequest } from 'shim/objects/request';
import { TrufosResponse } from 'shim/objects/response';
import { PersistenceService } from 'main/persistence/service/persistence-service';
import { ResponseBodyService } from 'main/network/service/response-body-service';
import { StreamInput, StringBufferEncoding } from 'shim/ipc-stream';

let nextId = 0;

Expand All @@ -12,42 +11,44 @@ const streams = new Map<number, ReadStream>();
const persistenceService = PersistenceService.instance;
const responseBodyService = ResponseBodyService.instance;

type StreamInput = string | TrufosRequest | TrufosResponse;

ipcMain.handle('stream-open', async (event, input: StreamInput) => {
const { sender } = event;
const id = nextId++;

let stream: ReadStream;

if (typeof input === 'string') {
stream = createReadStream(input, 'utf8');
} else if (input.type === 'response') {
if (input.id == null) {
logger.debug('Response has no body, sending empty stream');
ipcMain.handle(
'stream-open',
async (event, input: StreamInput, encoding?: StringBufferEncoding) => {
const { sender } = event;
const id = nextId++;

let stream: ReadStream;

if (typeof input === 'string') {
stream = createReadStream(input, encoding);
} else if (input.type === 'response') {
if (input.id == null) {
logger.debug('Response has no body, sending empty stream');
setImmediate(() => sender.send('stream-end', id));
return id;
}
const filePath = responseBodyService.getFilePath(input.id);
if (filePath == null) {
logger.error(`Response body file path not found for ID: ${input.id}`);
setImmediate(() => sender.send('stream-end', id));
return id;
}
stream = createReadStream(filePath, encoding);
} else if ((stream = await persistenceService.loadTextBodyOfRequest(input, encoding)) == null) {
setImmediate(() => sender.send('stream-end', id));
return id;
}
const filePath = responseBodyService.getFilePath(input.id);
if (filePath == null) {
logger.error(`Response body file path not found for ID: ${input.id}`);
setImmediate(() => sender.send('stream-end', id));
return id;
}
stream = createReadStream(filePath, 'utf8');
} else if ((stream = await persistenceService.loadTextBodyOfRequest(input, 'utf8')) == null) {
setImmediate(() => sender.send('stream-end', id));

streams.set(id, stream);
stream.on('data', (chunk) => sender.send('stream-data', id, chunk));
stream.on('end', () => sender.send('stream-end', id, false));
stream.on('error', (error) => sender.send('stream-error', id, error));
return id;
}

streams.set(id, stream);
stream.on('data', (chunk: string) => sender.send('stream-data', id, chunk));
stream.on('end', () => sender.send('stream-end', id));
stream.on('error', (error) => sender.send('stream-error', id, error));
return id;
});
);

ipcMain.on('stream-close', (event, id: number) => {
streams.get(id)?.close();
streams.delete(id);
event.sender.send('stream-end', id, true);
});
2 changes: 1 addition & 1 deletion src/renderer/components/mainWindow/MainBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { OutputTabs } from '@/components/mainWindow/bodyTabs/OutputTabs';

export function MainBody() {
return (
<div className="grid h-full grid-rows-[48%_48%] gap-6 xl:grid-cols-2 xl:grid-rows-1">
<div className="grid h-full grid-rows-[minmax(0,1fr)_minmax(0,1fr)] gap-6 xl:grid-cols-2 xl:grid-rows-1">
<InputTabs className="min-h-0 min-w-0" />
<OutputTabs className="min-h-0 min-w-0" />
</div>
Expand Down
156 changes: 29 additions & 127 deletions src/renderer/components/mainWindow/bodyTabs/OutputTabs.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useMemo } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Table,
Expand All @@ -8,95 +8,26 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { RESPONSE_EDITOR_OPTIONS } from '@/components/shared/settings/monaco-settings';
import { HttpHeaders } from 'shim/headers';
import { ResponseStatus } from '@/components/mainWindow/responseStatus/ResponseStatus';
import { IpcPushStream } from '@/lib/ipc-stream';
import { selectResponse, useResponseActions, useResponseStore } from '@/state/responseStore';
import { selectResponse, useResponseStore } from '@/state/responseStore';
import { selectRequest, useCollectionStore } from '@/state/collectionStore';
import { Divider } from '@/components/shared/Divider';
import { Button } from '@/components/ui/button';
import { WandSparkles } from 'lucide-react';
import MonacoEditor from '@/lib/monaco/MonacoEditor';
import { cn } from '@/lib/utils';
import { isFormattableLanguage } from '@/lib/monaco/language';
import { RESPONSE_MODEL } from '@/lib/monaco/models';

/**
* Get the mime type from the content type.
* @param contentType The content type to get the mime type from.
*/
function getMimeType(contentType?: string) {
if (contentType !== undefined) {
const index = contentType.indexOf(';');
return (index === -1 ? contentType : contentType.substring(0, index)).trim();
}
}

/**
* Get the content type without any encoding from the headers.
* @param headers The headers to get the content type from.
*/
function getContentType(headers?: HttpHeaders) {
const value = headers?.['content-type'];
if (value !== undefined) {
return Array.isArray(value) ? value[0] : value;
}
}
import { BodyTab } from './OutputTabs/BodyTab';

interface OutputTabsProps {
className: string;
}

export function OutputTabs({ className }: OutputTabsProps) {
const { setResponseEditor, formatResponseEditorText } = useResponseActions();
const editor = useResponseStore((state) => state.editor);
const requestId = useCollectionStore((state) => selectRequest(state)?.id);
const response = useResponseStore((state) => selectResponse(state, requestId));

const [editorLanguage, setEditorLanguage] = useState<string | undefined>();

const mimeType = useMemo(() => {
const contentType = getContentType(response?.headers);
return getMimeType(contentType);
}, [response?.headers]);

useEffect(() => {
const updateEditorContent = async () => {
RESPONSE_MODEL.setValue('');
if (response?.id != null) {
const stream = await IpcPushStream.open(response);
const content = await IpcPushStream.collect(stream);
RESPONSE_MODEL.setValue(content);
if (response?.autoFormat) {
formatResponseEditorText(requestId);
}
}
};

updateEditorContent();
}, [response, requestId]);

useEffect(() => {
if (!editor) return;

setEditorLanguage(RESPONSE_MODEL.getLanguageId());
const disposable = RESPONSE_MODEL.onDidChangeLanguage((e) => {
setEditorLanguage(e.newLanguage);
});

return () => disposable.dispose();
}, [mimeType]);

const canFormatResponseBody = useMemo(() => {
return response?.id && isFormattableLanguage(editorLanguage);
}, [response?.id, editorLanguage]);

const handleFormatResponseBody = useCallback(() => {
if (requestId && canFormatResponseBody) {
formatResponseEditorText(requestId);
}
}, [requestId, canFormatResponseBody, formatResponseEditorText]);
if (response == null) {
return (
<div className="flex h-full w-full items-center justify-center text-center">
<span>Send a request to get a response :)</span>
</div>
);
}

return (
<Tabs className={className} defaultValue="body">
Expand All @@ -108,58 +39,29 @@ export function OutputTabs({ className }: OutputTabsProps) {
<ResponseStatus />
</TabsList>

<TabsContent value="body">
<div className="flex h-full flex-col gap-4 pt-2">
<div className="space-y-2 px-4">
<div className="flex justify-end px-2">
<Button
className={cn('h-6 gap-2', { 'opacity-50': !canFormatResponseBody })}
size="sm"
variant="ghost"
onClick={handleFormatResponseBody}
disabled={!canFormatResponseBody}
>
<WandSparkles size={16} />
Format
</Button>
</div>
<Divider />
</div>

<MonacoEditor
className="absolute h-full"
language={mimeType}
options={RESPONSE_EDITOR_OPTIONS}
onMount={setResponseEditor}
/>
</div>
<TabsContent value="body" className="flex h-full min-h-0 flex-col">
<BodyTab />
</TabsContent>

<TabsContent value="header" className="p-4">
{!response?.headers ? (
<div className="flex h-full w-full items-center justify-center text-center">
<span>Please enter URL address and click Send to get a response</span>
</div>
) : (
<div className="h-0">
<Table className="w-full table-auto">
<TableHeader>
<TableRow>
<TableHead className="w-auto">Key</TableHead>
<TableHead className="w-full">Value</TableHead>
<div className="h-0">
<Table className="w-full table-auto">
<TableHeader>
<TableRow>
<TableHead className="w-auto">Key</TableHead>
<TableHead className="w-full">Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Object.entries(response.headers).map(([key, value]) => (
<TableRow key={key}>
<TableCell className="w-1/3">{key}</TableCell>
<TableCell>{Array.isArray(value) ? value.join(', ') : value}</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{Object.entries(response.headers).map(([key, value]) => (
<TableRow key={key}>
<TableCell className="w-1/3">{key}</TableCell>
<TableCell>{Array.isArray(value) ? value.join(', ') : value}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
))}
</TableBody>
</Table>
</div>
</TabsContent>
</Tabs>
);
Expand Down
71 changes: 71 additions & 0 deletions src/renderer/components/mainWindow/bodyTabs/OutputTabs/BodyTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useState, useMemo } from 'react';
import { Divider } from '@/components/shared/Divider';
import { isFormattableLanguage, mimeTypeToLanguage } from '@/lib/monaco/language';
import { useResponseStore, selectResponse, useResponseActions } from '@/state/responseStore';
import { useCollectionStore, selectRequest } from '@/state/collectionStore';
import { SimpleSelect } from '@/components/mainWindow/bodyTabs/InputTabs/SimpleSelect';
import { getMimeType } from './PrettyRenderer';
import { ImagePrettyRenderer } from './ImagePrettyRenderer';
import { TextualPrettyRenderer } from './TextualPrettyRenderer';
import MonacoEditor from '@/lib/monaco/MonacoEditor';
import { RESPONSE_EDITOR_OPTIONS } from '@/components/shared/settings/monaco-settings';
import { DefaultRenderer } from './DefaultRenderer';
import { useStateDerived } from '@/util/react-util';

enum OutputType {
RAW = 'Raw',
PRETTY = 'Pretty',
}

/**
* Determine if the mime type can be prettified.
* @param mimeType The mime type to check.
* @returns True if the mime type can be prettified, false otherwise.
*/
function canBePrettified(mimeType?: string) {
if (mimeType == null) return false;
return isFormattableLanguage(mimeTypeToLanguage(mimeType)) || mimeType.startsWith('image/');
}

export const BodyTab = () => {
const requestId = useCollectionStore((state) => selectRequest(state)?.id);
const response = useResponseStore((state) => selectResponse(state, requestId));
const [outputType, setOutputType] = useStateDerived(response, (response) =>
canBePrettified(getMimeType(response)) ? OutputType.PRETTY : OutputType.RAW
);
const mimeType = useMemo(() => getMimeType(response), [response]);

const outputTypes = useMemo(() => {
const types: [OutputType, string][] = [[OutputType.RAW, 'Raw']];
if (canBePrettified(mimeType)) types.push([OutputType.PRETTY, 'Pretty']);
return types;
}, [mimeType]);

const renderContent = () => {
if (outputType === OutputType.PRETTY) {
if (mimeType.startsWith('image/')) {
return <ImagePrettyRenderer response={response} />;
} else {
return <TextualPrettyRenderer response={response} />;
}
} else {
return <DefaultRenderer response={response} />;
}
};

return (
<div className="flex min-h-0 flex-1 flex-col gap-4 pt-2">
<div className="space-y-2 px-4">
<div className="flex justify-between px-2">
<SimpleSelect<OutputType>
value={outputType}
onValueChange={setOutputType}
items={outputTypes}
/>
</div>
<Divider />
</div>
<div className="relative flex min-h-0 flex-1 px-4">{renderContent()}</div>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useEffect, useMemo } from 'react';
import {
useResponseData,
useResponseEditor,
ResponseRenderer,
getMimeType,
} from './PrettyRenderer';
import MonacoEditor from '@/lib/monaco/MonacoEditor';
import { RESPONSE_EDITOR_OPTIONS } from '@/components/shared/settings/monaco-settings';
import { RESPONSE_MODEL } from '@/lib/monaco/models';
import { mimeTypeToLanguage } from '@/lib/monaco/language';

export const DefaultRenderer: ResponseRenderer = ({ response }) => {
const { editor, setEditorLanguage, setResponseEditor } = useResponseEditor();
const language = useMemo(() => mimeTypeToLanguage(getMimeType(response)), [response]);
useResponseData(response, 'utf-8', (content) => RESPONSE_MODEL.setValue(content));

useEffect(() => {
if (editor) {
setEditorLanguage(RESPONSE_MODEL.getLanguageId());
const disposable = RESPONSE_MODEL.onDidChangeLanguage((e) =>
setEditorLanguage(e.newLanguage)
);
return () => disposable.dispose();
}
}, [language]);

return (
<MonacoEditor
className="absolute h-full"
language={language}
options={RESPONSE_EDITOR_OPTIONS}
onMount={setResponseEditor}
/>
);
};
Loading