Binary file uploads and missing workspace empty state

This commit is contained in:
Gregory Schier
2024-03-10 10:56:38 -07:00
parent ce9ccd34e7
commit efd7e7bf84
27 changed files with 214 additions and 47 deletions

15
package-lock.json generated
View File

@@ -34,6 +34,7 @@
"format-graphql": "^1.4.0",
"framer-motion": "^9.0.4",
"lucide-react": "^0.309.0",
"mime": "^4.0.1",
"papaparse": "^5.4.1",
"parse-color": "^1.0.0",
"react": "^18.2.0",
@@ -7208,6 +7209,20 @@
"node": ">=8.6"
}
},
"node_modules/mime": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/mime/-/mime-4.0.1.tgz",
"integrity": "sha512-5lZ5tyrIfliMXzFtkYyekWbtRXObT9OWa8IwQ5uxTBDHucNNwniRqo0yInflj+iYi5CBa6qxadGzGarDfuEOxA==",
"funding": [
"https://github.com/sponsors/broofa"
],
"bin": {
"mime": "bin/cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/mimic-fn": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",

View File

@@ -51,6 +51,7 @@
"format-graphql": "^1.4.0",
"framer-motion": "^9.0.4",
"lucide-react": "^0.309.0",
"mime": "^4.0.1",
"papaparse": "^5.4.1",
"parse-color": "^1.0.0",
"react": "^18.2.0",

View File

@@ -244,6 +244,13 @@ pub async fn send_http_request(
}
}
request_builder = request_builder.form(&form_params);
} else if body_type == "binary" && request_body.contains_key("filePath") {
let file_path = request_body.get("filePath").unwrap().as_str().unwrap();
request_builder = request_builder.body(
fs::read(file_path)
.map_err(|e| e.to_string())
.expect("Failed to read file"),
);
} else if body_type == "multipart/form-data" && request_body.contains_key("form") {
let mut multipart_form = multipart::Form::new();
if let Some(form_definition) = request_body.get("form") {

View File

@@ -0,0 +1,82 @@
import { open } from '@tauri-apps/api/dialog';
import mime from 'mime';
import { useKeyValue } from '../hooks/useKeyValue';
import type { HttpRequest } from '../lib/models';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { InlineCode } from './core/InlineCode';
import { HStack, VStack } from './core/Stacks';
type Props = {
requestId: string;
contentType: string | null;
body: HttpRequest['body'];
onChange: (body: HttpRequest['body']) => void;
onChangeContentType: (contentType: string | null) => void;
};
export function BinaryFileEditor({
contentType,
body,
onChange,
onChangeContentType,
requestId,
}: Props) {
const ignoreContentType = useKeyValue<boolean>({
namespace: 'global',
key: ['ignore_content_type', requestId],
fallback: false,
});
const handleClick = async () => {
await ignoreContentType.set(false);
const path = await open({
title: 'Select File',
multiple: false,
});
if (path) {
onChange({ filePath: path });
}
};
const filePath = typeof body.filePath === 'string' ? body.filePath : undefined;
const mimeType = mime.getType(filePath ?? '') ?? 'application/octet-stream';
console.log('mimeType', mimeType, contentType);
return (
<VStack space={2}>
<HStack space={2} alignItems="center">
<Button variant="border" color="gray" size="sm" onClick={handleClick}>
Choose File
</Button>
<div className="text-xs font-mono truncate rtl pr-3 text-gray-800">
{/* Special character to insert ltr text in rtl element without making things wonky */}
&#x200E;
{filePath ?? 'Select File'}
</div>
</HStack>
{mimeType !== contentType && !ignoreContentType.value && (
<Banner className="mt-3 !py-5">
<div className="text-sm mb-4 text-center">
<div>Set Content-Type header to</div>
<InlineCode>{mimeType}</InlineCode>?
</div>
<HStack space={1.5} justifyContent="center">
<Button
variant="solid"
color="gray"
size="xs"
onClick={() => onChangeContentType(mimeType)}
>
Set Header
</Button>
<Button size="xs" variant="border" onClick={() => ignoreContentType.set(true)}>
Ignore
</Button>
</HStack>
</Banner>
)}
</VStack>
);
}

View File

@@ -6,7 +6,7 @@ import { PairEditor } from './core/PairEditor';
type Props = {
forceUpdateKey: string;
body: HttpRequest['body'];
onChange: (headers: HttpRequest['body']) => void;
onChange: (body: HttpRequest['body']) => void;
};
export function FormMultipartEditor({ body, forceUpdateKey, onChange }: Props) {

View File

@@ -18,7 +18,6 @@ import { settingsQueryKey } from '../hooks/useSettings';
import { useSyncAppearance } from '../hooks/useSyncAppearance';
import { useSyncWindowTitle } from '../hooks/useSyncWindowTitle';
import { workspacesQueryKey } from '../hooks/useWorkspaces';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { Model } from '../lib/models';
import { modelsEq } from '../lib/models';
import { setPathname } from '../lib/persistPathname';
@@ -142,7 +141,7 @@ function removeById<T extends { id: string }>(model: T) {
const shouldIgnoreModel = (payload: Model) => {
if (payload.model === 'key_value') {
return payload.namespace === NAMESPACE_NO_SYNC;
return payload.namespace === 'no_sync';
}
return false;
};

View File

@@ -6,24 +6,27 @@ import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useIsResponseLoading } from '../hooks/useIsResponseLoading';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders';
import { useSendRequest } from '../hooks/useSendRequest';
import { useUpdateHttpRequest } from '../hooks/useUpdateHttpRequest';
import { tryFormatJson } from '../lib/formatters';
import type { HttpHeader, HttpRequest, HttpUrlParameter } from '../lib/models';
import {
BODY_TYPE_OTHER,
AUTH_TYPE_BASIC,
AUTH_TYPE_BEARER,
AUTH_TYPE_NONE,
BODY_TYPE_BINARY,
BODY_TYPE_FORM_MULTIPART,
BODY_TYPE_FORM_URLENCODED,
BODY_TYPE_GRAPHQL,
BODY_TYPE_JSON,
BODY_TYPE_NONE,
BODY_TYPE_OTHER,
BODY_TYPE_XML,
} from '../lib/models';
import { BasicAuth } from './BasicAuth';
import { BearerAuth } from './BearerAuth';
import { BinaryFileEditor } from './BinaryFileEditor';
import { CountBadge } from './core/CountBadge';
import { Editor } from './core/Editor';
import type { TabItem } from './core/Tabs/Tabs';
@@ -56,6 +59,7 @@ export const RequestPane = memo(function RequestPane({
const [activeTab, setActiveTab] = useActiveTab();
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
const { updateKey: forceUpdateKey } = useRequestUpdateKey(activeRequest.id ?? null);
const contentType = useContentTypeFromHeaders(activeRequest.headers);
const tabs: TabItem[] = useMemo(
() => [
@@ -68,11 +72,12 @@ export const RequestPane = memo(function RequestPane({
{ label: 'Url Encoded', value: BODY_TYPE_FORM_URLENCODED },
{ label: 'Multi-Part', value: BODY_TYPE_FORM_MULTIPART },
{ type: 'separator', label: 'Text Content' },
{ label: 'GraphQL', value: BODY_TYPE_GRAPHQL },
{ label: 'JSON', value: BODY_TYPE_JSON },
{ label: 'XML', value: BODY_TYPE_XML },
{ label: 'GraphQL', value: BODY_TYPE_GRAPHQL },
{ label: 'Other', value: BODY_TYPE_OTHER },
{ type: 'separator', label: 'Other' },
{ label: 'Binary File', value: BODY_TYPE_BINARY },
{ label: 'No Body', shortLabel: 'Body', value: BODY_TYPE_NONE },
],
onChange: async (bodyType) => {
@@ -94,7 +99,7 @@ export const RequestPane = memo(function RequestPane({
[]),
{
name: 'Content-Type',
value: bodyType,
value: bodyType === BODY_TYPE_OTHER ? 'text/plain' : bodyType,
enabled: true,
},
];
@@ -111,10 +116,10 @@ export const RequestPane = memo(function RequestPane({
];
}
await updateRequest.mutateAsync(patch);
// Force update header editor so any changed headers are reflected
setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);
updateRequest.mutate(patch);
},
},
},
@@ -171,6 +176,27 @@ export const RequestPane = memo(function RequestPane({
(body: HttpRequest['body']) => updateRequest.mutate({ body }),
[updateRequest],
);
const handleContentTypeChange = useCallback(
async (contentType: string | null) => {
const headers =
contentType != null
? activeRequest.headers.map((h) =>
h.name.toLowerCase() === 'content-type' ? { ...h, value: contentType } : h,
)
: activeRequest.headers;
await updateRequest.mutateAsync({ headers });
// Force update header editor so any changed headers are reflected
setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);
},
[activeRequest.headers, updateRequest],
);
const handleBinaryFileChange = useCallback(
(body: HttpRequest['body']) => {
updateRequest.mutate({ body });
},
[updateRequest],
);
const handleBodyTextChange = useCallback(
(text: string) => updateRequest.mutate({ body: { text } }),
[updateRequest],
@@ -314,6 +340,14 @@ export const RequestPane = memo(function RequestPane({
body={activeRequest.body}
onChange={handleBodyChange}
/>
) : activeRequest.bodyType === BODY_TYPE_BINARY ? (
<BinaryFileEditor
requestId={activeRequest.id}
contentType={contentType}
body={activeRequest.body}
onChange={handleBinaryFileChange}
onChangeContentType={handleContentTypeChange}
/>
) : (
<EmptyStateText>No Body</EmptyStateText>
)}

View File

@@ -3,7 +3,7 @@ import type { CSSProperties } from 'react';
import { memo, useMemo } from 'react';
import { createGlobalState } from 'react-use';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useResponseContentType } from '../hooks/useResponseContentType';
import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders';
import { useResponseViewMode } from '../hooks/useResponseViewMode';
import type { HttpRequest } from '../lib/models';
import { isResponseLoading } from '../lib/models';
@@ -37,7 +37,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
const { activeResponse, setPinnedResponse, responses } = usePinnedHttpResponse(activeRequest);
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const [activeTab, setActiveTab] = useActiveTab();
const contentType = useResponseContentType(activeResponse);
const contentType = useContentTypeFromHeaders(activeResponse?.headers ?? null);
const tabs = useMemo<TabItem[]>(
() => [

View File

@@ -31,7 +31,6 @@ import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest';
import { useUpdateGrpcRequest } from '../hooks/useUpdateGrpcRequest';
import { useUpdateHttpRequest } from '../hooks/useUpdateHttpRequest';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { Folder, GrpcRequest, HttpRequest, Workspace } from '../lib/models';
import { isResponseLoading } from '../lib/models';
import type { DropdownItem } from './core/Dropdown';
@@ -87,7 +86,7 @@ export function Sidebar({ className }: Props) {
const collapsed = useKeyValue<Record<string, boolean>>({
key: ['sidebar_collapsed', activeWorkspace?.id ?? 'n/a'],
fallback: {},
namespace: NAMESPACE_NO_SYNC,
namespace: 'no_sync',
});
useHotKey('http_request.duplicate', async () => {

View File

@@ -9,13 +9,19 @@ import type {
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useWindowSize } from 'react-use';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { useImportData } from '../hooks/useImportData';
import { useIsFullscreen } from '../hooks/useIsFullscreen';
import { useOsInfo } from '../hooks/useOsInfo';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useSidebarWidth } from '../hooks/useSidebarWidth';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { HotKeyList } from './core/HotKeyList';
import { InlineCode } from './core/InlineCode';
import { FeedbackLink } from './core/Link';
import { HStack } from './core/Stacks';
import { CreateDropdown } from './CreateDropdown';
import { GrpcConnectionLayout } from './GrpcConnectionLayout';
@@ -34,6 +40,9 @@ const drag = { gridArea: 'drag' };
const WINDOW_FLOATING_SIDEBAR_WIDTH = 600;
export default function Workspace() {
const workspaces = useWorkspaces();
const activeWorkspace = useActiveWorkspace();
const activeWorkspaceId = useActiveWorkspaceId();
const { setWidth, width, resetWidth } = useSidebarWidth();
const { hide, show, hidden } = useSidebarHidden();
const activeRequest = useActiveRequest();
@@ -119,6 +128,11 @@ export default function Workspace() {
);
}
// We're loading still
if (workspaces.length === 0) {
return null;
}
return (
<div
style={styles}
@@ -163,7 +177,15 @@ export default function Workspace() {
<HeaderSize data-tauri-drag-region style={head}>
<WorkspaceHeader className="pointer-events-none" />
</HeaderSize>
{activeRequest == null ? (
{activeWorkspace == null ? (
<div className="m-auto">
<Banner color="warning" className="max-w-[30rem]">
The active workspace{' '}
<InlineCode className="text-orange-800">{activeWorkspaceId}</InlineCode> was not found.
Select a workspace from the header menu or report this bug to <FeedbackLink />
</Banner>
</div>
) : activeRequest == null ? (
<HotKeyList
hotkeys={['http_request.create', 'sidebar.toggle', 'settings.show']}
bottomSlot={

View File

@@ -167,10 +167,14 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
<Dropdown items={items}>
<Button
size="sm"
className={classNames(className, 'text-gray-800 !px-2 truncate')}
className={classNames(
className,
'text-gray-800 !px-2 truncate',
activeWorkspace === null && 'italic opacity-disabled',
)}
{...buttonProps}
>
{activeWorkspace?.name}
{activeWorkspace?.name ?? 'Workspace'}
</Button>
</Dropdown>
);

View File

@@ -4,7 +4,7 @@ import type { ReactNode } from 'react';
interface Props {
children: ReactNode;
className?: string;
color?: 'danger' | 'success' | 'gray';
color?: 'danger' | 'warning' | 'success' | 'gray';
}
export function Banner({ children, className, color = 'gray' }: Props) {
return (
@@ -14,6 +14,7 @@ export function Banner({ children, className, color = 'gray' }: Props) {
className,
'border border-dashed italic px-3 py-2 rounded select-auto cursor-text',
color === 'gray' && 'border-gray-500/60 bg-gray-300/10 text-gray-800',
color === 'warning' && 'border-orange-500/60 bg-orange-300/10 text-orange-800',
color === 'danger' && 'border-red-500/60 bg-red-300/10 text-red-800',
color === 'success' && 'border-green-500/60 bg-green-300/10 text-green-800',
)}

View File

@@ -33,3 +33,7 @@ export function Link({ href, children, className, ...other }: Props) {
</RouterLink>
);
}
export function FeedbackLink() {
return <Link href="https://yaak.canny.io">Feedback</Link>;
}

View File

@@ -4,7 +4,7 @@ import { useCallback, useMemo } from 'react';
import { useDebouncedState } from '../../hooks/useDebouncedState';
import { useFilterResponse } from '../../hooks/useFilterResponse';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import { useResponseContentType } from '../../hooks/useResponseContentType';
import { useContentTypeFromHeaders } from '../../hooks/useContentTypeFromHeaders';
import { useToggle } from '../../hooks/useToggle';
import { tryFormatJson, tryFormatXml } from '../../lib/formatters';
import type { HttpResponse } from '../../lib/models';
@@ -21,7 +21,7 @@ export function TextViewer({ response, pretty }: Props) {
const [isSearching, toggleIsSearching] = useToggle();
const [filterText, setDebouncedFilterText, setFilterText] = useDebouncedState<string>('', 400);
const contentType = useResponseContentType(response);
const contentType = useContentTypeFromHeaders(response.headers);
const rawBody = useResponseBodyText(response) ?? '';
const formattedBody =
pretty && contentType?.includes('json')

View File

@@ -1,5 +1,4 @@
import { useEffect } from 'react';
import { NAMESPACE_GLOBAL } from '../lib/keyValueStore';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useCookieJars } from './useCookieJars';
import { useKeyValue } from './useKeyValue';
@@ -9,7 +8,7 @@ export function useActiveCookieJar() {
const cookieJars = useCookieJars();
const kv = useKeyValue<string | null>({
namespace: NAMESPACE_GLOBAL,
namespace: 'global',
key: ['activeCookieJar', workspaceId ?? 'n/a'],
fallback: null,
});

View File

@@ -0,0 +1,9 @@
import { useMemo } from 'react';
import type { HttpHeader } from '../lib/models';
export function useContentTypeFromHeaders(headers: HttpHeader[] | null): string | null {
return useMemo(
() => headers?.find((h) => h.name.toLowerCase() === 'content-type')?.value ?? null,
[headers],
);
}

View File

@@ -1,9 +1,8 @@
import { NAMESPACE_GLOBAL } from '../lib/keyValueStore';
import { useKeyValue } from './useKeyValue';
export function protoFilesArgs(requestId: string | null) {
return {
namespace: NAMESPACE_GLOBAL,
namespace: 'global' as const,
key: ['proto_files', requestId ?? 'n/a'],
};
}

View File

@@ -20,7 +20,7 @@ export function useKeyValue<T extends Object | null>({
key,
fallback,
}: {
namespace?: string;
namespace?: 'app' | 'no_sync' | 'global';
key: string | string[];
fallback: T;
}) {

View File

@@ -1,12 +1,12 @@
import { useEffect, useMemo } from 'react';
import { getKeyValue, NAMESPACE_GLOBAL } from '../lib/keyValueStore';
import { getKeyValue } from '../lib/keyValueStore';
import { useActiveEnvironmentId } from './useActiveEnvironmentId';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useEnvironments } from './useEnvironments';
import { useKeyValue } from './useKeyValue';
const kvKey = (workspaceId: string) => 'recent_environments::' + workspaceId;
const namespace = NAMESPACE_GLOBAL;
const namespace = 'global';
const fallback: string[] = [];
export function useRecentEnvironments() {

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo } from 'react';
import { getKeyValue, NAMESPACE_GLOBAL } from '../lib/keyValueStore';
import { getKeyValue } from '../lib/keyValueStore';
import { useActiveRequestId } from './useActiveRequestId';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useGrpcRequests } from './useGrpcRequests';
@@ -7,7 +7,7 @@ import { useHttpRequests } from './useHttpRequests';
import { useKeyValue } from './useKeyValue';
const kvKey = (workspaceId: string) => 'recent_requests::' + workspaceId;
const namespace = NAMESPACE_GLOBAL;
const namespace = 'global';
const fallback: string[] = [];
export function useRecentRequests() {

View File

@@ -1,11 +1,11 @@
import { useEffect, useMemo } from 'react';
import { getKeyValue, NAMESPACE_GLOBAL } from '../lib/keyValueStore';
import { getKeyValue } from '../lib/keyValueStore';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useKeyValue } from './useKeyValue';
import { useWorkspaces } from './useWorkspaces';
const kvKey = () => 'recent_workspaces';
const namespace = NAMESPACE_GLOBAL;
const namespace = 'global';
const fallback: string[] = [];
export function useRecentWorkspaces() {

View File

@@ -1,9 +0,0 @@
import { useMemo } from 'react';
import type { HttpResponse } from '../lib/models';
export function useResponseContentType(response: HttpResponse | null): string | null {
return useMemo(
() => response?.headers.find((h) => h.name.toLowerCase() === 'content-type')?.value ?? null,
[response],
);
}

View File

@@ -1,12 +1,11 @@
import { useMemo } from 'react';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useKeyValue } from './useKeyValue';
export function useSidebarHidden() {
const activeWorkspaceId = useActiveWorkspaceId();
const { set, value } = useKeyValue<boolean>({
namespace: NAMESPACE_NO_SYNC,
namespace: 'no_sync',
key: ['sidebar_hidden', activeWorkspaceId ?? 'n/a'],
fallback: false,
});

View File

@@ -1,11 +1,8 @@
import { invoke } from '@tauri-apps/api';
import type { KeyValue } from './models';
export const NAMESPACE_GLOBAL = 'global';
export const NAMESPACE_NO_SYNC = 'no_sync';
export async function setKeyValue<T>({
namespace = NAMESPACE_GLOBAL,
namespace = 'global',
key,
value,
}: {
@@ -21,7 +18,7 @@ export async function setKeyValue<T>({
}
export async function getKeyValue<T>({
namespace = NAMESPACE_GLOBAL,
namespace = 'global',
key,
fallback,
}: {

View File

@@ -1,6 +1,7 @@
export const BODY_TYPE_NONE = null;
export const BODY_TYPE_GRAPHQL = 'graphql';
export const BODY_TYPE_JSON = 'application/json';
export const BODY_TYPE_BINARY = 'binary';
export const BODY_TYPE_OTHER = 'other';
export const BODY_TYPE_FORM_URLENCODED = 'application/x-www-form-urlencoded';
export const BODY_TYPE_FORM_MULTIPART = 'multipart/form-data';

View File

@@ -1,8 +1,8 @@
import { appWindow } from '@tauri-apps/api/window';
import { NAMESPACE_NO_SYNC, getKeyValue, setKeyValue } from './keyValueStore';
import { getKeyValue, setKeyValue } from './keyValueStore';
const key = ['window_pathname', appWindow.label];
const namespace = NAMESPACE_NO_SYNC;
const namespace = 'no_sync';
const fallback = undefined;
export async function setPathname(value: string) {

View File

@@ -65,6 +65,10 @@
}
}
.rtl {
direction: rtl;
}
iframe {
&::-webkit-scrollbar-corner,
&::-webkit-scrollbar {