mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-19 16:21:13 +01:00
Improve copy-as-curl
This commit is contained in:
@@ -30,15 +30,14 @@ export function BinaryFileEditor({
|
||||
|
||||
const handleClick = async () => {
|
||||
await ignoreContentType.set(false);
|
||||
const path = await open({
|
||||
const selected = await open({
|
||||
title: 'Select File',
|
||||
multiple: false,
|
||||
});
|
||||
if (path) {
|
||||
onChange({ filePath: path });
|
||||
} else {
|
||||
onChange({ filePath: undefined });
|
||||
if (selected == null) {
|
||||
return;
|
||||
}
|
||||
onChange({ filePath: selected.path });
|
||||
};
|
||||
|
||||
const filePath = typeof body.filePath === 'string' ? body.filePath : undefined;
|
||||
|
||||
@@ -42,15 +42,15 @@ export function GrpcProtoSelection({ requestId }: Props) {
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const files = await open({
|
||||
const selected = await open({
|
||||
title: 'Select Proto Files',
|
||||
multiple: true,
|
||||
filters: [{ name: 'Proto Files', extensions: ['proto'] }],
|
||||
});
|
||||
if (files == null) {
|
||||
if (selected == null) {
|
||||
return;
|
||||
}
|
||||
const newFiles = files.map((f) => f.path).filter((p) => !protoFiles.includes(p));
|
||||
const newFiles = selected.map((f) => f.path).filter((p) => !protoFiles.includes(p));
|
||||
await protoFilesKv.set([...protoFiles, ...newFiles]);
|
||||
await grpc.reflect.refetch();
|
||||
}}
|
||||
|
||||
41
src-web/components/ImportCurlButton.tsx
Normal file
41
src-web/components/ImportCurlButton.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from './core/Button';
|
||||
import { useClipboardText } from '../hooks/useClipboardText';
|
||||
import { useImportCurl } from '../hooks/useImportCurl';
|
||||
import { Icon } from './core/Icon';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export function ImportCurlButton() {
|
||||
const [clipboardText] = useClipboardText();
|
||||
const [lastImportedCmd, setLastImportedCmd] = useState<string>('');
|
||||
const importCurl = useImportCurl();
|
||||
|
||||
if (!clipboardText?.trim().startsWith('curl ') || lastImportedCmd === clipboardText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
color="secondary"
|
||||
leftSlot={<Icon icon="paste" size="sm" />}
|
||||
onClick={() => {
|
||||
importCurl.mutate({
|
||||
requestId: null, // Create request
|
||||
command: clipboardText,
|
||||
});
|
||||
// setClipboardText('');
|
||||
setLastImportedCmd(clipboardText);
|
||||
}}
|
||||
>
|
||||
Import Curl
|
||||
</Button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -38,9 +38,7 @@ import { GraphQLEditor } from './GraphQLEditor';
|
||||
import { HeadersEditor } from './HeadersEditor';
|
||||
import { UrlBar } from './UrlBar';
|
||||
import { UrlParametersEditor } from './UrlParameterEditor';
|
||||
import { useCurlToRequest } from '../hooks/useCurlToRequest';
|
||||
import { useToast } from './ToastContext';
|
||||
import { Icon } from './core/Icon';
|
||||
import { useImportCurl } from '../hooks/useImportCurl';
|
||||
|
||||
interface Props {
|
||||
style: CSSProperties;
|
||||
@@ -230,11 +228,9 @@ export const RequestPane = memo(function RequestPane({
|
||||
[updateRequest],
|
||||
);
|
||||
|
||||
const importCurl = useCurlToRequest();
|
||||
const toast = useToast();
|
||||
|
||||
const isLoading = useIsResponseLoading(activeRequestId ?? null);
|
||||
const { updateKey } = useRequestUpdateKey(activeRequestId ?? null);
|
||||
const importCurl = useImportCurl();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -244,6 +240,7 @@ export const RequestPane = memo(function RequestPane({
|
||||
{activeRequest && (
|
||||
<>
|
||||
<UrlBar
|
||||
key={forceUpdateKey}
|
||||
url={activeRequest.url}
|
||||
method={activeRequest.method}
|
||||
placeholder="https://example.com"
|
||||
@@ -252,14 +249,6 @@ export const RequestPane = memo(function RequestPane({
|
||||
return;
|
||||
}
|
||||
importCurl.mutate({ requestId: activeRequestId, command });
|
||||
toast.show({
|
||||
render: () => [
|
||||
<>
|
||||
<Icon icon="info" />
|
||||
<span>Curl command imported</span>
|
||||
</>,
|
||||
],
|
||||
});
|
||||
}}
|
||||
onSend={handleSend}
|
||||
onCancel={handleCancel}
|
||||
@@ -326,17 +315,6 @@ export const RequestPane = memo(function RequestPane({
|
||||
contentType="text/xml"
|
||||
onChange={handleBodyTextChange}
|
||||
/>
|
||||
) : activeRequest.bodyType === BODY_TYPE_OTHER ? (
|
||||
<Editor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
useTemplating
|
||||
autocompleteVariables
|
||||
placeholder="..."
|
||||
className="!bg-gray-50"
|
||||
heightMode={fullHeight ? 'full' : 'auto'}
|
||||
defaultValue={`${activeRequest.body?.text ?? ''}`}
|
||||
onChange={handleBodyTextChange}
|
||||
/>
|
||||
) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? (
|
||||
<GraphQLEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
@@ -365,6 +343,17 @@ export const RequestPane = memo(function RequestPane({
|
||||
onChange={handleBinaryFileChange}
|
||||
onChangeContentType={handleContentTypeChange}
|
||||
/>
|
||||
) : typeof activeRequest.bodyType === 'string' ? (
|
||||
<Editor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
useTemplating
|
||||
autocompleteVariables
|
||||
placeholder="..."
|
||||
className="!bg-gray-50"
|
||||
heightMode={fullHeight ? 'full' : 'auto'}
|
||||
defaultValue={`${activeRequest.body?.text ?? ''}`}
|
||||
onChange={handleBodyTextChange}
|
||||
/>
|
||||
) : (
|
||||
<EmptyStateText>Empty Body</EmptyStateText>
|
||||
)}
|
||||
|
||||
@@ -608,7 +608,7 @@ const SidebarItem = forwardRef(function SidebarItem(
|
||||
const deleteRequest = useDeleteRequest(activeRequest ?? null);
|
||||
const duplicateHttpRequest = useDuplicateHttpRequest({ id: itemId, navigateAfter: true });
|
||||
const duplicateGrpcRequest = useDuplicateGrpcRequest({ id: itemId, navigateAfter: true });
|
||||
const [copyAsCurl] = useCopyAsCurl(itemId);
|
||||
const [, copyAsCurl] = useCopyAsCurl(itemId);
|
||||
const sendRequest = useSendRequest(itemId);
|
||||
const sendManyRequests = useSendManyRequests();
|
||||
const latestHttpResponse = useLatestHttpResponse(itemId);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import React, { createContext, useContext, useMemo, useRef, useState } from 'react';
|
||||
import type { ToastProps } from './core/Toast';
|
||||
import { Toast } from './core/Toast';
|
||||
@@ -6,7 +7,7 @@ import { Portal } from './Portal';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
|
||||
type ToastEntry = {
|
||||
render: ({ hide }: { hide: () => void }) => React.ReactNode;
|
||||
message: ReactNode;
|
||||
timeout?: number;
|
||||
} & Omit<ToastProps, 'onClose' | 'open' | 'children' | 'timeout'>;
|
||||
|
||||
@@ -66,12 +67,11 @@ export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
);
|
||||
};
|
||||
|
||||
function ToastInstance({ id, render, timeout, ...props }: PrivateToastEntry) {
|
||||
function ToastInstance({ id, message, timeout, ...props }: PrivateToastEntry) {
|
||||
const { actions } = useContext(ToastContext);
|
||||
const children = render({ hide: () => actions.hide(id) });
|
||||
return (
|
||||
<Toast open timeout={timeout} onClose={() => actions.hide(id)} {...props}>
|
||||
{children}
|
||||
{message}
|
||||
</Toast>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { EditorView } from 'codemirror';
|
||||
import type { FormEvent } from 'react';
|
||||
import type { FormEvent, ReactNode } from 'react';
|
||||
import { memo, useRef, useState } from 'react';
|
||||
import { useHotKey } from '../hooks/useHotKey';
|
||||
import type { HttpRequest } from '../lib/models';
|
||||
@@ -20,6 +20,7 @@ type Props = Pick<HttpRequest, 'url'> & {
|
||||
onMethodChange?: (method: string) => void;
|
||||
isLoading: boolean;
|
||||
forceUpdateKey: string;
|
||||
rightSlot?: ReactNode;
|
||||
};
|
||||
|
||||
export const UrlBar = memo(function UrlBar({
|
||||
@@ -34,6 +35,7 @@ export const UrlBar = memo(function UrlBar({
|
||||
onMethodChange,
|
||||
onPaste,
|
||||
submitIcon = 'sendHorizontal',
|
||||
rightSlot,
|
||||
isLoading,
|
||||
}: Props) {
|
||||
const inputRef = useRef<EditorView>(null);
|
||||
@@ -84,17 +86,20 @@ export const UrlBar = memo(function UrlBar({
|
||||
)
|
||||
}
|
||||
rightSlot={
|
||||
submitIcon !== null && (
|
||||
<IconButton
|
||||
size="xs"
|
||||
iconSize="md"
|
||||
title="Send Request"
|
||||
type="submit"
|
||||
className="w-8 my-0.5 mr-0.5"
|
||||
icon={isLoading ? 'x' : submitIcon}
|
||||
hotkeyAction="http_request.send"
|
||||
/>
|
||||
)
|
||||
<>
|
||||
{rightSlot}
|
||||
{submitIcon !== null && (
|
||||
<IconButton
|
||||
size="xs"
|
||||
iconSize="md"
|
||||
title="Send Request"
|
||||
type="submit"
|
||||
className="w-8 my-0.5 mr-0.5"
|
||||
icon={isLoading ? 'x' : submitIcon}
|
||||
hotkeyAction="http_request.send"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
MouseEvent as ReactMouseEvent,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useWindowSize } from 'react-use';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
|
||||
@@ -33,8 +33,6 @@ import { ResizeHandle } from './ResizeHandle';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { SidebarActions } from './SidebarActions';
|
||||
import { WorkspaceHeader } from './WorkspaceHeader';
|
||||
import { useClipboardText } from '../hooks/useClipboardText';
|
||||
import { useToast } from './ToastContext';
|
||||
|
||||
const side = { gridArea: 'side' };
|
||||
const head = { gridArea: 'head' };
|
||||
@@ -56,24 +54,6 @@ export default function Workspace() {
|
||||
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
|
||||
null,
|
||||
);
|
||||
const clipboardText = useClipboardText();
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const isCurlInClipboard = clipboardText?.startsWith('curl ');
|
||||
if (!isCurlInClipboard) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.show({
|
||||
render: () => (
|
||||
<div>
|
||||
<p>Curl command detected?</p>
|
||||
<Button color="primary">Import</Button>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}, [clipboardText, toast]);
|
||||
|
||||
const unsub = () => {
|
||||
if (moveState.current !== null) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { RecentRequestsDropdown } from './RecentRequestsDropdown';
|
||||
import { SettingsDropdown } from './SettingsDropdown';
|
||||
import { SidebarActions } from './SidebarActions';
|
||||
import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
|
||||
import { ImportCurlButton } from './ImportCurlButton';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@@ -19,6 +20,7 @@ interface Props {
|
||||
export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Props) {
|
||||
const osInfo = useOsInfo();
|
||||
const [maximized, setMaximized] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<HStack
|
||||
space={2}
|
||||
@@ -39,6 +41,7 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
|
||||
<RecentRequestsDropdown />
|
||||
</div>
|
||||
<div className="flex-1 flex items-center h-full justify-end pointer-events-none">
|
||||
<ImportCurlButton />
|
||||
<SettingsDropdown />
|
||||
{(osInfo?.osType === 'linux' || osInfo?.osType === 'windows') && (
|
||||
<HStack className="ml-4" alignItems="center">
|
||||
|
||||
@@ -10,7 +10,7 @@ export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, 'color'> & {
|
||||
color?: 'custom' | 'default' | 'gray' | 'primary' | 'secondary' | 'warning' | 'danger';
|
||||
variant?: 'border' | 'solid';
|
||||
isLoading?: boolean;
|
||||
size?: 'sm' | 'md' | 'xs';
|
||||
size?: 'xs' | 'sm' | 'md';
|
||||
justify?: 'start' | 'center';
|
||||
type?: 'button' | 'submit';
|
||||
forDropdown?: boolean;
|
||||
|
||||
@@ -312,8 +312,8 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
|
||||
handleClose();
|
||||
}
|
||||
setSelectedIndex(null);
|
||||
if (i.type !== 'separator') {
|
||||
i.onSelect?.();
|
||||
if (i.type !== 'separator' && typeof i.onSelect === 'function') {
|
||||
i.onSelect();
|
||||
}
|
||||
},
|
||||
[handleClose],
|
||||
@@ -506,7 +506,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
|
||||
return (
|
||||
<Button
|
||||
ref={initRef}
|
||||
size="xs"
|
||||
size="sm"
|
||||
tabIndex={-1}
|
||||
onMouseEnter={(e) => e.currentTarget.focus()}
|
||||
onMouseLeave={(e) => e.currentTarget.blur()}
|
||||
@@ -518,6 +518,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
|
||||
rightSlot={rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>}
|
||||
className={classNames(
|
||||
className,
|
||||
'h-xs', // More compact
|
||||
'min-w-[8rem] outline-none px-2 mx-1.5 flex text-sm text-gray-700 whitespace-nowrap',
|
||||
'focus:bg-highlight focus:text-gray-800 rounded',
|
||||
item.variant === 'danger' && 'text-red-600',
|
||||
|
||||
@@ -19,6 +19,7 @@ const icons = {
|
||||
cake: lucide.CakeIcon,
|
||||
chat: lucide.MessageSquare,
|
||||
check: lucide.CheckIcon,
|
||||
checkCircle: lucide.CheckCircleIcon,
|
||||
chevronDown: lucide.ChevronDownIcon,
|
||||
chevronRight: lucide.ChevronRightIcon,
|
||||
code: lucide.CodeIcon,
|
||||
@@ -41,6 +42,7 @@ const icons = {
|
||||
magicWand: lucide.Wand2Icon,
|
||||
minus: lucide.MinusIcon,
|
||||
moreVertical: lucide.MoreVerticalIcon,
|
||||
paste: lucide.ClipboardPasteIcon,
|
||||
pencil: lucide.PencilIcon,
|
||||
plug: lucide.Plug,
|
||||
plus: lucide.PlusIcon,
|
||||
|
||||
@@ -392,11 +392,15 @@ function PairEditorRow({
|
||||
className="font-mono text-xs"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
const file = await open({
|
||||
const selected = await open({
|
||||
title: 'Select file',
|
||||
multiple: false,
|
||||
});
|
||||
handleChangeValueFile((Array.isArray(file) ? file[0] : file) ?? '');
|
||||
if (selected == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleChangeValueFile(selected.path);
|
||||
}}
|
||||
>
|
||||
{getFileName(pairContainer.pair.value) || 'Select File'}
|
||||
@@ -491,7 +495,8 @@ const newPairContainer = (initialPair?: Pair): PairContainer => {
|
||||
return { id, pair };
|
||||
};
|
||||
|
||||
const getFileName = (path: string): string => {
|
||||
const parts = path.split(/[\\/]/);
|
||||
const getFileName = (path: string | null | undefined): string => {
|
||||
console.log('PATH', path);
|
||||
const parts = String(path).split(/[\\/]/);
|
||||
return parts[parts.length - 1] ?? '';
|
||||
};
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
import classNames from 'classnames';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { ReactNode } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import { useKey } from 'react-use';
|
||||
import { Heading } from './Heading';
|
||||
import { IconButton } from './IconButton';
|
||||
import type { IconProps } from './Icon';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export interface ToastProps {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: ReactNode;
|
||||
className?: string;
|
||||
timeout: number;
|
||||
variant?: 'copied' | 'success' | 'info' | 'warning' | 'error';
|
||||
}
|
||||
|
||||
export function Toast({ children, className, open, onClose, title, timeout }: ToastProps) {
|
||||
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
|
||||
const ICONS: Record<NonNullable<ToastProps['variant']>, IconProps['icon']> = {
|
||||
copied: 'copyCheck',
|
||||
warning: 'alert',
|
||||
error: 'alert',
|
||||
info: 'info',
|
||||
success: 'checkCircle',
|
||||
};
|
||||
|
||||
export function Toast({
|
||||
children,
|
||||
className,
|
||||
open,
|
||||
onClose,
|
||||
timeout,
|
||||
variant = 'info',
|
||||
}: ToastProps) {
|
||||
useKey(
|
||||
'Escape',
|
||||
() => {
|
||||
@@ -46,13 +60,16 @@ export function Toast({ children, className, open, onClose, title, timeout }: To
|
||||
'text-gray-700',
|
||||
)}
|
||||
>
|
||||
<div className="px-3 py-2">
|
||||
{title && (
|
||||
<Heading size={3} id={titleId}>
|
||||
{title}
|
||||
</Heading>
|
||||
)}
|
||||
|
||||
<div className="px-3 py-2 flex items-center gap-2">
|
||||
<Icon
|
||||
icon={ICONS[variant]}
|
||||
className={classNames(
|
||||
variant === 'success' && 'text-green-500',
|
||||
variant === 'warning' && 'text-orange-500',
|
||||
variant === 'error' && 'text-red-500',
|
||||
variant === 'copied' && 'text-violet-500',
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">{children}</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user