mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-06 12:55:24 +02:00
Download Active Response (#49)
This PR prompts you to save un-previewable file types and adds an option to save to the response history.
This commit is contained in:
@@ -958,6 +958,28 @@ async fn cmd_export_data(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn cmd_save_response(
|
||||||
|
window: WebviewWindow,
|
||||||
|
response_id: &str,
|
||||||
|
filepath: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let response = get_http_response(&window, response_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let body_path = match response.body_path {
|
||||||
|
None => {
|
||||||
|
return Err("Response does not have a body".to_string());
|
||||||
|
}
|
||||||
|
Some(p) => p,
|
||||||
|
};
|
||||||
|
|
||||||
|
fs::copy(body_path, filepath).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn cmd_send_http_request(
|
async fn cmd_send_http_request(
|
||||||
window: WebviewWindow,
|
window: WebviewWindow,
|
||||||
@@ -1702,6 +1724,7 @@ pub fn run() {
|
|||||||
cmd_new_window,
|
cmd_new_window,
|
||||||
cmd_request_to_curl,
|
cmd_request_to_curl,
|
||||||
cmd_dismiss_notification,
|
cmd_dismiss_notification,
|
||||||
|
cmd_save_response,
|
||||||
cmd_send_ephemeral_request,
|
cmd_send_ephemeral_request,
|
||||||
cmd_send_http_request,
|
cmd_send_http_request,
|
||||||
cmd_set_key_value,
|
cmd_set_key_value,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useDeleteHttpResponse } from '../hooks/useDeleteHttpResponse';
|
import { useDeleteHttpResponse } from '../hooks/useDeleteHttpResponse';
|
||||||
import { useDeleteHttpResponses } from '../hooks/useDeleteHttpResponses';
|
import { useDeleteHttpResponses } from '../hooks/useDeleteHttpResponses';
|
||||||
|
import { useSaveResponse } from '../hooks/useSaveResponse';
|
||||||
import type { HttpResponse } from '../lib/models';
|
import type { HttpResponse } from '../lib/models';
|
||||||
import { pluralize } from '../lib/pluralize';
|
import { pluralize } from '../lib/pluralize';
|
||||||
import { Dropdown } from './core/Dropdown';
|
import { Dropdown } from './core/Dropdown';
|
||||||
@@ -25,24 +26,43 @@ export const RecentResponsesDropdown = function ResponsePane({
|
|||||||
const deleteResponse = useDeleteHttpResponse(activeResponse?.id ?? null);
|
const deleteResponse = useDeleteHttpResponse(activeResponse?.id ?? null);
|
||||||
const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId);
|
const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId);
|
||||||
const latestResponseId = responses[0]?.id ?? 'n/a';
|
const latestResponseId = responses[0]?.id ?? 'n/a';
|
||||||
|
const saveResponse = useSaveResponse(activeResponse);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
items={[
|
items={[
|
||||||
|
{
|
||||||
|
key: 'save',
|
||||||
|
label: 'Save to File',
|
||||||
|
onSelect: saveResponse.mutate,
|
||||||
|
leftSlot: <Icon icon="save" />,
|
||||||
|
hidden: responses.length === 0,
|
||||||
|
disabled: responses.length === 0,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'clear-single',
|
key: 'clear-single',
|
||||||
label: 'Clear Response',
|
label: 'Delete',
|
||||||
|
leftSlot: <Icon icon="trash" />,
|
||||||
onSelect: deleteResponse.mutate,
|
onSelect: deleteResponse.mutate,
|
||||||
disabled: responses.length === 0,
|
disabled: responses.length === 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'unpin',
|
||||||
|
label: 'Unpin Response',
|
||||||
|
onSelect: () => onPinnedResponseId(activeResponse.id),
|
||||||
|
leftSlot: <Icon icon="unpin" />,
|
||||||
|
hidden: latestResponseId === activeResponse.id,
|
||||||
|
disabled: responses.length === 0,
|
||||||
|
},
|
||||||
|
{ type: 'separator', label: 'History' },
|
||||||
{
|
{
|
||||||
key: 'clear-all',
|
key: 'clear-all',
|
||||||
label: `Clear ${responses.length} ${pluralize('Response', responses.length)}`,
|
label: `Delete ${responses.length} ${pluralize('Response', responses.length)}`,
|
||||||
onSelect: deleteAllResponses.mutate,
|
onSelect: deleteAllResponses.mutate,
|
||||||
hidden: responses.length <= 1,
|
hidden: responses.length <= 1,
|
||||||
disabled: responses.length === 0,
|
disabled: responses.length === 0,
|
||||||
},
|
},
|
||||||
{ type: 'separator', label: 'History' },
|
{ type: 'separator' },
|
||||||
...responses.slice(0, 20).map((r: HttpResponse) => ({
|
...responses.slice(0, 20).map((r: HttpResponse) => ({
|
||||||
key: r.id,
|
key: r.id,
|
||||||
label: (
|
label: (
|
||||||
|
|||||||
@@ -262,13 +262,15 @@ export const RequestPane = memo(function RequestPane({
|
|||||||
options:
|
options:
|
||||||
requests.length > 0
|
requests.length > 0
|
||||||
? [
|
? [
|
||||||
...requests.map(
|
...requests
|
||||||
(r) =>
|
.filter((r) => r.id !== activeRequestId)
|
||||||
({
|
.map(
|
||||||
type: 'constant',
|
(r) =>
|
||||||
label: r.url,
|
({
|
||||||
} as GenericCompletionOption),
|
type: 'constant',
|
||||||
),
|
label: r.url,
|
||||||
|
} as GenericCompletionOption),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ label: 'http://', type: 'constant' },
|
{ label: 'http://', type: 'constant' },
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createGlobalState } from 'react-use';
|
|||||||
import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders';
|
import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders';
|
||||||
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
|
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
|
||||||
import { useResponseViewMode } from '../hooks/useResponseViewMode';
|
import { useResponseViewMode } from '../hooks/useResponseViewMode';
|
||||||
|
import { isBinaryContentType } from '../lib/data/mimetypes';
|
||||||
import type { HttpRequest } from '../lib/models';
|
import type { HttpRequest } from '../lib/models';
|
||||||
import { isResponseLoading } from '../lib/models';
|
import { isResponseLoading } from '../lib/models';
|
||||||
import { Banner } from './core/Banner';
|
import { Banner } from './core/Banner';
|
||||||
@@ -21,6 +22,7 @@ import { EmptyStateText } from './EmptyStateText';
|
|||||||
import { RecentResponsesDropdown } from './RecentResponsesDropdown';
|
import { RecentResponsesDropdown } from './RecentResponsesDropdown';
|
||||||
import { ResponseHeaders } from './ResponseHeaders';
|
import { ResponseHeaders } from './ResponseHeaders';
|
||||||
import { AudioViewer } from './responseViewers/AudioViewer';
|
import { AudioViewer } from './responseViewers/AudioViewer';
|
||||||
|
import { BinaryViewer } from './responseViewers/BinaryViewer';
|
||||||
import { CsvViewer } from './responseViewers/CsvViewer';
|
import { CsvViewer } from './responseViewers/CsvViewer';
|
||||||
import { ImageViewer } from './responseViewers/ImageViewer';
|
import { ImageViewer } from './responseViewers/ImageViewer';
|
||||||
import { PdfViewer } from './responseViewers/PdfViewer';
|
import { PdfViewer } from './responseViewers/PdfViewer';
|
||||||
@@ -159,14 +161,16 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
|
|||||||
<AudioViewer response={activeResponse} />
|
<AudioViewer response={activeResponse} />
|
||||||
) : contentType?.startsWith('video') ? (
|
) : contentType?.startsWith('video') ? (
|
||||||
<VideoViewer response={activeResponse} />
|
<VideoViewer response={activeResponse} />
|
||||||
|
) : contentType?.match(/pdf/) ? (
|
||||||
|
<PdfViewer response={activeResponse} />
|
||||||
|
) : isBinaryContentType(contentType) ? (
|
||||||
|
<BinaryViewer response={activeResponse} />
|
||||||
|
) : contentType?.match(/csv|tab-separated/) ? (
|
||||||
|
<CsvViewer className="pb-2" response={activeResponse} />
|
||||||
) : activeResponse.contentLength > 2 * 1000 * 1000 ? (
|
) : activeResponse.contentLength > 2 * 1000 * 1000 ? (
|
||||||
<EmptyStateText>Cannot preview text responses larger than 2MB</EmptyStateText>
|
<EmptyStateText>Cannot preview text responses larger than 2MB</EmptyStateText>
|
||||||
) : viewMode === 'pretty' && contentType?.includes('html') ? (
|
) : viewMode === 'pretty' && contentType?.includes('html') ? (
|
||||||
<WebPageViewer response={activeResponse} />
|
<WebPageViewer response={activeResponse} />
|
||||||
) : contentType?.match(/csv|tab-separated/) ? (
|
|
||||||
<CsvViewer className="pb-2" response={activeResponse} />
|
|
||||||
) : contentType?.match(/pdf/) ? (
|
|
||||||
<PdfViewer response={activeResponse} />
|
|
||||||
) : (
|
) : (
|
||||||
<TextViewer
|
<TextViewer
|
||||||
className="-mr-2" // Pull to the right
|
className="-mr-2" // Pull to the right
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { HStack, VStack } from './Stacks';
|
|||||||
export type DropdownItemSeparator = {
|
export type DropdownItemSeparator = {
|
||||||
type: 'separator';
|
type: 'separator';
|
||||||
label?: string;
|
label?: string;
|
||||||
|
hidden?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DropdownItemDefault = {
|
export type DropdownItemDefault = {
|
||||||
@@ -373,7 +374,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
|
|||||||
right: onRight ? docRect.width - triggerShape.right : undefined,
|
right: onRight ? docRect.width - triggerShape.right : undefined,
|
||||||
left: !onRight ? triggerShape.left : undefined,
|
left: !onRight ? triggerShape.left : undefined,
|
||||||
minWidth: fullWidth ? triggerWidth : undefined,
|
minWidth: fullWidth ? triggerWidth : undefined,
|
||||||
maxWidth: '25rem',
|
maxWidth: '40rem',
|
||||||
};
|
};
|
||||||
const size = { top: '-0.2rem', width: '0.4rem', height: '0.4rem' };
|
const size = { top: '-0.2rem', width: '0.4rem', height: '0.4rem' };
|
||||||
const triangleStyles = onRight
|
const triangleStyles = onRight
|
||||||
@@ -456,6 +457,9 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
|
|||||||
<span className="text-fg-subtler text-center px-2 py-1">No matches</span>
|
<span className="text-fg-subtler text-center px-2 py-1">No matches</span>
|
||||||
)}
|
)}
|
||||||
{filteredItems.map((item, i) => {
|
{filteredItems.map((item, i) => {
|
||||||
|
if (item.hidden) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (item.type === 'separator') {
|
if (item.type === 'separator') {
|
||||||
return (
|
return (
|
||||||
<Separator key={i} className={classNames('my-1.5', item.label && 'ml-2')}>
|
<Separator key={i} className={classNames('my-1.5', item.label && 'ml-2')}>
|
||||||
@@ -463,9 +467,6 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
|
|||||||
</Separator>
|
</Separator>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (item.hidden) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
focused={i === selectedIndex}
|
focused={i === selectedIndex}
|
||||||
@@ -538,9 +539,8 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
|
|||||||
'h-xs', // More compact
|
'h-xs', // More compact
|
||||||
'min-w-[8rem] outline-none px-2 mx-1.5 flex whitespace-nowrap',
|
'min-w-[8rem] outline-none px-2 mx-1.5 flex whitespace-nowrap',
|
||||||
'focus:bg-background-highlight focus:text-fg rounded',
|
'focus:bg-background-highlight focus:text-fg rounded',
|
||||||
item.variant === 'default' && 'text-fg-subtle',
|
item.variant === 'danger' && '!text-fg-danger',
|
||||||
item.variant === 'danger' && 'text-fg-danger',
|
item.variant === 'notify' && '!text-fg-primary',
|
||||||
item.variant === 'notify' && 'text-fg-primary',
|
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
import classNames from 'classnames';
|
||||||
import type { HotkeyAction } from '../../hooks/useHotKey';
|
import type { HotkeyAction } from '../../hooks/useHotKey';
|
||||||
import { useHotKeyLabel } from '../../hooks/useHotKey';
|
import { useHotKeyLabel } from '../../hooks/useHotKey';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
action: HotkeyAction;
|
action: HotkeyAction;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HotKeyLabel({ action }: Props) {
|
export function HotKeyLabel({ action, className }: Props) {
|
||||||
const label = useHotKeyLabel(action);
|
const label = useHotKeyLabel(action);
|
||||||
return <span className="text-fg-subtle whitespace-nowrap">{label}</span>;
|
return <span className={classNames(className, 'text-fg-subtle whitespace-nowrap')}>{label}</span>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
|
import classNames from 'classnames';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { HotkeyAction } from '../../hooks/useHotKey';
|
import type { HotkeyAction } from '../../hooks/useHotKey';
|
||||||
import { HotKey } from './HotKey';
|
import { HotKey } from './HotKey';
|
||||||
import { HotKeyLabel } from './HotKeyLabel';
|
import { HotKeyLabel } from './HotKeyLabel';
|
||||||
import { HStack, VStack } from './Stacks';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
hotkeys: HotkeyAction[];
|
hotkeys: HotkeyAction[];
|
||||||
bottomSlot?: React.ReactNode;
|
bottomSlot?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HotKeyList = ({ hotkeys, bottomSlot }: Props) => {
|
export const HotKeyList = ({ hotkeys, bottomSlot, className }: Props) => {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex items-center justify-center">
|
<div className={classNames(className, 'h-full flex items-center justify-center')}>
|
||||||
<VStack space={2}>
|
<div className="px-4 grid gap-2 grid-cols-[auto_auto]">
|
||||||
{hotkeys.map((hotkey) => (
|
{hotkeys.map((hotkey) => (
|
||||||
<HStack key={hotkey} className="grid grid-cols-2">
|
<>
|
||||||
<HotKeyLabel action={hotkey} />
|
<HotKeyLabel className="truncate" action={hotkey} />
|
||||||
<HotKey className="ml-auto" action={hotkey} />
|
<HotKey className="ml-4" action={hotkey} />
|
||||||
</HStack>
|
</>
|
||||||
))}
|
))}
|
||||||
{bottomSlot}
|
{bottomSlot}
|
||||||
</VStack>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,13 +54,15 @@ const icons = {
|
|||||||
plusCircle: lucide.PlusCircleIcon,
|
plusCircle: lucide.PlusCircleIcon,
|
||||||
question: lucide.ShieldQuestionIcon,
|
question: lucide.ShieldQuestionIcon,
|
||||||
refresh: lucide.RefreshCwIcon,
|
refresh: lucide.RefreshCwIcon,
|
||||||
|
save: lucide.SaveIcon,
|
||||||
search: lucide.SearchIcon,
|
search: lucide.SearchIcon,
|
||||||
sendHorizontal: lucide.SendHorizonalIcon,
|
sendHorizontal: lucide.SendHorizonalIcon,
|
||||||
settings2: lucide.Settings2Icon,
|
settings2: lucide.Settings2Icon,
|
||||||
settings: lucide.SettingsIcon,
|
settings: lucide.SettingsIcon,
|
||||||
sparkles: lucide.SparklesIcon,
|
sparkles: lucide.SparklesIcon,
|
||||||
sun: lucide.SunIcon,
|
sun: lucide.SunIcon,
|
||||||
trash: lucide.TrashIcon,
|
trash: lucide.Trash2Icon,
|
||||||
|
unpin: lucide.PinOffIcon,
|
||||||
update: lucide.RefreshCcwIcon,
|
update: lucide.RefreshCcwIcon,
|
||||||
upload: lucide.UploadIcon,
|
upload: lucide.UploadIcon,
|
||||||
x: lucide.XIcon,
|
x: lucide.XIcon,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export function InlineCode({ className, ...props }: HTMLAttributes<HTMLSpanEleme
|
|||||||
<code
|
<code
|
||||||
className={classNames(
|
className={classNames(
|
||||||
className,
|
className,
|
||||||
'font-mono text-shrink bg-background-highlight-secondary border border-background-highlight',
|
'font-mono text-shrink bg-background-highlight-secondary border border-background-highlight-secondary',
|
||||||
'px-1.5 py-0.5 rounded text-fg shadow-inner',
|
'px-1.5 py-0.5 rounded text-fg shadow-inner',
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useSaveResponse } from '../../hooks/useSaveResponse';
|
||||||
|
import type { HttpResponse } from '../../lib/models';
|
||||||
|
import { getContentTypeHeader } from '../../lib/models';
|
||||||
|
import { Banner } from '../core/Banner';
|
||||||
|
import { Button } from '../core/Button';
|
||||||
|
import { InlineCode } from '../core/InlineCode';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
response: HttpResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BinaryViewer({ response }: Props) {
|
||||||
|
const saveResponse = useSaveResponse(response);
|
||||||
|
const contentType = getContentTypeHeader(response.headers) ?? 'unknown';
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { useLatestHttpResponse } from './useLatestHttpResponse';
|
|||||||
|
|
||||||
export function usePinnedHttpResponse(activeRequest: HttpRequest) {
|
export function usePinnedHttpResponse(activeRequest: HttpRequest) {
|
||||||
const latestResponse = useLatestHttpResponse(activeRequest.id);
|
const latestResponse = useLatestHttpResponse(activeRequest.id);
|
||||||
const { set: setPinnedResponseId, value: pinnedResponseId } = useKeyValue<string | null>({
|
const { set, value: pinnedResponseId } = useKeyValue<string | null>({
|
||||||
// Key on latest response instead of activeRequest because responses change out of band of active request
|
// Key on latest response instead of activeRequest because responses change out of band of active request
|
||||||
key: ['pinned_http_response_id', latestResponse?.id ?? 'n/a'],
|
key: ['pinned_http_response_id', latestResponse?.id ?? 'n/a'],
|
||||||
fallback: null,
|
fallback: null,
|
||||||
@@ -15,5 +15,13 @@ export function usePinnedHttpResponse(activeRequest: HttpRequest) {
|
|||||||
const activeResponse: HttpResponse | null =
|
const activeResponse: HttpResponse | null =
|
||||||
responses.find((r) => r.id === pinnedResponseId) ?? latestResponse;
|
responses.find((r) => r.id === pinnedResponseId) ?? latestResponse;
|
||||||
|
|
||||||
|
const setPinnedResponseId = async (id: string) => {
|
||||||
|
if (pinnedResponseId === id) {
|
||||||
|
await set(null);
|
||||||
|
} else {
|
||||||
|
await set(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return { activeResponse, setPinnedResponseId, pinnedResponseId, responses } as const;
|
return { activeResponse, setPinnedResponseId, pinnedResponseId, responses } as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { save } from '@tauri-apps/plugin-dialog';
|
||||||
|
import mime from 'mime';
|
||||||
|
import slugify from 'slugify';
|
||||||
|
import { InlineCode } from '../components/core/InlineCode';
|
||||||
|
import { useToast } from '../components/ToastContext';
|
||||||
|
import type { HttpResponse } from '../lib/models';
|
||||||
|
import { getContentTypeHeader } from '../lib/models';
|
||||||
|
import { getHttpRequest } from '../lib/store';
|
||||||
|
|
||||||
|
export function useSaveResponse(response: HttpResponse) {
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const request = await getHttpRequest(response.requestId);
|
||||||
|
if (request == null) return null;
|
||||||
|
|
||||||
|
const contentType = getContentTypeHeader(response.headers) ?? 'unknown';
|
||||||
|
const ext = mime.getExtension(contentType);
|
||||||
|
const slug = slugify(request.name, { lower: true });
|
||||||
|
const filepath = await save({
|
||||||
|
defaultPath: ext ? `${slug}.${ext}` : slug,
|
||||||
|
title: 'Save Response',
|
||||||
|
});
|
||||||
|
await invoke('cmd_save_response', { responseId: response.id, filepath });
|
||||||
|
toast.show({
|
||||||
|
message: (
|
||||||
|
<>
|
||||||
|
Response saved to <InlineCode>{filepath}</InlineCode>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -206,3 +206,28 @@ export const mimeTypes = [
|
|||||||
'video/x-flv',
|
'video/x-flv',
|
||||||
'video/x-m4v',
|
'video/x-m4v',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export function isBinaryContentType(contentType: string | null) {
|
||||||
|
const mimeType = contentType?.split(';')[0];
|
||||||
|
if (mimeType == null) return false;
|
||||||
|
|
||||||
|
const [first, second] = mimeType.split('/').map((s) => s.trim().toLowerCase());
|
||||||
|
if (first == 'text' || second == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first != 'application') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTextSubtype =
|
||||||
|
second === 'json' ||
|
||||||
|
second === 'ld+json' ||
|
||||||
|
second === 'x-httpd-php' ||
|
||||||
|
second === 'x-sh' ||
|
||||||
|
second === 'x-csh' ||
|
||||||
|
second === 'xhtml+xml' ||
|
||||||
|
second === 'xml';
|
||||||
|
|
||||||
|
return !isTextSubtype;
|
||||||
|
}
|
||||||
|
|||||||
@@ -220,3 +220,7 @@ export function modelsEq(a: Model, b: Model) {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getContentTypeHeader(headers: HttpHeader[]): string | null {
|
||||||
|
return headers.find((h) => h.name.toLowerCase() === 'content-type')?.value ?? null;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user