Multi-line multi-part values

This commit is contained in:
Gregory Schier
2025-01-27 07:30:06 -08:00
parent 1d37a15cfe
commit 662c38d7a0
11 changed files with 147 additions and 40 deletions

View File

@@ -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}`;
}

View File

@@ -326,14 +326,33 @@ pub async fn send_http_request<R: Runtime>(
// 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

View File

@@ -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<R: Runtime>(
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"))]

View File

@@ -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
</Separator>
<Editor
format={tryFormatJson}
language="json"
heightMode="auto"
defaultValue={currentBody.variables}

View File

@@ -14,7 +14,6 @@ import { useEffect, useMemo, useRef } from 'react';
import type { ReflectResponseService } from '../hooks/useGrpc';
import { showAlert } from '../lib/alert';
import { showDialog } from '../lib/dialog';
import { tryFormatJson } from '../lib/formatters';
import { pluralizeCount } from '../lib/pluralize';
import { Button } from './core/Button';
import type { EditorProps } from './core/Editor/Editor';
@@ -186,7 +185,6 @@ export function GrpcEditor({
useTemplating
forceUpdateKey={request.id}
defaultValue={request.message}
format={tryFormatJson}
heightMode="auto"
placeholder="..."
ref={editorViewRef}

View File

@@ -1,5 +1,5 @@
import type { HttpRequest } from '@yaakapp-internal/models';
import type {GenericCompletionOption} from "@yaakapp-internal/plugins";
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import { atom, useAtom, useAtomValue } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
@@ -21,7 +21,6 @@ import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest';
import { deepEqualAtom } from '../lib/atoms';
import { languageFromContentType } from '../lib/contentType';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { tryFormatJson } from '../lib/formatters';
import { generateId } from '../lib/generateId';
import {
BODY_TYPE_BINARY,
@@ -407,7 +406,6 @@ export const RequestPane = memo(function RequestPane({
defaultValue={`${activeRequest.body?.text ?? ''}`}
language="json"
onChange={handleBodyTextChange}
format={tryFormatJson}
stateKey={`json.${activeRequest.id}`}
/>
) : activeRequest.bodyType === BODY_TYPE_XML ? (

View File

@@ -217,6 +217,7 @@ export const SidebarItem = memo(function SidebarItem({
const itemPrefix = (item.model === 'http_request' || item.model === 'grpc_request') && (
<HttpMethodTag
shortNames
request={item}
className={classNames(!(active || selected) && 'text-text-subtlest')}
/>
@@ -271,7 +272,7 @@ export const SidebarItem = memo(function SidebarItem({
onKeyDown={handleInputKeyDown}
/>
) : (
<span className="truncate">{itemName}</span>
<span className="truncate w-full">{itemName}</span>
)}
</div>
{latestGrpcConnection ? (

View File

@@ -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<EditorView | undefined, EditorProps>(function E
}
if (disabled) {
readOnly = true;
readOnly = true;
}
if (
@@ -147,6 +148,15 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(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, []);

View File

@@ -15,18 +15,16 @@ const longMethodMap = {
delete: 'DELETE',
options: 'OPTIONS',
head: 'HEAD',
grpc: 'GRPC',
} as const;
const shortMethodMap: Record<keyof typeof longMethodMap, string> = {
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();

View File

@@ -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<HTMLDivElement>(null);
const nameInputRef = useRef<EditorView>(null);
const valueInputRef = useRef<EditorView>(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 }) => (
<MultilineEditDialog
hide={hide}
onChange={handleChangeValueText}
defaultValue={pair.value}
language={valueLanguage}
/>
),
}),
[handleChangeValueText, pair.name, pair.value, valueLanguage],
);
const [, connectDrop] = useDrop<Pair>(
{
accept: ItemTypes.ROW,
@@ -495,6 +519,15 @@ function PairEditorRow({
onFocus={handleFocus}
placeholder={valuePlaceholder ?? 'value'}
/>
) : pair.value.includes('\n') ? (
<Button
color="secondary"
size="sm"
onClick={handleEditMultiLineValue}
title={pair.value}
>
Edit {formatSize(pair.value.length)}
</Button>
) : (
<Input
ref={valueInputRef}
@@ -505,6 +538,7 @@ function PairEditorRow({
size="sm"
containerClassName={classNames(isLast && 'border-dashed')}
validate={valueValidate}
language={valueLanguage}
forceUpdateKey={forceUpdateKey}
defaultValue={pair.value}
label="Value"
@@ -526,6 +560,7 @@ function PairEditorRow({
onChangeText={handleChangeValueText}
onChangeContentType={handleChangeValueContentType}
onDelete={handleDelete}
editMultiLine={handleEditMultiLineValue}
/>
) : (
<Dropdown items={deleteItems}>
@@ -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<DropdownItem[]>(
() => [
{
label: 'Edit Multi-Line',
leftSlot: <Icon icon="file_code" />,
hidden: pair.isFile,
onSelect: editMultiLine,
},
{
label: 'Set Content-Type',
leftSlot: <Icon icon="pencil" />,
hidden: !pair.isFile,
onSelect: async () => {
const contentType = await showPrompt({
id: 'content-type',
@@ -602,7 +644,7 @@ function FileActionsDropdown({
leftSlot: <Icon icon="trash" />,
},
],
[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<string>(defaultValue);
return (
<div className="w-[100vw] max-w-[40rem] h-[50vh] max-h-full grid grid-rows-[minmax(0,1fr)_auto]">
<Editor
heightMode="auto"
defaultValue={defaultValue}
language={language}
onChange={setValue}
stateKey={null}
/>
<div>
<Button
color="primary"
className="ml-auto my-2"
onClick={() => {
onChange(value);
hide();
}}
>
Done
</Button>
</div>
</div>
);
}

View File

@@ -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 (
<span className="font-mono" title={`${contentLength} bytes`}>
{Math.round(num * 10) / 10} {unit}
{formatSize(contentLength)}
</span>
);
}