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:
Gregory Schier
2024-06-10 16:36:09 -07:00
committed by GitHub
parent 5bb9815f4b
commit a2dbd7f849
14 changed files with 190 additions and 35 deletions
+23
View File
@@ -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,
+23 -3
View File
@@ -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: (
+9 -7
View File
@@ -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' },
+8 -4
View File
@@ -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
+7 -7
View File
@@ -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}
> >
+4 -2
View File
@@ -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>;
} }
+10 -9
View File
@@ -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>
); );
}; };
+3 -1
View File
@@ -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,
+1 -1
View File
@@ -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>
);
}
+9 -1
View File
@@ -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;
} }
+37
View File
@@ -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>
</>
),
});
},
});
}
+25
View File
@@ -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;
}
+4
View File
@@ -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;
}