mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-21 08:59:07 +01:00
A bunch more small things
This commit is contained in:
141
src-web/App.tsx
141
src-web/App.tsx
@@ -1,26 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
import { useEffect } from 'react';
|
||||
import Editor from './components/Editor/Editor';
|
||||
import { HStack, VStack } from './components/Stacks';
|
||||
import { Dropdown } from './components/Dropdown';
|
||||
import { WindowDragRegion } from './components/WindowDragRegion';
|
||||
import { IconButton } from './components/IconButton';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { UrlBar } from './components/UrlBar';
|
||||
import { Grid } from './components/Grid';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useRequests } from './hooks/useWorkspaces';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
interface Response {
|
||||
url: string;
|
||||
method: string;
|
||||
body: string;
|
||||
status: string;
|
||||
elapsed: number;
|
||||
elapsed2: number;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
import { useRequests, useRequestUpdate, useSendRequest } from './hooks/useRequest';
|
||||
import { ResponsePane } from './components/ResponsePane';
|
||||
|
||||
type Params = {
|
||||
workspaceId: string;
|
||||
@@ -30,57 +17,26 @@ type Params = {
|
||||
function App() {
|
||||
const p = useParams<Params>();
|
||||
const workspaceId = p.workspaceId ?? '';
|
||||
const requestId = p.requestId;
|
||||
const { data: requests } = useRequests(workspaceId);
|
||||
const request = requests?.find((r) => r.id === requestId);
|
||||
const request = requests?.find((r) => r.id === p.requestId);
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [response, setResponse] = useState<Response | null>(null);
|
||||
const [url, setUrl] = useState<string>(request?.url ?? '');
|
||||
const [body, setBody] = useState<string>(request?.body ?? '');
|
||||
const [method, setMethod] = useState<{ label: string; value: string }>({
|
||||
label: request?.method ?? 'GET',
|
||||
value: request?.method ?? 'GET',
|
||||
});
|
||||
const updateRequest = useRequestUpdate(request ?? null);
|
||||
const sendRequest = useSendRequest(request ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = async (e: KeyboardEvent) => {
|
||||
if (e.metaKey && (e.key === 'Enter' || e.key === 'r')) {
|
||||
await sendRequest();
|
||||
await sendRequest.mutate();
|
||||
}
|
||||
};
|
||||
document.documentElement.addEventListener('keypress', listener);
|
||||
return () => document.documentElement.removeEventListener('keypress', listener);
|
||||
}, []);
|
||||
|
||||
async function sendRequest() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const resp = (await invoke('send_request', {
|
||||
method: method.value,
|
||||
url,
|
||||
body: body || undefined,
|
||||
})) as Response;
|
||||
if (resp.body.includes('<head>')) {
|
||||
resp.body = resp.body.replace(/<head>/gi, `<head><base href="${resp.url}"/>`);
|
||||
}
|
||||
setResponse(resp);
|
||||
} catch (err) {
|
||||
setError(`${err}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const contentType = response?.headers['content-type']?.split(';')[0] ?? 'text/plain';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-[auto_1fr] h-full text-gray-900">
|
||||
<Sidebar requests={requests ?? []} workspaceId={workspaceId} requestId={requestId} />
|
||||
<div className="grid grid-cols-[auto_1fr] h-full text-gray-900">
|
||||
<Sidebar requests={requests ?? []} workspaceId={workspaceId} activeRequestId={request?.id} />
|
||||
{request && (
|
||||
<Grid cols={2}>
|
||||
<VStack className="w-full">
|
||||
<HStack as={WindowDragRegion} items="center" className="pl-3 pr-1.5">
|
||||
@@ -88,71 +44,26 @@ function App() {
|
||||
</HStack>
|
||||
<VStack className="pl-3 px-1.5 py-3" space={3}>
|
||||
<UrlBar
|
||||
method={method}
|
||||
url={url}
|
||||
loading={loading}
|
||||
onMethodChange={setMethod}
|
||||
onUrlChange={setUrl}
|
||||
sendRequest={sendRequest}
|
||||
key={request.id}
|
||||
method={request.method}
|
||||
url={request.url}
|
||||
loading={sendRequest.isLoading}
|
||||
onMethodChange={(method) => updateRequest.mutate({ method })}
|
||||
onUrlChange={(url) => updateRequest.mutate({ url })}
|
||||
sendRequest={sendRequest.mutate}
|
||||
/>
|
||||
<Editor
|
||||
key={request.id}
|
||||
defaultValue={request.body}
|
||||
contentType="application/json"
|
||||
onChange={(body) => updateRequest.mutate({ body })}
|
||||
/>
|
||||
<Editor initialValue={body} contentType="application/json" onChange={setBody} />
|
||||
</VStack>
|
||||
</VStack>
|
||||
<VStack className="w-full">
|
||||
<HStack as={WindowDragRegion} items="center" className="pl-1.5 pr-1">
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: 'Clear Response',
|
||||
onSelect: () => setResponse(null),
|
||||
disabled: !response,
|
||||
},
|
||||
{
|
||||
label: 'Other Thing',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<IconButton icon="gear" className="ml-auto" size="sm" />
|
||||
</Dropdown>
|
||||
</HStack>
|
||||
{(response || error) && (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
initial={{ opacity: 0 }}
|
||||
className="w-full h-full"
|
||||
>
|
||||
<VStack className="pr-3 pl-1.5 py-3" space={3}>
|
||||
{error && <div className="text-white bg-red-500 px-3 py-1 rounded">{error}</div>}
|
||||
{response && (
|
||||
<>
|
||||
<HStack
|
||||
items="center"
|
||||
className="italic text-gray-500 text-sm w-full pointer-events-none h-10 mb-3 flex-shrink-0"
|
||||
>
|
||||
{response.status}
|
||||
•
|
||||
{response.elapsed}ms •
|
||||
{response.elapsed2}ms
|
||||
</HStack>
|
||||
{contentType.includes('html') ? (
|
||||
<iframe
|
||||
title="Response preview"
|
||||
srcDoc={response.body}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
className="h-full w-full rounded-lg"
|
||||
/>
|
||||
) : response?.body ? (
|
||||
<Editor value={response?.body} contentType={contentType} />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</motion.div>
|
||||
)}
|
||||
</VStack>
|
||||
<ResponsePane requestId={request.id} error={sendRequest.error} />
|
||||
</Grid>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { ButtonHTMLAttributes, ComponentPropsWithoutRef, ElementType } from 'react';
|
||||
import {
|
||||
ButtonHTMLAttributes,
|
||||
ComponentPropsWithoutRef,
|
||||
ElementType,
|
||||
ForwardedRef,
|
||||
forwardRef,
|
||||
} from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
@@ -11,19 +17,23 @@ export interface ButtonProps<T extends ElementType>
|
||||
as?: T;
|
||||
}
|
||||
|
||||
export function Button<T extends ElementType>({
|
||||
className,
|
||||
as,
|
||||
justify = 'center',
|
||||
children,
|
||||
size = 'md',
|
||||
forDropdown,
|
||||
color,
|
||||
...props
|
||||
}: ButtonProps<T> & Omit<ComponentPropsWithoutRef<T>, keyof ButtonProps<T>>) {
|
||||
export const Button = forwardRef(function Button<T extends ElementType>(
|
||||
{
|
||||
className,
|
||||
as,
|
||||
justify = 'center',
|
||||
children,
|
||||
size = 'md',
|
||||
forDropdown,
|
||||
color,
|
||||
...props
|
||||
}: ButtonProps<T> & Omit<ComponentPropsWithoutRef<T>, keyof ButtonProps<T>>,
|
||||
ref: ForwardedRef<HTMLButtonElement>,
|
||||
) {
|
||||
const Component = as || 'button';
|
||||
return (
|
||||
<Component
|
||||
ref={ref}
|
||||
className={classnames(
|
||||
className,
|
||||
'rounded-md flex items-center',
|
||||
@@ -43,4 +53,4 @@ export function Button<T extends ElementType>({
|
||||
{forDropdown && <Icon icon="triangle-down" className="ml-1 -mr-1" />}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -133,39 +133,39 @@ function DropdownMenuItem({
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuCheckboxItemProps = DropdownMenu.DropdownMenuCheckboxItemProps & ItemInnerProps;
|
||||
// type DropdownMenuCheckboxItemProps = DropdownMenu.DropdownMenuCheckboxItemProps & ItemInnerProps;
|
||||
//
|
||||
// function DropdownMenuCheckboxItem({
|
||||
// leftSlot,
|
||||
// rightSlot,
|
||||
// children,
|
||||
// ...props
|
||||
// }: DropdownMenuCheckboxItemProps) {
|
||||
// return (
|
||||
// <DropdownMenu.CheckboxItem asChild {...props}>
|
||||
// <ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
|
||||
// {children}
|
||||
// </ItemInner>
|
||||
// </DropdownMenu.CheckboxItem>
|
||||
// );
|
||||
// }
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
leftSlot,
|
||||
rightSlot,
|
||||
children,
|
||||
...props
|
||||
}: DropdownMenuCheckboxItemProps) {
|
||||
return (
|
||||
<DropdownMenu.CheckboxItem asChild {...props}>
|
||||
<ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
|
||||
{children}
|
||||
</ItemInner>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuSubTriggerProps = DropdownMenu.DropdownMenuSubTriggerProps & ItemInnerProps;
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
leftSlot,
|
||||
rightSlot,
|
||||
children,
|
||||
...props
|
||||
}: DropdownMenuSubTriggerProps) {
|
||||
return (
|
||||
<DropdownMenu.SubTrigger asChild {...props}>
|
||||
<ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
|
||||
{children}
|
||||
</ItemInner>
|
||||
</DropdownMenu.SubTrigger>
|
||||
);
|
||||
}
|
||||
// type DropdownMenuSubTriggerProps = DropdownMenu.DropdownMenuSubTriggerProps & ItemInnerProps;
|
||||
//
|
||||
// function DropdownMenuSubTrigger({
|
||||
// leftSlot,
|
||||
// rightSlot,
|
||||
// children,
|
||||
// ...props
|
||||
// }: DropdownMenuSubTriggerProps) {
|
||||
// return (
|
||||
// <DropdownMenu.SubTrigger asChild {...props}>
|
||||
// <ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
|
||||
// {children}
|
||||
// </ItemInner>
|
||||
// </DropdownMenu.SubTrigger>
|
||||
// );
|
||||
// }
|
||||
|
||||
type DropdownMenuRadioItemProps = Omit<
|
||||
DropdownMenu.DropdownMenuRadioItemProps & ItemInnerProps,
|
||||
@@ -189,22 +189,22 @@ function DropdownMenuRadioItem({ rightSlot, children, ...props }: DropdownMenuRa
|
||||
);
|
||||
}
|
||||
|
||||
const DropdownMenuSubContent = forwardRef<HTMLDivElement, DropdownMenu.DropdownMenuSubContentProps>(
|
||||
function DropdownMenuSubContent(
|
||||
{ className, ...props }: DropdownMenu.DropdownMenuSubContentProps,
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<DropdownMenu.SubContent
|
||||
ref={ref}
|
||||
alignOffset={0}
|
||||
sideOffset={4}
|
||||
className={classnames(className, dropdownMenuClasses)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
// const DropdownMenuSubContent = forwardRef<HTMLDivElement, DropdownMenu.DropdownMenuSubContentProps>(
|
||||
// function DropdownMenuSubContent(
|
||||
// { className, ...props }: DropdownMenu.DropdownMenuSubContentProps,
|
||||
// ref,
|
||||
// ) {
|
||||
// return (
|
||||
// <DropdownMenu.SubContent
|
||||
// ref={ref}
|
||||
// alignOffset={0}
|
||||
// sideOffset={4}
|
||||
// className={classnames(className, dropdownMenuClasses)}
|
||||
// {...props}
|
||||
// />
|
||||
// );
|
||||
// },
|
||||
// );
|
||||
|
||||
function DropdownMenuLabel({ className, children, ...props }: DropdownMenu.DropdownMenuLabelProps) {
|
||||
return (
|
||||
@@ -216,14 +216,14 @@ function DropdownMenuLabel({ className, children, ...props }: DropdownMenu.Dropd
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({ className, ...props }: DropdownMenu.DropdownMenuSeparatorProps) {
|
||||
return (
|
||||
<DropdownMenu.Separator
|
||||
className={classnames(className, 'h-[1px] bg-gray-400 bg-opacity-30 my-1')}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// function DropdownMenuSeparator({ className, ...props }: DropdownMenu.DropdownMenuSeparatorProps) {
|
||||
// return (
|
||||
// <DropdownMenu.Separator
|
||||
// className={classnames(className, 'h-[1px] bg-gray-400 bg-opacity-30 my-1')}
|
||||
// {...props}
|
||||
// />
|
||||
// );
|
||||
// }
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
children,
|
||||
@@ -236,7 +236,7 @@ function DropdownMenuTrigger({
|
||||
className={classnames(className, 'focus:outline-none')}
|
||||
{...props}
|
||||
>
|
||||
<>{children}</>
|
||||
{children}
|
||||
</DropdownMenu.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,7 +89,12 @@
|
||||
|
||||
.cm-editor .cm-activeLineGutter,
|
||||
.cm-editor .cm-activeLine {
|
||||
background-color: hsl(var(--color-gray-50));
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.cm-editor.cm-focused .cm-activeLineGutter,
|
||||
.cm-editor.cm-focused .cm-activeLine {
|
||||
background-color: hsl(var(--color-gray-100)/0.3);
|
||||
}
|
||||
|
||||
.cm-editor * {
|
||||
@@ -101,9 +106,9 @@
|
||||
}
|
||||
|
||||
.cm-editor .cm-selectionBackground {
|
||||
background-color: hsl(var(--color-gray-100));
|
||||
background-color: hsl(var(--color-gray-200));
|
||||
}
|
||||
|
||||
.cm-editor.cm-focused .cm-selectionBackground {
|
||||
background-color: hsl(var(--color-gray-100));
|
||||
background-color: hsl(var(--color-gray-200));
|
||||
}
|
||||
|
||||
@@ -1,14 +1,42 @@
|
||||
import useCodeMirror from '../../hooks/useCodemirror';
|
||||
import './Editor.css';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { EditorView } from 'codemirror';
|
||||
import { baseExtensions, syntaxExtension } from './extensions';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
|
||||
interface Props {
|
||||
contentType: string;
|
||||
initialValue?: string;
|
||||
value?: string;
|
||||
defaultValue?: string | null;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function Editor(props: Props) {
|
||||
const { ref } = useCodeMirror(props);
|
||||
export default function Editor({ contentType, defaultValue, onChange }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const extensions = useMemo(() => {
|
||||
const ext = syntaxExtension(contentType);
|
||||
return [
|
||||
...baseExtensions,
|
||||
...(ext ? [ext] : []),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (typeof onChange === 'function') {
|
||||
onChange(update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
];
|
||||
}, [contentType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current === null) return;
|
||||
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: defaultValue ?? '',
|
||||
extensions: extensions,
|
||||
}),
|
||||
parent: ref.current,
|
||||
});
|
||||
return () => view?.destroy();
|
||||
}, [ref.current]);
|
||||
|
||||
return <div ref={ref} className="cm-wrapper" />;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { EditorView } from 'codemirror';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { tags } from '@lezer/highlight';
|
||||
import {
|
||||
bracketMatching,
|
||||
defaultHighlightStyle,
|
||||
@@ -35,18 +29,23 @@ import {
|
||||
} from '@codemirror/autocomplete';
|
||||
import { lintKeymap } from '@codemirror/lint';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { tags } from '@lezer/highlight';
|
||||
|
||||
const myHighlightStyle = HighlightStyle.define([
|
||||
export const myHighlightStyle = HighlightStyle.define([
|
||||
{
|
||||
tag: [tags.documentMeta, tags.blockComment, tags.lineComment, tags.docComment, tags.comment],
|
||||
color: '#757b93',
|
||||
},
|
||||
{ tag: tags.name, color: '#4699de' },
|
||||
{ tag: tags.variableName, color: '#31c434' },
|
||||
{ tag: tags.attributeName, color: '#b06fff' },
|
||||
{ tag: tags.bool, color: '#e864f6' },
|
||||
{ tag: tags.attributeName, color: '#8f68ff' },
|
||||
{ tag: tags.attributeValue, color: '#ff964b' },
|
||||
{ tag: [tags.keyword, tags.string], color: '#e8b045' },
|
||||
{ tag: tags.comment, color: '#f5d', fontStyle: 'italic' },
|
||||
{ tag: tags.comment, color: '#cec4cc', fontStyle: 'italic' },
|
||||
]);
|
||||
|
||||
const syntaxExtensions: Record<string, LanguageSupport> = {
|
||||
@@ -55,7 +54,11 @@ const syntaxExtensions: Record<string, LanguageSupport> = {
|
||||
'text/html': html(),
|
||||
};
|
||||
|
||||
const extensions = [
|
||||
export function syntaxExtension(contentType: string): LanguageSupport | undefined {
|
||||
return syntaxExtensions[contentType];
|
||||
}
|
||||
|
||||
export const baseExtensions = [
|
||||
lineNumbers(),
|
||||
highlightActiveLineGutter(),
|
||||
highlightSpecialChars(),
|
||||
@@ -94,64 +97,3 @@ const extensions = [
|
||||
]),
|
||||
syntaxHighlighting(myHighlightStyle),
|
||||
];
|
||||
|
||||
export default function useCodeMirror({
|
||||
initialValue,
|
||||
value,
|
||||
contentType,
|
||||
onChange,
|
||||
}: {
|
||||
initialValue?: string;
|
||||
value?: string;
|
||||
contentType: string;
|
||||
onChange?: (value: string) => void;
|
||||
}) {
|
||||
const [cm, setCm] = useState<EditorView | null>(null);
|
||||
const ref = useRef(null);
|
||||
useEffect(() => {
|
||||
if (ref.current === null) return;
|
||||
const state = EditorState.create({
|
||||
doc: initialValue,
|
||||
extensions: getExtensions({ contentType, onChange }),
|
||||
});
|
||||
const view = new EditorView({
|
||||
state,
|
||||
parent: ref.current,
|
||||
});
|
||||
|
||||
setCm(view);
|
||||
|
||||
return () => view?.destroy();
|
||||
}, [ref.current]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cm === null) return;
|
||||
|
||||
const newState = EditorState.create({
|
||||
doc: value ?? cm.state.doc,
|
||||
extensions: getExtensions({ contentType, onChange }),
|
||||
});
|
||||
cm.setState(newState);
|
||||
}, [cm, contentType, value, onChange]);
|
||||
|
||||
return { ref, cm };
|
||||
}
|
||||
|
||||
function getExtensions({
|
||||
contentType,
|
||||
onChange,
|
||||
}: {
|
||||
contentType: string;
|
||||
onChange?: (value: string) => void;
|
||||
}) {
|
||||
const ext = syntaxExtensions[contentType];
|
||||
return ext
|
||||
? [
|
||||
...extensions,
|
||||
...(onChange
|
||||
? [EditorView.updateListener.of((update) => onChange(update.state.doc.toString()))]
|
||||
: []),
|
||||
ext,
|
||||
]
|
||||
: extensions;
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { Icon, IconProps } from './Icon';
|
||||
import { Button, ButtonProps } from './Button';
|
||||
|
||||
type Props = Omit<IconProps, 'size'> & ButtonProps<typeof Button>;
|
||||
|
||||
export function IconButton({ icon, spin, ...props }: Props) {
|
||||
export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
|
||||
{ icon, spin, ...props }: Props,
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<Button className="group" {...props}>
|
||||
<Button ref={ref} className="group" {...props}>
|
||||
<Icon icon={icon} spin={spin} className="text-gray-700 group-hover:text-gray-900" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import {Outlet} from 'react-router-dom';
|
||||
|
||||
|
||||
export function Layout() {
|
||||
return (
|
||||
89
src-web/components/ResponsePane.tsx
Normal file
89
src-web/components/ResponsePane.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useDeleteAllResponses, useDeleteResponse, useResponses } from '../hooks/useResponses';
|
||||
import { motion } from 'framer-motion';
|
||||
import { HStack, VStack } from './Stacks';
|
||||
import Editor from './Editor/Editor';
|
||||
import { useMemo } from 'react';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
import { Dropdown } from './Dropdown';
|
||||
import { IconButton } from './IconButton';
|
||||
|
||||
interface Props {
|
||||
requestId: string;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function ResponsePane({ requestId, error }: Props) {
|
||||
const responses = useResponses(requestId);
|
||||
const response = responses.data[0];
|
||||
const deleteResponse = useDeleteResponse(response);
|
||||
const deleteAllResponses = useDeleteAllResponses(response?.requestId);
|
||||
|
||||
const contentType = useMemo(
|
||||
() =>
|
||||
response?.headers.find((h) => h.name.toLowerCase() === 'content-type')?.value ?? 'text/plain',
|
||||
[response],
|
||||
);
|
||||
|
||||
const contentForIframe: string = useMemo(() => {
|
||||
if (response == null) return '';
|
||||
if (response.body.includes('<head>')) {
|
||||
return response.body.replace(/<head>/gi, `<head><base href="${response.url}"/>`);
|
||||
}
|
||||
return response.body;
|
||||
}, [response?.id]);
|
||||
|
||||
return (
|
||||
<VStack className="w-full">
|
||||
<HStack as={WindowDragRegion} items="center" className="pl-1.5 pr-1">
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: 'Clear Response',
|
||||
onSelect: deleteResponse.mutate,
|
||||
disabled: responses.data.length === 0,
|
||||
},
|
||||
{
|
||||
label: 'Clear All Responses',
|
||||
onSelect: deleteAllResponses.mutate,
|
||||
disabled: responses.data.length === 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<IconButton icon="gear" className="ml-auto" size="sm" />
|
||||
</Dropdown>
|
||||
</HStack>
|
||||
<motion.div animate={{ opacity: 1 }} initial={{ opacity: 0 }} className="w-full h-full">
|
||||
<VStack className="pr-3 pl-1.5 py-3" space={3}>
|
||||
{error && <div className="text-white bg-red-500 px-3 py-1 rounded">{error}</div>}
|
||||
{response && (
|
||||
<>
|
||||
<HStack
|
||||
items="center"
|
||||
className="italic text-gray-500 text-sm w-full pointer-events-none h-10 mb-3 flex-shrink-0"
|
||||
>
|
||||
{response.status}
|
||||
{response.statusReason && ` ${response.statusReason}`}
|
||||
•
|
||||
{response.elapsed}ms
|
||||
</HStack>
|
||||
{contentType.includes('html') ? (
|
||||
<iframe
|
||||
title="Response preview"
|
||||
srcDoc={contentForIframe}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
className="h-full w-full rounded-lg"
|
||||
/>
|
||||
) : response?.body ? (
|
||||
<Editor
|
||||
key={response.body}
|
||||
defaultValue={response?.body}
|
||||
contentType={contentType}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</motion.div>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -5,17 +5,18 @@ import { Button } from './Button';
|
||||
import useTheme from '../hooks/useTheme';
|
||||
import { HStack, VStack } from './Stacks';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
import { Request } from '../hooks/useWorkspaces';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import { HttpRequest } from '../lib/models';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useRequestCreate } from '../hooks/useRequest';
|
||||
|
||||
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
|
||||
workspaceId: string;
|
||||
requests: Request[];
|
||||
requestId?: string;
|
||||
requests: HttpRequest[];
|
||||
activeRequestId?: string;
|
||||
}
|
||||
|
||||
export function Sidebar({ className, requestId, workspaceId, requests, ...props }: Props) {
|
||||
export function Sidebar({ className, activeRequestId, workspaceId, requests, ...props }: Props) {
|
||||
const createRequest = useRequestCreate(workspaceId);
|
||||
const { toggleTheme } = useTheme();
|
||||
return (
|
||||
<div
|
||||
@@ -27,31 +28,30 @@ export function Sidebar({ className, requestId, workspaceId, requests, ...props
|
||||
<IconButton
|
||||
size="sm"
|
||||
icon="camera"
|
||||
onClick={async () => {
|
||||
const req = await invoke('upsert_request', {
|
||||
workspaceId,
|
||||
id: null,
|
||||
name: 'Test Request',
|
||||
});
|
||||
console.log('UPSERTED', req);
|
||||
}}
|
||||
onClick={() => createRequest.mutate({ name: 'Test Request' })}
|
||||
/>
|
||||
</HStack>
|
||||
<VStack as="ul" className="py-2" space={1}>
|
||||
{requests.map((r) => (
|
||||
<li key={r.id} className="mx-2">
|
||||
<Button
|
||||
as={Link}
|
||||
to={`/workspaces/${workspaceId}/requests/${r.id}`}
|
||||
className={classnames('w-full', requestId === r.id && 'bg-gray-50')}
|
||||
size="sm"
|
||||
justify="start"
|
||||
>
|
||||
{r.name}
|
||||
</Button>
|
||||
</li>
|
||||
<SidebarItem key={r.id} request={r} active={r.id === activeRequestId} />
|
||||
))}
|
||||
</VStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarItem({ request, active }: { request: HttpRequest; active: boolean }) {
|
||||
return (
|
||||
<li key={request.id} className="mx-2">
|
||||
<Button
|
||||
as={Link}
|
||||
to={`/workspaces/${request.workspaceId}/requests/${request.id}`}
|
||||
className={classnames('w-full', active && 'bg-gray-50')}
|
||||
size="sm"
|
||||
justify="start"
|
||||
>
|
||||
{request.name}
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ import { IconButton } from './IconButton';
|
||||
interface Props {
|
||||
sendRequest: () => void;
|
||||
loading: boolean;
|
||||
method: { label: string; value: string };
|
||||
method: string;
|
||||
url: string;
|
||||
onMethodChange: (method: { label: string; value: string }) => void;
|
||||
onMethodChange: (method: string) => void;
|
||||
onUrlChange: (url: string) => void;
|
||||
}
|
||||
|
||||
@@ -27,12 +27,12 @@ export function UrlBar({ sendRequest, loading, onMethodChange, method, onUrlChan
|
||||
label="Enter URL"
|
||||
className="font-mono"
|
||||
onChange={(e) => onUrlChange(e.currentTarget.value)}
|
||||
value={url}
|
||||
defaultValue={url}
|
||||
placeholder="Enter a URL..."
|
||||
leftSlot={
|
||||
<DropdownMenuRadio
|
||||
onValueChange={onMethodChange}
|
||||
value={method.value}
|
||||
onValueChange={(v) => onMethodChange(v.value)}
|
||||
value={method.toUpperCase()}
|
||||
items={[
|
||||
{ label: 'GET', value: 'GET' },
|
||||
{ label: 'PUT', value: 'PUT' },
|
||||
@@ -43,8 +43,8 @@ export function UrlBar({ sendRequest, loading, onMethodChange, method, onUrlChan
|
||||
{ label: 'HEAD', value: 'HEAD' },
|
||||
]}
|
||||
>
|
||||
<Button disabled={loading} size="sm" className="ml-1" justify="start">
|
||||
{method.label}
|
||||
<Button type="button" disabled={loading} size="sm" className="ml-1" justify="start">
|
||||
{method.toUpperCase()}
|
||||
</Button>
|
||||
</DropdownMenuRadio>
|
||||
}
|
||||
|
||||
64
src-web/hooks/useRequest.ts
Normal file
64
src-web/hooks/useRequest.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import { convertDates, HttpRequest } from '../lib/models';
|
||||
import { responsesQueryKey } from './useResponses';
|
||||
|
||||
export function useRequests(workspaceId: string) {
|
||||
return useQuery(['requests'], async () => {
|
||||
const requests = (await invoke('requests', { workspaceId })) as HttpRequest[];
|
||||
return requests.map(convertDates);
|
||||
});
|
||||
}
|
||||
|
||||
export function useRequestUpdate(request: HttpRequest | null) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<HttpRequest, unknown, Partial<HttpRequest>>({
|
||||
mutationFn: async (patch) => {
|
||||
if (request == null) {
|
||||
throw new Error("Can't update a null request");
|
||||
}
|
||||
// console.error('UPDATE REQUEST', patch);
|
||||
const req = await invoke('upsert_request', { ...request, ...patch });
|
||||
return convertDates(req as HttpRequest);
|
||||
},
|
||||
onSuccess: (req) => {
|
||||
queryClient.setQueryData(['requests'], (requests: HttpRequest[] = []) =>
|
||||
requests.map((r) => (r.id === req.id ? req : r)),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRequestCreate(workspaceId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<HttpRequest, unknown, Partial<Omit<HttpRequest, 'workspaceId'>>>({
|
||||
mutationFn: async (patch) => {
|
||||
const req = await invoke('upsert_request', {
|
||||
url: '',
|
||||
method: 'GET',
|
||||
name: 'New Request',
|
||||
headers: [],
|
||||
...patch,
|
||||
workspaceId,
|
||||
});
|
||||
return convertDates(req as HttpRequest);
|
||||
},
|
||||
onSuccess: (req) => {
|
||||
queryClient.setQueryData(['requests'], (requests: HttpRequest[] = []) => [...requests, req]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendRequest(request: HttpRequest | null) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, string>({
|
||||
mutationFn: async () => {
|
||||
if (request == null) return;
|
||||
await invoke('send_request', { requestId: request.id });
|
||||
},
|
||||
onSuccess: async () => {
|
||||
if (request == null) return;
|
||||
await queryClient.invalidateQueries(responsesQueryKey(request.id));
|
||||
},
|
||||
});
|
||||
}
|
||||
49
src-web/hooks/useResponses.ts
Normal file
49
src-web/hooks/useResponses.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import { convertDates, HttpResponse } from '../lib/models';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
export function responsesQueryKey(requestId: string) {
|
||||
return ['responses', { requestId }];
|
||||
}
|
||||
|
||||
export function useResponses(requestId: string) {
|
||||
return useQuery<HttpResponse[]>({
|
||||
initialData: [],
|
||||
queryKey: responsesQueryKey(requestId),
|
||||
queryFn: async () => {
|
||||
const responses = (await invoke('responses', { requestId })) as HttpResponse[];
|
||||
return responses.map(convertDates);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteResponse(response?: HttpResponse) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
if (response == null) return;
|
||||
await invoke('delete_response', { id: response.id });
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (response == null) return;
|
||||
queryClient.setQueryData(
|
||||
['responses', { requestId: response.requestId }],
|
||||
(responses: HttpResponse[] = []) => responses.filter((r) => r.id !== response.id),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAllResponses(requestId?: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
if (requestId == null) return;
|
||||
await invoke('delete_all_responses', { requestId });
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (requestId == null) return;
|
||||
queryClient.setQueryData(['responses', { requestId: requestId }], []);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,61 +1,10 @@
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import { convertDates, Workspace } from '../lib/models';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
interface BaseModel {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface Request extends BaseModel {
|
||||
name: string;
|
||||
url: string;
|
||||
body: string | null;
|
||||
method: string;
|
||||
}
|
||||
|
||||
export interface Workspace extends BaseModel {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function useWorkspaces(): UseQueryResult<Workspace[]> {
|
||||
return useQuery('workspaces', async () => {
|
||||
export function useWorkspaces() {
|
||||
return useQuery(['workspaces'], async () => {
|
||||
const workspaces = (await invoke('workspaces')) as Workspace[];
|
||||
return workspaces.map(convertDates);
|
||||
});
|
||||
}
|
||||
|
||||
export function useRequests(workspaceId: string): UseQueryResult<Request[]> {
|
||||
return useQuery('requests', async () => {
|
||||
const requests = (await invoke('requests', { workspaceId })) as Request[];
|
||||
return requests.map(convertDates);
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkspace(): UseQueryResult<{ workspace: Workspace; requests: Request[] }> {
|
||||
return useQuery('workspace', async () => {
|
||||
const workspaces = (await invoke('workspaces')) as Workspace[];
|
||||
const requests = (await invoke('requests', { workspaceId: workspaces[0].id })) as Request[];
|
||||
return {
|
||||
workspace: convertDates(workspaces[0]),
|
||||
requests: requests.map(convertDates),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function convertDates<T extends BaseModel>(m: T): T {
|
||||
return {
|
||||
...m,
|
||||
createdAt: convertDate(m.createdAt),
|
||||
updatedAt: convertDate(m.updatedAt),
|
||||
deletedAt: m.deletedAt ? convertDate(m.deletedAt) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function convertDate(d: string | Date): Date {
|
||||
const date = new Date(d);
|
||||
const userTimezoneOffset = date.getTimezoneOffset() * 60000;
|
||||
return new Date(date.getTime() - userTimezoneOffset);
|
||||
}
|
||||
|
||||
51
src-web/lib/models.ts
Normal file
51
src-web/lib/models.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface BaseModel {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface Workspace extends BaseModel {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface HttpHeader {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface HttpRequest extends BaseModel {
|
||||
name: string;
|
||||
url: string;
|
||||
body: string | null;
|
||||
method: string;
|
||||
headers: HttpHeader[];
|
||||
}
|
||||
|
||||
export interface HttpResponse extends BaseModel {
|
||||
id: string;
|
||||
requestId: string;
|
||||
body: string;
|
||||
status: string;
|
||||
elapsed: number;
|
||||
statusReason: string;
|
||||
url: string;
|
||||
headers: HttpHeader[];
|
||||
}
|
||||
|
||||
export function convertDates<T extends BaseModel>(m: T): T {
|
||||
return {
|
||||
...m,
|
||||
createdAt: convertDate(m.createdAt),
|
||||
updatedAt: convertDate(m.updatedAt),
|
||||
deletedAt: m.deletedAt ? convertDate(m.deletedAt) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function convertDate(d: string | Date): Date {
|
||||
const date = new Date(d);
|
||||
const userTimezoneOffset = date.getTimezoneOffset() * 60000;
|
||||
return new Date(date.getTime() - userTimezoneOffset);
|
||||
}
|
||||
@@ -6,9 +6,9 @@ import { HelmetProvider } from 'react-helmet-async';
|
||||
import { MotionConfig } from 'framer-motion';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import { setTheme } from './lib/theme';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||
import { Layout } from './pages/Layout';
|
||||
import { Layout } from './components/Layout';
|
||||
import { Workspaces } from './pages/Workspaces';
|
||||
import './main.css';
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useWorkspaces } from '../hooks/useWorkspaces';
|
||||
import { Button } from '../components/Button';
|
||||
|
||||
export function Workspaces() {
|
||||
const workspaces = useWorkspaces();
|
||||
return (
|
||||
<ul className="p-12">
|
||||
{workspaces.data?.map((w) => (
|
||||
<Link key={w.id} to={`/workspaces/${w.id}`}>
|
||||
<Button as={Link} key={w.id} to={`/workspaces/${w.id}`}>
|
||||
{w.name}
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user