From a2dbd7f849779ab58be9134053a68e4f7996ccf6 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Mon, 10 Jun 2024 16:36:09 -0700 Subject: [PATCH] Download Active Response (#49) This PR prompts you to save un-previewable file types and adds an option to save to the response history. --- src-tauri/src/lib.rs | 23 ++++++++++++ .../components/RecentResponsesDropdown.tsx | 26 +++++++++++-- src-web/components/RequestPane.tsx | 16 ++++---- src-web/components/ResponsePane.tsx | 12 ++++-- src-web/components/core/Dropdown.tsx | 14 +++---- src-web/components/core/HotKeyLabel.tsx | 6 ++- src-web/components/core/HotKeyList.tsx | 19 +++++----- src-web/components/core/Icon.tsx | 4 +- src-web/components/core/InlineCode.tsx | 2 +- .../responseViewers/BinaryViewer.tsx | 27 ++++++++++++++ src-web/hooks/usePinnedHttpResponse.ts | 10 ++++- src-web/hooks/useSaveResponse.tsx | 37 +++++++++++++++++++ src-web/lib/data/mimetypes.ts | 25 +++++++++++++ src-web/lib/models.ts | 4 ++ 14 files changed, 190 insertions(+), 35 deletions(-) create mode 100644 src-web/components/responseViewers/BinaryViewer.tsx create mode 100644 src-web/hooks/useSaveResponse.tsx diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 42c691b9..9787074b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -958,6 +958,28 @@ async fn cmd_export_data( 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] async fn cmd_send_http_request( window: WebviewWindow, @@ -1702,6 +1724,7 @@ pub fn run() { cmd_new_window, cmd_request_to_curl, cmd_dismiss_notification, + cmd_save_response, cmd_send_ephemeral_request, cmd_send_http_request, cmd_set_key_value, diff --git a/src-web/components/RecentResponsesDropdown.tsx b/src-web/components/RecentResponsesDropdown.tsx index 175d3dc5..fc63049a 100644 --- a/src-web/components/RecentResponsesDropdown.tsx +++ b/src-web/components/RecentResponsesDropdown.tsx @@ -1,6 +1,7 @@ import classNames from 'classnames'; import { useDeleteHttpResponse } from '../hooks/useDeleteHttpResponse'; import { useDeleteHttpResponses } from '../hooks/useDeleteHttpResponses'; +import { useSaveResponse } from '../hooks/useSaveResponse'; import type { HttpResponse } from '../lib/models'; import { pluralize } from '../lib/pluralize'; import { Dropdown } from './core/Dropdown'; @@ -25,24 +26,43 @@ export const RecentResponsesDropdown = function ResponsePane({ const deleteResponse = useDeleteHttpResponse(activeResponse?.id ?? null); const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId); const latestResponseId = responses[0]?.id ?? 'n/a'; + const saveResponse = useSaveResponse(activeResponse); return ( , + hidden: responses.length === 0, + disabled: responses.length === 0, + }, { key: 'clear-single', - label: 'Clear Response', + label: 'Delete', + leftSlot: , onSelect: deleteResponse.mutate, disabled: responses.length === 0, }, + { + key: 'unpin', + label: 'Unpin Response', + onSelect: () => onPinnedResponseId(activeResponse.id), + leftSlot: , + hidden: latestResponseId === activeResponse.id, + disabled: responses.length === 0, + }, + { type: 'separator', label: 'History' }, { key: 'clear-all', - label: `Clear ${responses.length} ${pluralize('Response', responses.length)}`, + label: `Delete ${responses.length} ${pluralize('Response', responses.length)}`, onSelect: deleteAllResponses.mutate, hidden: responses.length <= 1, disabled: responses.length === 0, }, - { type: 'separator', label: 'History' }, + { type: 'separator' }, ...responses.slice(0, 20).map((r: HttpResponse) => ({ key: r.id, label: ( diff --git a/src-web/components/RequestPane.tsx b/src-web/components/RequestPane.tsx index ea57214d..1fe8d6c0 100644 --- a/src-web/components/RequestPane.tsx +++ b/src-web/components/RequestPane.tsx @@ -262,13 +262,15 @@ export const RequestPane = memo(function RequestPane({ options: requests.length > 0 ? [ - ...requests.map( - (r) => - ({ - type: 'constant', - label: r.url, - } as GenericCompletionOption), - ), + ...requests + .filter((r) => r.id !== activeRequestId) + .map( + (r) => + ({ + type: 'constant', + label: r.url, + } as GenericCompletionOption), + ), ] : [ { label: 'http://', type: 'constant' }, diff --git a/src-web/components/ResponsePane.tsx b/src-web/components/ResponsePane.tsx index ca5741c9..3df746ff 100644 --- a/src-web/components/ResponsePane.tsx +++ b/src-web/components/ResponsePane.tsx @@ -5,6 +5,7 @@ import { createGlobalState } from 'react-use'; import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders'; import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse'; import { useResponseViewMode } from '../hooks/useResponseViewMode'; +import { isBinaryContentType } from '../lib/data/mimetypes'; import type { HttpRequest } from '../lib/models'; import { isResponseLoading } from '../lib/models'; import { Banner } from './core/Banner'; @@ -21,6 +22,7 @@ import { EmptyStateText } from './EmptyStateText'; import { RecentResponsesDropdown } from './RecentResponsesDropdown'; import { ResponseHeaders } from './ResponseHeaders'; import { AudioViewer } from './responseViewers/AudioViewer'; +import { BinaryViewer } from './responseViewers/BinaryViewer'; import { CsvViewer } from './responseViewers/CsvViewer'; import { ImageViewer } from './responseViewers/ImageViewer'; import { PdfViewer } from './responseViewers/PdfViewer'; @@ -159,14 +161,16 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ ) : contentType?.startsWith('video') ? ( + ) : contentType?.match(/pdf/) ? ( + + ) : isBinaryContentType(contentType) ? ( + + ) : contentType?.match(/csv|tab-separated/) ? ( + ) : activeResponse.contentLength > 2 * 1000 * 1000 ? ( Cannot preview text responses larger than 2MB ) : viewMode === 'pretty' && contentType?.includes('html') ? ( - ) : contentType?.match(/csv|tab-separated/) ? ( - - ) : contentType?.match(/pdf/) ? ( - ) : ( , MenuPro right: onRight ? docRect.width - triggerShape.right : undefined, left: !onRight ? triggerShape.left : undefined, minWidth: fullWidth ? triggerWidth : undefined, - maxWidth: '25rem', + maxWidth: '40rem', }; const size = { top: '-0.2rem', width: '0.4rem', height: '0.4rem' }; const triangleStyles = onRight @@ -456,6 +457,9 @@ const Menu = forwardRef, MenuPro No matches )} {filteredItems.map((item, i) => { + if (item.hidden) { + return null; + } if (item.type === 'separator') { return ( @@ -463,9 +467,6 @@ const Menu = forwardRef, MenuPro ); } - if (item.hidden) { - return null; - } return ( diff --git a/src-web/components/core/HotKeyLabel.tsx b/src-web/components/core/HotKeyLabel.tsx index 8ea0ba86..c01faeb3 100644 --- a/src-web/components/core/HotKeyLabel.tsx +++ b/src-web/components/core/HotKeyLabel.tsx @@ -1,11 +1,13 @@ +import classNames from 'classnames'; import type { HotkeyAction } from '../../hooks/useHotKey'; import { useHotKeyLabel } from '../../hooks/useHotKey'; interface Props { action: HotkeyAction; + className?: string; } -export function HotKeyLabel({ action }: Props) { +export function HotKeyLabel({ action, className }: Props) { const label = useHotKeyLabel(action); - return {label}; + return {label}; } diff --git a/src-web/components/core/HotKeyList.tsx b/src-web/components/core/HotKeyList.tsx index 8f4c142b..4e099489 100644 --- a/src-web/components/core/HotKeyList.tsx +++ b/src-web/components/core/HotKeyList.tsx @@ -1,26 +1,27 @@ +import classNames from 'classnames'; import React from 'react'; import type { HotkeyAction } from '../../hooks/useHotKey'; import { HotKey } from './HotKey'; import { HotKeyLabel } from './HotKeyLabel'; -import { HStack, VStack } from './Stacks'; interface Props { hotkeys: HotkeyAction[]; bottomSlot?: React.ReactNode; + className?: string; } -export const HotKeyList = ({ hotkeys, bottomSlot }: Props) => { +export const HotKeyList = ({ hotkeys, bottomSlot, className }: Props) => { return ( -
- +
+
{hotkeys.map((hotkey) => ( - - - - + <> + + + ))} {bottomSlot} - +
); }; diff --git a/src-web/components/core/Icon.tsx b/src-web/components/core/Icon.tsx index 6cef20bf..5f2270fd 100644 --- a/src-web/components/core/Icon.tsx +++ b/src-web/components/core/Icon.tsx @@ -54,13 +54,15 @@ const icons = { plusCircle: lucide.PlusCircleIcon, question: lucide.ShieldQuestionIcon, refresh: lucide.RefreshCwIcon, + save: lucide.SaveIcon, search: lucide.SearchIcon, sendHorizontal: lucide.SendHorizonalIcon, settings2: lucide.Settings2Icon, settings: lucide.SettingsIcon, sparkles: lucide.SparklesIcon, sun: lucide.SunIcon, - trash: lucide.TrashIcon, + trash: lucide.Trash2Icon, + unpin: lucide.PinOffIcon, update: lucide.RefreshCcwIcon, upload: lucide.UploadIcon, x: lucide.XIcon, diff --git a/src-web/components/core/InlineCode.tsx b/src-web/components/core/InlineCode.tsx index 1890f7c9..3bf0897c 100644 --- a/src-web/components/core/InlineCode.tsx +++ b/src-web/components/core/InlineCode.tsx @@ -6,7 +6,7 @@ export function InlineCode({ className, ...props }: HTMLAttributes +

+ Content type {contentType} cannot be previewed +

+
+ +
+ + ); +} diff --git a/src-web/hooks/usePinnedHttpResponse.ts b/src-web/hooks/usePinnedHttpResponse.ts index 2d2e74fb..56116549 100644 --- a/src-web/hooks/usePinnedHttpResponse.ts +++ b/src-web/hooks/usePinnedHttpResponse.ts @@ -5,7 +5,7 @@ import { useLatestHttpResponse } from './useLatestHttpResponse'; export function usePinnedHttpResponse(activeRequest: HttpRequest) { const latestResponse = useLatestHttpResponse(activeRequest.id); - const { set: setPinnedResponseId, value: pinnedResponseId } = useKeyValue({ + const { set, value: pinnedResponseId } = useKeyValue({ // 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'], fallback: null, @@ -15,5 +15,13 @@ export function usePinnedHttpResponse(activeRequest: HttpRequest) { const activeResponse: HttpResponse | null = 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; } diff --git a/src-web/hooks/useSaveResponse.tsx b/src-web/hooks/useSaveResponse.tsx new file mode 100644 index 00000000..7b659385 --- /dev/null +++ b/src-web/hooks/useSaveResponse.tsx @@ -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 {filepath} + + ), + }); + }, + }); +} diff --git a/src-web/lib/data/mimetypes.ts b/src-web/lib/data/mimetypes.ts index bd4a8f4d..a8605398 100644 --- a/src-web/lib/data/mimetypes.ts +++ b/src-web/lib/data/mimetypes.ts @@ -206,3 +206,28 @@ export const mimeTypes = [ 'video/x-flv', '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; +} diff --git a/src-web/lib/models.ts b/src-web/lib/models.ts index c861e8f4..27048d9a 100644 --- a/src-web/lib/models.ts +++ b/src-web/lib/models.ts @@ -220,3 +220,7 @@ export function modelsEq(a: Model, b: Model) { } return false; } + +export function getContentTypeHeader(headers: HttpHeader[]): string | null { + return headers.find((h) => h.name.toLowerCase() === 'content-type')?.value ?? null; +}