From 662c38d7a0bcdf7548ffbefe20ab1c36a112d685 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Mon, 27 Jan 2025 07:30:06 -0800 Subject: [PATCH] Multi-line multi-part values --- packages/common-lib/formatSize.ts | 20 ++++++ src-tauri/src/http_request.rs | 25 ++++++- src-tauri/src/window.rs | 3 +- src-web/components/GraphQLEditor.tsx | 2 - src-web/components/GrpcEditor.tsx | 2 - src-web/components/RequestPane.tsx | 4 +- src-web/components/SidebarItem.tsx | 3 +- src-web/components/core/Editor/Editor.tsx | 12 +++- src-web/components/core/HttpMethodTag.tsx | 12 ++-- src-web/components/core/PairEditor.tsx | 83 ++++++++++++++++++++++- src-web/components/core/SizeTag.tsx | 21 +----- 11 files changed, 147 insertions(+), 40 deletions(-) create mode 100644 packages/common-lib/formatSize.ts diff --git a/packages/common-lib/formatSize.ts b/packages/common-lib/formatSize.ts new file mode 100644 index 00000000..2826cc4e --- /dev/null +++ b/packages/common-lib/formatSize.ts @@ -0,0 +1,20 @@ +export function formatSize(bytes: number): string { + let num; + let unit; + + if (bytes > 1000 * 1000 * 1000) { + num = bytes / 1000 / 1000 / 1000; + unit = 'GB'; + } else if (bytes > 1000 * 1000) { + num = bytes / 1000 / 1000; + unit = 'MB'; + } else if (bytes > 1000) { + num = bytes / 1000; + unit = 'KB'; + } else { + num = bytes; + unit = 'B'; + } + + return `${Math.round(num * 10) / 10} ${unit}`; +} diff --git a/src-tauri/src/http_request.rs b/src-tauri/src/http_request.rs index 4b8a532a..ad6ffae1 100644 --- a/src-tauri/src/http_request.rs +++ b/src-tauri/src/http_request.rs @@ -326,14 +326,33 @@ pub async fn send_http_request( // Set or guess mimetype if !content_type.is_empty() { - part = part.mime_str(content_type).map_err(|e| e.to_string())?; + part = match part.mime_str(content_type) { + Ok(p) => p, + Err(e) => { + return Ok(response_err( + &*response.lock().await, + format!("Invalid mime for multi-part entry {e:?}"), + window, + ) + .await); + } + }; } else if !file_path.is_empty() { let default_mime = Mime::from_str("application/octet-stream").unwrap(); let mime = mime_guess::from_path(file_path.clone()).first_or(default_mime); - part = - part.mime_str(mime.essence_str()).map_err(|e| e.to_string())?; + part = match part.mime_str(mime.essence_str()) { + Ok(p) => p, + Err(e) => { + return Ok(response_err( + &*response.lock().await, + format!("Invalid mime for multi-part entry {e:?}"), + window, + ) + .await); + } + }; } // Set file path if not empty diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index a08ff450..c2df780d 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -3,7 +3,7 @@ use crate::{DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH, MIN_WINDOW_HEIGHT, MIN_ use log::{info, warn}; use std::process::exit; use tauri::{ - AppHandle, Emitter, LogicalSize, Manager, Runtime, TitleBarStyle, WebviewUrl, WebviewWindow, + AppHandle, Emitter, LogicalSize, Manager, Runtime, WebviewUrl, WebviewWindow, }; use tauri_plugin_opener::OpenerExt; use tokio::sync::mpsc; @@ -67,6 +67,7 @@ pub(crate) fn create_window( if config.hide_titlebar { #[cfg(target_os = "macos")] { + use tauri::TitleBarStyle; win_builder = win_builder.hidden_title(true).title_bar_style(TitleBarStyle::Overlay); } #[cfg(not(target_os = "macos"))] diff --git a/src-web/components/GraphQLEditor.tsx b/src-web/components/GraphQLEditor.tsx index b959a452..c87d8f54 100644 --- a/src-web/components/GraphQLEditor.tsx +++ b/src-web/components/GraphQLEditor.tsx @@ -7,7 +7,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useLocalStorage } from 'react-use'; import { useIntrospectGraphQL } from '../hooks/useIntrospectGraphQL'; import { showDialog } from '../lib/dialog'; -import { tryFormatJson } from '../lib/formatters'; import { Button } from './core/Button'; import { Dropdown } from './core/Dropdown'; import type { EditorProps } from './core/Editor/Editor'; @@ -178,7 +177,6 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr Variables ) : activeRequest.bodyType === BODY_TYPE_XML ? ( diff --git a/src-web/components/SidebarItem.tsx b/src-web/components/SidebarItem.tsx index 97675c33..d8d1b398 100644 --- a/src-web/components/SidebarItem.tsx +++ b/src-web/components/SidebarItem.tsx @@ -217,6 +217,7 @@ export const SidebarItem = memo(function SidebarItem({ const itemPrefix = (item.model === 'http_request' || item.model === 'grpc_request') && ( @@ -271,7 +272,7 @@ export const SidebarItem = memo(function SidebarItem({ onKeyDown={handleInputKeyDown} /> ) : ( - {itemName} + {itemName} )} {latestGrpcConnection ? ( diff --git a/src-web/components/core/Editor/Editor.tsx b/src-web/components/core/Editor/Editor.tsx index e0049f6a..24c0c590 100644 --- a/src-web/components/core/Editor/Editor.tsx +++ b/src-web/components/core/Editor/Editor.tsx @@ -29,6 +29,7 @@ import { useRequestEditor } from '../../../hooks/useRequestEditor'; import { useSettings } from '../../../hooks/useSettings'; import { useTemplateFunctionCompletionOptions } from '../../../hooks/useTemplateFunctions'; import { showDialog } from '../../../lib/dialog'; +import { tryFormatJson, tryFormatXml } from '../../../lib/formatters'; import { TemplateFunctionDialog } from '../../TemplateFunctionDialog'; import { TemplateVariableDialog } from '../../TemplateVariableDialog'; import { IconButton } from '../IconButton'; @@ -134,7 +135,7 @@ export const Editor = forwardRef(function E } if (disabled) { - readOnly = true; + readOnly = true; } if ( @@ -147,6 +148,15 @@ export const Editor = forwardRef(function E disableTabIndent = true; } + if (format == null) { + format = + language === 'json' + ? tryFormatJson + : language === 'xml' || language === 'html' + ? tryFormatXml + : undefined; + } + const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null); useImperativeHandle(ref, () => cm.current?.view, []); diff --git a/src-web/components/core/HttpMethodTag.tsx b/src-web/components/core/HttpMethodTag.tsx index d885eb8c..53b71c94 100644 --- a/src-web/components/core/HttpMethodTag.tsx +++ b/src-web/components/core/HttpMethodTag.tsx @@ -15,18 +15,16 @@ const longMethodMap = { delete: 'DELETE', options: 'OPTIONS', head: 'HEAD', - grpc: 'GRPC', } as const; const shortMethodMap: Record = { get: 'GET', put: 'PUT', - post: 'POST', - patch: 'PTCH', + post: 'PST', + patch: 'PTC', delete: 'DEL', - options: 'OPTS', - head: 'HEAD', - grpc: 'GRPC', + options: 'OPT', + head: 'HED', }; export function HttpMethodTag({ shortNames, request, className }: Props) { @@ -34,7 +32,7 @@ export function HttpMethodTag({ shortNames, request, className }: Props) { request.model === 'http_request' && request.bodyType === 'graphql' ? 'GQL' : request.model === 'grpc_request' - ? 'GRPC' + ? 'GRP' : request.method; const m = method.toLowerCase(); diff --git a/src-web/components/core/PairEditor.tsx b/src-web/components/core/PairEditor.tsx index ce4efd91..c8a2b675 100644 --- a/src-web/components/core/PairEditor.tsx +++ b/src-web/components/core/PairEditor.tsx @@ -1,3 +1,4 @@ +import { formatSize } from '@yaakapp-internal/lib/formatSize'; import classNames from 'classnames'; import type { EditorView } from 'codemirror'; import { @@ -13,6 +14,8 @@ import { import type { XYCoord } from 'react-dnd'; import { useDrag, useDrop } from 'react-dnd'; import { useToggle } from '../../hooks/useToggle'; +import { languageFromContentType } from '../../lib/contentType'; +import { showDialog } from '../../lib/dialog'; import { generateId } from '../../lib/generateId'; import { showPrompt } from '../../lib/prompt'; import { DropMarker } from '../DropMarker'; @@ -21,6 +24,8 @@ import { Button } from './Button'; import { Checkbox } from './Checkbox'; import type { DropdownItem } from './Dropdown'; import { Dropdown } from './Dropdown'; +import type { EditorProps } from './Editor/Editor'; +import { Editor } from './Editor/Editor'; import type { GenericCompletionConfig } from './Editor/genericCompletion'; import { Icon } from './Icon'; import { IconButton } from './IconButton'; @@ -326,6 +331,7 @@ function PairEditorRow({ const ref = useRef(null); const nameInputRef = useRef(null); const valueInputRef = useRef(null); + const valueLanguage = languageFromContentType(pair.contentType ?? null); useEffect(() => { if (forceFocusNamePairId === pair.id) { @@ -380,6 +386,24 @@ function PairEditorRow({ [onChange, pair], ); + const handleEditMultiLineValue = useCallback( + () => + showDialog({ + id: 'pair-edit-multiline', + size: 'dynamic', + title: <>Edit {pair.name}, + render: ({ hide }) => ( + + ), + }), + [handleChangeValueText, pair.name, pair.value, valueLanguage], + ); + const [, connectDrop] = useDrop( { accept: ItemTypes.ROW, @@ -495,6 +519,15 @@ function PairEditorRow({ onFocus={handleFocus} placeholder={valuePlaceholder ?? 'value'} /> + ) : pair.value.includes('\n') ? ( + ) : ( ) : ( @@ -552,12 +587,14 @@ function FileActionsDropdown({ onChangeText, onChangeContentType, onDelete, + editMultiLine, }: { pair: Pair; onChangeFile: ({ filePath }: { filePath: string | null }) => void; onChangeText: (text: string) => void; onChangeContentType: (contentType: string) => void; onDelete: () => void; + editMultiLine: () => void; }) { const onChange = useCallback( (v: string) => { @@ -569,10 +606,15 @@ function FileActionsDropdown({ const extraItems = useMemo( () => [ + { + label: 'Edit Multi-Line', + leftSlot: , + hidden: pair.isFile, + onSelect: editMultiLine, + }, { label: 'Set Content-Type', leftSlot: , - hidden: !pair.isFile, onSelect: async () => { const contentType = await showPrompt({ id: 'content-type', @@ -602,7 +644,7 @@ function FileActionsDropdown({ leftSlot: , }, ], - [onChangeContentType, onChangeFile, onDelete, pair.contentType, pair.isFile], + [editMultiLine, onChangeContentType, onChangeFile, onDelete, pair.contentType, pair.isFile], ); return ( @@ -629,3 +671,40 @@ function emptyPair(): PairWithId { function isPairEmpty(pair: Pair): boolean { return !pair.name && !pair.value; } + +function MultilineEditDialog({ + defaultValue, + language, + onChange, + hide, +}: { + defaultValue: string; + language: EditorProps['language']; + onChange: (value: string) => void; + hide: () => void; +}) { + const [value, setValue] = useState(defaultValue); + return ( +
+ +
+ +
+
+ ); +} diff --git a/src-web/components/core/SizeTag.tsx b/src-web/components/core/SizeTag.tsx index b11a5e56..845a8d42 100644 --- a/src-web/components/core/SizeTag.tsx +++ b/src-web/components/core/SizeTag.tsx @@ -1,28 +1,13 @@ +import { formatSize } from '@yaakapp-internal/lib/formatSize'; + interface Props { contentLength: number; } export function SizeTag({ contentLength }: Props) { - let num; - let unit; - - if (contentLength > 1000 * 1000 * 1000) { - num = contentLength / 1000 / 1000 / 1000; - unit = 'GB'; - } else if (contentLength > 1000 * 1000) { - num = contentLength / 1000 / 1000; - unit = 'MB'; - } else if (contentLength > 1000) { - num = contentLength / 1000; - unit = 'KB'; - } else { - num = contentLength; - unit = 'B'; - } - return ( - {Math.round(num * 10) / 10} {unit} + {formatSize(contentLength)} ); }