Compare commits

...

10 Commits

Author SHA1 Message Date
Gregory Schier
bdf89ac288 Fix platform check 2023-03-14 00:15:01 -07:00
Gregory Schier
debd3c8185 Some small changes 2023-03-14 00:08:03 -07:00
Gregory Schier
f81a3ae8e7 Move stuff around 2023-03-13 23:30:14 -07:00
Gregory Schier
7d4e9894c3 Refactor hooks to be easier to use 2023-03-13 23:25:41 -07:00
Gregory Schier
4bf22d8a60 Fix header editor and scroll in general 2023-03-13 19:37:36 -07:00
Gregory Schier
8be4971a23 Lazy load routes 2023-03-13 13:56:13 -07:00
Gregory Schier
359e916b73 Back to React 2023-03-13 09:50:49 -07:00
Gregory Schier
68058f3e41 Move some stuff around 2023-03-13 09:24:38 -07:00
Gregory Schier
0c6fa3e634 Fix URL bar 2023-03-13 00:13:25 -07:00
Gregory Schier
0fa25c6335 Fix ButtonLink and edit request names 2023-03-13 00:11:23 -07:00
73 changed files with 999 additions and 1297 deletions

View File

@@ -10,8 +10,7 @@ jobs:
strategy:
fail-fast: false
matrix:
# platform: [ ubuntu-latest, macos-latest, windows-latest ]
platform: [ macos-latest ]
platform: [ ubuntu-latest, macos-latest, windows-latest ]
steps:
- uses: actions/checkout@v3
@@ -29,7 +28,7 @@ jobs:
node-version: 18
cache: 'npm'
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-20.04'
if: matrix.platform == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf

View File

@@ -4,7 +4,8 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + React + TS</title>
<title>Yaak App</title>
<!-- <script src="http://localhost:8097"></script>-->
</head>
<body>

721
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -34,23 +34,26 @@
"@radix-ui/react-tabs": "^1.0.3",
"@tanstack/react-query": "^4.24.10",
"@tauri-apps/api": "^1.2.0",
"@vitejs/plugin-react": "^3.1.0",
"classnames": "^2.3.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.8.1",
"cm6-graphql": "^0.0.4-canary-b30a2325.0",
"codemirror": "^6.0.1",
"format-graphql": "^1.4.0",
"framer-motion": "^9.0.4",
"parse-color": "^1.0.0",
"preact-router": "^4.1.0",
"react-helmet-async": "^1.3.0",
"react-use": "^17.4.0"
},
"devDependencies": {
"@preact/preset-vite": "^2.5.0",
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e",
"@tauri-apps/cli": "^1.2.2",
"@types/node": "^18.7.10",
"@types/parse-color": "^1.0.1",
"@types/parse-json": "^4.0.0",
"@types/react-dom": "^18.0.6",
"@typescript-eslint/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0",
"autoprefixer": "^10.4.13",

View File

Binary file not shown.

View File

@@ -8,7 +8,7 @@
},
"package": {
"productName": "Yaak",
"version": "0.0.1"
"version": "0.0.2"
},
"tauri": {
"windows": [

View File

@@ -1,55 +0,0 @@
import classnames from 'classnames';
import { useWindowSize } from 'react-use';
import { RequestPane } from './components/RequestPane';
import { ResponsePane } from './components/ResponsePane';
import { Sidebar } from './components/Sidebar';
import { HStack } from './components/Stacks';
import { WindowDragRegion } from './components/WindowDragRegion';
import { useRequests } from './hooks/useRequest';
type Params = {
workspaceId: string;
requestId?: string;
};
export function Workspace({ matches }: { path: string; matches?: Params }) {
const workspaceId = matches?.workspaceId ?? '';
const { data: requests } = useRequests(workspaceId);
const request = requests?.find((r) => r.id === matches?.requestId);
const { width } = useWindowSize();
const isH = width > 900;
return (
<div className="grid grid-cols-[auto_1fr] h-full text-gray-900">
<Sidebar
requests={requests ?? []}
workspaceId={workspaceId}
activeRequestId={matches?.requestId}
/>
{request && (
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
<HStack
as={WindowDragRegion}
className="px-3 bg-gray-50 text-gray-900 border-b border-b-gray-200 pt-[1px]"
alignItems="center"
>
{request.name}
</HStack>
<div
className={classnames(
'grid overflow-auto',
isH ? 'grid-cols-[1fr_1fr]' : 'grid-rows-[minmax(0,auto)_minmax(0,100%)]',
)}
>
<RequestPane
fullHeight={isH}
request={request}
className={classnames(!isH && 'pr-2 pb-0')}
/>
<ResponsePane requestId={request.id} />
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { listen } from '@tauri-apps/api/event';
import { MotionConfig } from 'framer-motion';
import { HelmetProvider } from 'react-helmet-async';
import { AppRouter } from './AppRouter';
import { requestsQueryKey } from '../hooks/useRequests';
import { responsesQueryKey } from '../hooks/useResponses';
import { DEFAULT_FONT_SIZE } from '../lib/constants';
import type { HttpRequest, HttpResponse } from '../lib/models';
import { convertDates } from '../lib/models';
const queryClient = new QueryClient();
await listen('updated_request', ({ payload: request }: { payload: HttpRequest }) => {
queryClient.setQueryData(
requestsQueryKey(request.workspaceId),
(requests: HttpRequest[] = []) => {
const newRequests = [];
let found = false;
for (const r of requests) {
if (r.id === request.id) {
found = true;
newRequests.push(convertDates(request));
} else {
newRequests.push(r);
}
}
if (!found) {
newRequests.push(convertDates(request));
}
return newRequests;
},
);
});
await listen('deleted_request', ({ payload: request }: { payload: HttpRequest }) => {
queryClient.setQueryData(requestsQueryKey(request.workspaceId), (requests: HttpRequest[] = []) =>
requests.filter((r) => r.id !== request.id),
);
});
await listen('updated_response', ({ payload: response }: { payload: HttpResponse }) => {
queryClient.setQueryData(
responsesQueryKey(response.requestId),
(responses: HttpResponse[] = []) => {
const newResponses = [];
let found = false;
for (const r of responses) {
if (r.id === response.id) {
found = true;
newResponses.push(convertDates(response));
} else {
newResponses.push(r);
}
}
if (!found) {
newResponses.push(convertDates(response));
}
return newResponses;
},
);
});
await listen('zoom', ({ payload: zoomDelta }: { payload: number }) => {
const fontSize = parseFloat(window.getComputedStyle(document.documentElement).fontSize);
let newFontSize;
if (zoomDelta === 0) {
newFontSize = DEFAULT_FONT_SIZE;
} else if (zoomDelta > 0) {
newFontSize = Math.min(fontSize * 1.1, DEFAULT_FONT_SIZE * 5);
} else if (zoomDelta < 0) {
newFontSize = Math.max(fontSize * 0.9, DEFAULT_FONT_SIZE * 0.4);
}
document.documentElement.style.fontSize = `${newFontSize}px`;
});
export function App() {
return (
<QueryClientProvider client={queryClient}>
<MotionConfig transition={{ duration: 0.1 }}>
<HelmetProvider>
<AppRouter />
</HelmetProvider>
</MotionConfig>
</QueryClientProvider>
);
}

View File

@@ -1,13 +1,35 @@
import { Router } from 'preact-router';
import { Workspaces } from '../pages/Workspaces';
import { Workspace } from '../Workspace';
import { lazy, Suspense } from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
const Workspaces = lazy(() => import('./Workspaces'));
const Workspace = lazy(() => import('./Workspace'));
const RouteError = lazy(() => import('./RouteError'));
const router = createBrowserRouter([
{
path: '/',
errorElement: <RouteError />,
children: [
{
path: '/',
element: <Workspaces />,
},
{
path: '/workspaces/:workspaceId',
element: <Workspace />,
},
{
path: '/workspaces/:workspaceId/requests/:requestId',
element: <Workspace />,
},
],
},
]);
export function AppRouter() {
return (
<Router>
<Workspaces path="/" />
<Workspace path="/workspaces/:workspaceId" />
<Workspace path="/workspaces/:workspaceId/requests/:requestId" />
</Router>
<Suspense>
<RouterProvider router={router} />
</Suspense>
);
}

View File

@@ -1,63 +0,0 @@
import classnames from 'classnames';
import type { ComponentChildren } from 'preact';
import type { ForwardedRef } from 'preact/compat';
import { forwardRef } from 'preact/compat';
import { Icon } from './Icon';
const colorStyles = {
custom: '',
default: 'text-gray-700 enabled:hover:bg-gray-700/10 enabled:hover:text-gray-1000',
gray: 'text-gray-800 bg-gray-100 enabled:hover:bg-gray-500/20 enabled:hover:text-gray-1000',
primary: 'bg-blue-400 text-white hover:bg-blue-500',
secondary: 'bg-violet-400 text-white hover:bg-violet-500',
warning: 'bg-orange-400 text-white hover:bg-orange-500',
danger: 'bg-red-400 text-white hover:bg-red-500',
};
export type ButtonProps = {
color?: keyof typeof colorStyles;
size?: 'sm' | 'md';
justify?: 'start' | 'center';
type?: 'button' | 'submit';
onClick?: (event: MouseEvent) => void;
forDropdown?: boolean;
className?: string;
children?: ComponentChildren;
disabled?: boolean;
title?: string;
tabIndex?: number;
};
export const Button = forwardRef(function Button(
{
className,
children,
forDropdown,
color,
justify = 'center',
size = 'md',
...props
}: ButtonProps,
ref: ForwardedRef<HTMLButtonElement>,
) {
return (
<button
ref={ref}
className={classnames(
className,
'outline-none',
'border border-transparent focus-visible:border-blue-300',
'rounded-md flex items-center',
colorStyles[color || 'default'],
justify === 'start' && 'justify-start',
justify === 'center' && 'justify-center',
size === 'md' && 'h-9 px-3',
size === 'sm' && 'h-7 px-2.5 text-sm',
)}
{...props}
>
{children}
{forDropdown && <Icon icon="triangleDown" className="ml-1 -mr-1" />}
</button>
);
});

View File

@@ -1,17 +0,0 @@
import classnames from 'classnames';
import { Link } from 'preact-router';
import type { ButtonProps } from './Button';
import { Button } from './Button';
type Props = ButtonProps & {
href: string;
};
export function ButtonLink({ href, className, ...buttonProps }: Props) {
const linkProps = { href };
return (
<Link {...linkProps}>
<Button className={classnames(className, 'w-full')} tabIndex={-1} {...buttonProps} />
</Link>
);
}

View File

@@ -1,104 +0,0 @@
import classnames from 'classnames';
import { HStack, VStack } from './Stacks';
export function Colors() {
return (
<HStack>
<VStack>
<Color className="bg-gray-50" />
<Color className="bg-gray-100" />
<Color className="bg-gray-200" />
<Color className="bg-gray-300" />
<Color className="bg-gray-400" />
<Color className="bg-gray-500" />
<Color className="bg-gray-600" />
<Color className="bg-gray-700" />
<Color className="bg-gray-800" />
<Color className="bg-gray-900" />
<Color className="bg-gray-950" />
</VStack>
<VStack>
<Color className="bg-red-50" />
<Color className="bg-red-100" />
<Color className="bg-red-200" />
<Color className="bg-red-300" />
<Color className="bg-red-400" />
<Color className="bg-red-500" />
<Color className="bg-red-600" />
<Color className="bg-red-700" />
<Color className="bg-red-800" />
<Color className="bg-red-900" />
<Color className="bg-red-950" />
</VStack>
<VStack>
<Color className="bg-orange-50" />
<Color className="bg-orange-100" />
<Color className="bg-orange-200" />
<Color className="bg-orange-300" />
<Color className="bg-orange-400" />
<Color className="bg-orange-500" />
<Color className="bg-orange-600" />
<Color className="bg-orange-700" />
<Color className="bg-orange-800" />
<Color className="bg-orange-900" />
<Color className="bg-orange-950" />
</VStack>
<VStack>
<Color className="bg-yellow-50" />
<Color className="bg-yellow-100" />
<Color className="bg-yellow-200" />
<Color className="bg-yellow-300" />
<Color className="bg-yellow-400" />
<Color className="bg-yellow-500" />
<Color className="bg-yellow-600" />
<Color className="bg-yellow-700" />
<Color className="bg-yellow-800" />
<Color className="bg-yellow-900" />
<Color className="bg-yellow-950" />
</VStack>
<VStack>
<Color className="bg-green-50" />
<Color className="bg-green-100" />
<Color className="bg-green-200" />
<Color className="bg-green-300" />
<Color className="bg-green-400" />
<Color className="bg-green-500" />
<Color className="bg-green-600" />
<Color className="bg-green-700" />
<Color className="bg-green-800" />
<Color className="bg-green-900" />
<Color className="bg-green-950" />
</VStack>
<VStack>
<Color className="bg-blue-50" />
<Color className="bg-blue-100" />
<Color className="bg-blue-200" />
<Color className="bg-blue-300" />
<Color className="bg-blue-400" />
<Color className="bg-blue-500" />
<Color className="bg-blue-600" />
<Color className="bg-blue-700" />
<Color className="bg-blue-800" />
<Color className="bg-blue-900" />
<Color className="bg-blue-950" />
</VStack>
<VStack>
<Color className="bg-violet-50" />
<Color className="bg-violet-100" />
<Color className="bg-violet-200" />
<Color className="bg-violet-300" />
<Color className="bg-violet-400" />
<Color className="bg-violet-500" />
<Color className="bg-violet-600" />
<Color className="bg-violet-700" />
<Color className="bg-violet-800" />
<Color className="bg-violet-900" />
<Color className="bg-violet-950" />
</VStack>
</HStack>
);
}
function Color({ className }: { className: string }) {
return <div className={classnames(className, 'w-full h-5')} />;
}

View File

@@ -1,48 +0,0 @@
import classnames from 'classnames';
import type { ComponentChildren } from 'preact';
const colsClasses: Record<string | number, string> = {
none: 'grid-cols-none',
1: 'grid-cols-1',
2: 'grid-cols-2',
3: 'grid-cols-3',
11: 'grid-cols-11',
};
const rowsClasses = {
none: 'grid-rows-none',
1: 'grid-rows-1',
2: 'grid-rows-2',
3: 'grid-rows-3',
11: 'grid-rows-11',
};
const gapClasses = {
0: 'gap-0',
1: 'gap-1',
2: 'gap-2',
3: 'gap-3',
};
interface Props {
rows?: keyof typeof rowsClasses;
cols?: keyof typeof colsClasses;
gap?: keyof typeof gapClasses;
className?: string;
children?: ComponentChildren;
}
export function Grid({ className, cols, gap, children }: Props) {
return (
<div
className={classnames(
className,
'grid w-full',
cols && colsClasses[cols],
gap && gapClasses[gap],
)}
>
{children}
</div>
);
}

View File

@@ -1,25 +1,24 @@
import { useCallback, useEffect, useState } from 'react';
import { useRequestUpdate } from '../hooks/useRequest';
import classnames from 'classnames';
import { useEffect, useState } from 'react';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpHeader, HttpRequest } from '../lib/models';
import { IconButton } from './IconButton';
import { Input } from './Input';
import { VStack } from './Stacks';
import { IconButton } from './core/IconButton';
import { Input } from './core/Input';
import { VStack } from './core/Stacks';
interface Props {
request: HttpRequest;
className?: string;
}
type PairWithId = { header: Partial<HttpHeader>; id: string };
export function HeaderEditor({ request }: Props) {
const updateRequest = useRequestUpdate(request);
const saveHeaders = useCallback(
(pairs: PairWithId[]) => {
const headers = pairs.map((p) => ({ name: '', value: '', ...p.header }));
updateRequest.mutate({ headers });
},
[updateRequest],
);
export function HeaderEditor({ request, className }: Props) {
const updateRequest = useUpdateRequest(request);
const saveHeaders = (pairs: PairWithId[]) => {
const headers = pairs.map((p) => ({ name: '', value: '', ...p.header }));
updateRequest.mutate({ headers });
};
const newPair = () => {
return { header: { name: '', value: '' }, id: Math.random().toString() };
@@ -29,16 +28,13 @@ export function HeaderEditor({ request }: Props) {
request.headers.map((h) => ({ header: h, id: Math.random().toString() })),
);
const setPairsAndSave = useCallback(
(fn: (pairs: PairWithId[]) => PairWithId[]) => {
setPairs((oldPairs) => {
const newPairs = fn(oldPairs);
saveHeaders(newPairs);
return newPairs;
});
},
[saveHeaders],
);
const setPairsAndSave = (fn: (pairs: PairWithId[]) => PairWithId[]) => {
setPairs((oldPairs) => {
const newPairs = fn(oldPairs);
saveHeaders(newPairs);
return newPairs;
});
};
const handleChangeHeader = (pair: PairWithId) => {
setPairsAndSave((pairs) =>
@@ -58,14 +54,14 @@ export function HeaderEditor({ request }: Props) {
if (lastPair.header.name !== '' || lastPair.header.value !== '') {
setPairsAndSave((pairs) => [...pairs, newPair()]);
}
}, [pairs, setPairsAndSave]);
}, [pairs]);
const handleDelete = (pair: PairWithId) => {
setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id));
};
return (
<div className="pb-6">
<div className={classnames(className, 'pb-6 grid')}>
<VStack space={2}>
{pairs.map((p, i) => (
<FormRow

View File

@@ -1,27 +1,31 @@
import classnames from 'classnames';
import { useRequestUpdate, useSendRequest } from '../hooks/useRequest';
import type { HttpRequest } from '../lib/models';
import { Editor } from './Editor';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useSendRequest } from '../hooks/useSendRequest';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import { Editor } from './core/Editor';
import { HeaderEditor } from './HeaderEditor';
import { TabContent, Tabs } from './Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { UrlBar } from './UrlBar';
interface Props {
request: HttpRequest;
fullHeight: boolean;
className?: string;
}
export function RequestPane({ fullHeight, request, className }: Props) {
const updateRequest = useRequestUpdate(request ?? null);
const sendRequest = useSendRequest(request ?? null);
export function RequestPane({ fullHeight, className }: Props) {
const activeRequest = useActiveRequest();
const updateRequest = useUpdateRequest(activeRequest);
const sendRequest = useSendRequest(activeRequest);
if (activeRequest === null) return null;
return (
<div className={classnames(className, 'py-2 grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}>
<div className="pl-2">
<UrlBar
key={request.id}
method={request.method}
url={request.url}
key={activeRequest.id}
method={activeRequest.method}
url={activeRequest.url}
loading={sendRequest.isLoading}
onMethodChange={(method) => updateRequest.mutate({ method })}
onUrlChange={(url) => updateRequest.mutate({ url })}
@@ -40,20 +44,20 @@ export function RequestPane({ fullHeight, request, className }: Props) {
defaultValue="body"
label="Request body"
>
<TabContent value="headers" className="pl-2">
<HeaderEditor key={activeRequest.id} request={activeRequest} />
</TabContent>
<TabContent value="body">
<Editor
key={request.id}
key={activeRequest.id}
className="!bg-gray-50"
heightMode={fullHeight ? 'full' : 'auto'}
useTemplating
defaultValue={request.body ?? ''}
defaultValue={activeRequest.body ?? ''}
contentType="application/graphql+json"
onChange={(body) => updateRequest.mutate({ body })}
/>
</TabContent>
<TabContent value="headers" className="pl-2">
<HeaderEditor key={request.id} request={request} />
</TabContent>
</Tabs>
</div>
);

View File

@@ -1,41 +1,44 @@
import classnames from 'classnames';
import { useEffect, useMemo, useState } from 'react';
import { useDeleteAllResponses, useDeleteResponse, useResponses } from '../hooks/useResponses';
import { memo, useEffect, useMemo, useState } from 'react';
import { useDeleteResponses } from '../hooks/useDeleteResponses';
import { useDeleteResponse } from '../hooks/useResponseDelete';
import { useResponses } from '../hooks/useResponses';
import { tryFormatJson } from '../lib/formatters';
import { Dropdown } from './Dropdown';
import { Editor } from './Editor';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
import { HStack } from './Stacks';
import { StatusColor } from './StatusColor';
import { Webview } from './Webview';
import type { HttpResponse } from '../lib/models';
import { Dropdown } from './core/Dropdown';
import { Editor } from './core/Editor';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
import { StatusColor } from './core/StatusColor';
import { Webview } from './core/Webview';
interface Props {
requestId: string;
className?: string;
}
export function ResponsePane({ requestId, className }: Props) {
export const ResponsePane = memo(function ResponsePane({ className }: Props) {
const [activeResponseId, setActiveResponseId] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'pretty' | 'raw'>('pretty');
const responses = useResponses(requestId);
const response = activeResponseId
? responses.data.find((r) => r.id === activeResponseId)
: responses.data[responses.data.length - 1];
const deleteResponse = useDeleteResponse(response);
const deleteAllResponses = useDeleteAllResponses(response?.requestId);
const responses = useResponses();
const activeResponse: HttpResponse | null = activeResponseId
? responses.find((r) => r.id === activeResponseId) ?? null
: responses[responses.length - 1] ?? null;
const deleteResponse = useDeleteResponse(activeResponse);
const deleteAllResponses = useDeleteResponses(activeResponse?.requestId);
useEffect(() => {
setActiveResponseId(null);
}, [responses.data?.length]);
}, [responses.length]);
const contentType = useMemo(
() =>
response?.headers.find((h) => h.name.toLowerCase() === 'content-type')?.value ?? 'text/plain',
[response],
activeResponse?.headers.find((h) => h.name.toLowerCase() === 'content-type')?.value ??
'text/plain',
[activeResponse],
);
if (!response) {
if (activeResponse === null) {
return null;
}
@@ -51,82 +54,78 @@ export function ResponsePane({ requestId, className }: Props) {
>
{/*<HStack as={WindowDragRegion} items="center" className="pl-1.5 pr-1">*/}
{/*</HStack>*/}
{response && (
<>
<HStack
alignItems="center"
className="italic text-gray-700 text-sm w-full mb-1 flex-shrink-0 pl-2"
<HStack
alignItems="center"
className="italic text-gray-700 text-sm w-full mb-1 flex-shrink-0 pl-2"
>
{activeResponse && activeResponse.status > 0 && (
<div className="whitespace-nowrap">
<StatusColor statusCode={activeResponse.status}>
{activeResponse.status}
{activeResponse.statusReason && ` ${activeResponse.statusReason}`}
</StatusColor>
&nbsp;&bull;&nbsp;
{activeResponse.elapsed}ms &nbsp;&bull;&nbsp;
{Math.round(activeResponse.body.length / 1000)} KB
</div>
)}
<HStack alignItems="center" className="ml-auto h-8">
<IconButton
icon={viewMode === 'pretty' ? 'eye' : 'code'}
size="sm"
className="ml-1"
onClick={() => setViewMode((m) => (m === 'pretty' ? 'raw' : 'pretty'))}
/>
<Dropdown
items={[
{
label: 'Clear Response',
onSelect: deleteResponse.mutate,
disabled: responses.length === 0,
},
{
label: 'Clear All Responses',
onSelect: deleteAllResponses.mutate,
disabled: responses.length === 0,
},
'-----',
...responses.slice(0, 10).map((r) => ({
label: r.status + ' - ' + r.elapsed + ' ms',
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <></>,
onSelect: () => setActiveResponseId(r.id),
})),
]}
>
{response.status > 0 && (
<div className="whitespace-nowrap">
<StatusColor statusCode={response.status}>
{response.status}
{response.statusReason && ` ${response.statusReason}`}
</StatusColor>
&nbsp;&bull;&nbsp;
{response.elapsed}ms &nbsp;&bull;&nbsp;
{Math.round(response.body.length / 1000)} KB
</div>
)}
<IconButton icon="clock" className="ml-auto" size="sm" />
</Dropdown>
</HStack>
</HStack>
<HStack alignItems="center" className="ml-auto h-8">
<IconButton
icon={viewMode === 'pretty' ? 'eye' : 'code'}
size="sm"
className="ml-1"
onClick={() => setViewMode((m) => (m === 'pretty' ? 'raw' : 'pretty'))}
/>
<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,
},
'-----',
...responses.data.slice(0, 10).map((r) => ({
label: r.status + ' - ' + r.elapsed + ' ms',
leftSlot: response?.id === r.id ? <Icon icon="check" /> : <></>,
onSelect: () => setActiveResponseId(r.id),
})),
]}
>
<IconButton icon="clock" className="ml-auto" size="sm" />
</Dropdown>
</HStack>
</HStack>
{response?.error ? (
<div className="p-1">
<div className="text-white bg-red-500 px-3 py-2 rounded">{response.error}</div>
</div>
) : viewMode === 'pretty' && contentType.includes('html') ? (
<Webview body={response.body} contentType={contentType} url={response.url} />
) : viewMode === 'pretty' && contentType.includes('json') ? (
<Editor
readOnly
key={`${contentType}:${response.updatedAt}:pretty`}
className="bg-gray-50 dark:!bg-gray-100"
defaultValue={tryFormatJson(response?.body)}
contentType={contentType}
/>
) : response?.body ? (
<Editor
readOnly
key={`${contentType}:${response.updatedAt}`}
className="bg-gray-50 dark:!bg-gray-100"
defaultValue={response?.body}
contentType={contentType}
/>
) : null}
</>
)}
{activeResponse?.error ? (
<div className="p-1">
<div className="text-white bg-red-500 px-3 py-2 rounded">{activeResponse.error}</div>
</div>
) : viewMode === 'pretty' && contentType.includes('html') ? (
<Webview body={activeResponse.body} contentType={contentType} url={activeResponse.url} />
) : viewMode === 'pretty' && contentType.includes('json') ? (
<Editor
readOnly
key={`${contentType}:${activeResponse.updatedAt}:pretty`}
className="bg-gray-50 dark:!bg-gray-100"
defaultValue={tryFormatJson(activeResponse?.body)}
contentType={contentType}
/>
) : activeResponse?.body ? (
<Editor
readOnly
key={`${contentType}:${activeResponse.updatedAt}`}
className="bg-gray-50 dark:!bg-gray-100"
defaultValue={activeResponse?.body}
contentType={contentType}
/>
) : null}
</div>
</div>
);
}
});

View File

@@ -0,0 +1,23 @@
import { useRouteError } from 'react-router-dom';
import { Button } from './core/Button';
import { Heading } from './core/Heading';
import { VStack } from './core/Stacks';
export default function RouteError() {
const error = useRouteError();
const stringified = JSON.stringify(error);
const message = (error as any).message ?? stringified;
return (
<div className="flex items-center justify-center h-full">
<VStack space={5} className="max-w-[30rem] !h-auto">
<Heading>Route Error 🔥</Heading>
<pre className="text-sm select-auto cursor-text bg-gray-100 p-3 rounded whitespace-normal">
{message}
</pre>
<Button to="/" color="primary">
Go Home
</Button>
</VStack>
</div>
);
}

View File

@@ -1,27 +1,32 @@
import classnames from 'classnames';
import { useRequestCreate } from '../hooks/useRequest';
import { useState } from 'react';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useRequests } from '../hooks/useRequests';
import { useTheme } from '../hooks/useTheme';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpRequest } from '../lib/models';
import { ButtonLink } from './ButtonLink';
import { IconButton } from './IconButton';
import { HStack, VStack } from './Stacks';
import { WindowDragRegion } from './WindowDragRegion';
import { Button } from './core/Button';
import { IconButton } from './core/IconButton';
import { HStack, VStack } from './core/Stacks';
import { WindowDragRegion } from './core/WindowDragRegion';
interface Props {
workspaceId: string;
requests: HttpRequest[];
activeRequestId?: string;
className?: string;
}
export function Sidebar({ className, activeRequestId, workspaceId, requests }: Props) {
const createRequest = useRequestCreate({ workspaceId, navigateAfter: true });
export function Sidebar({ className }: Props) {
const requests = useRequests();
const activeRequest = useActiveRequest();
const deleteRequest = useDeleteRequest(activeRequest);
const createRequest = useCreateRequest({ navigateAfter: true });
const { appearance, toggleAppearance } = useTheme();
return (
<div
className={classnames(
className,
'min-w-[10rem] bg-gray-100 h-full border-r border-gray-200 relative',
'w-[15rem] bg-gray-100 h-full border-r border-gray-200 relative grid grid-rows-[auto,1fr]',
)}
>
<HStack as={WindowDragRegion} alignItems="center" justifyContent="end">
@@ -33,9 +38,9 @@ export function Sidebar({ className, activeRequestId, workspaceId, requests }: P
}}
/>
</HStack>
<VStack as="ul" className="py-3 px-2" space={1}>
<VStack as="ul" className="py-3 px-2 overflow-auto h-full" space={1}>
{requests.map((r) => (
<SidebarItem key={r.id} request={r} active={r.id === activeRequestId} />
<SidebarItem key={r.id} request={r} active={r.id === activeRequest?.id} />
))}
{/*<Colors />*/}
@@ -44,6 +49,7 @@ export function Sidebar({ className, activeRequestId, workspaceId, requests }: P
alignItems="center"
justifyContent="end"
>
<IconButton icon="trash" onClick={() => deleteRequest.mutate()} />
<IconButton icon={appearance === 'dark' ? 'moon' : 'sun'} onClick={toggleAppearance} />
</HStack>
</VStack>
@@ -52,23 +58,61 @@ export function Sidebar({ className, activeRequestId, workspaceId, requests }: P
}
function SidebarItem({ request, active }: { request: HttpRequest; active: boolean }) {
const updateRequest = useUpdateRequest(request);
const [editing, setEditing] = useState<boolean>(false);
const handleSubmitNameEdit = async (el: HTMLInputElement) => {
await updateRequest.mutate({ name: el.value });
setEditing(false);
};
const handleFocus = (el: HTMLInputElement | null) => {
el?.focus();
el?.select();
};
return (
<li key={request.id}>
<ButtonLink
<li>
<Button
color="custom"
href={`/workspaces/${request.workspaceId}/requests/${request.id}`}
disabled={active}
size="sm"
className={classnames(
'w-full',
editing && 'focus-within:border-blue-400/40',
active
? 'bg-gray-200/70 text-gray-900'
: 'text-gray-600 hover:text-gray-800 active:bg-gray-200/30',
)}
size="sm"
onKeyDown={(e) => {
// Hitting enter on active request during keyboard nav will start edit
if (active && e.key === 'Enter') {
e.preventDefault();
setEditing(true);
}
}}
to={`/workspaces/${request.workspaceId}/requests/${request.id}`}
onDoubleClick={() => setEditing(true)}
justify="start"
>
{request.name || request.url}
</ButtonLink>
{editing ? (
<input
ref={handleFocus}
defaultValue={request.name}
className="bg-transparent outline-none w-full"
onBlur={(e) => handleSubmitNameEdit(e.currentTarget)}
onKeyDown={async (e) => {
switch (e.key) {
case 'Enter':
await handleSubmitNameEdit(e.currentTarget);
break;
case 'Escape':
setEditing(false);
break;
}
}}
/>
) : (
<span className="truncate">{request.name || request.url}</span>
)}
</Button>
</li>
);
}

View File

@@ -1,7 +1,7 @@
import { Button } from './Button';
import { DropdownMenuRadio } from './Dropdown';
import { IconButton } from './IconButton';
import { Input } from './Input';
import { Button } from './core/Button';
import { DropdownMenuRadio } from './core/Dropdown';
import { IconButton } from './core/IconButton';
import { Input } from './core/Input';
interface Props {
sendRequest: () => void;
@@ -23,7 +23,7 @@ export function UrlBar({ sendRequest, loading, onMethodChange, method, onUrlChan
>
<Input
hideLabel
// useEditor={{ useTemplating: true, contentType: 'url' }}
useEditor={{ useTemplating: true, contentType: 'url' }}
className="px-0"
name="url"
label="Enter URL"

View File

@@ -0,0 +1,40 @@
import classnames from 'classnames';
import { useWindowSize } from 'react-use';
import { RequestPane } from './RequestPane';
import { ResponsePane } from './ResponsePane';
import { Sidebar } from './Sidebar';
import { HStack } from './core/Stacks';
import { WindowDragRegion } from './core/WindowDragRegion';
import { useActiveRequest } from '../hooks/useActiveRequest';
export default function Workspace() {
const activeRequest = useActiveRequest();
const { width } = useWindowSize();
const isH = width > 900;
return (
<div className="grid grid-cols-[auto_1fr] grid-rows-1 h-full text-gray-900">
<Sidebar />
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
<HStack
as={WindowDragRegion}
className="px-3 bg-gray-50 text-gray-900 border-b border-b-gray-200 pt-[1px]"
alignItems="center"
>
{activeRequest?.name}
</HStack>
<div
className={classnames(
'grid',
isH
? 'grid-cols-[1fr_1fr] grid-rows-1'
: 'grid-cols-1 grid-rows-[minmax(0,auto)_minmax(0,100%)]',
)}
>
<RequestPane fullHeight={isH} className={classnames(!isH && 'pr-2 pb-0')} />
<ResponsePane />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { Button } from './core/Button';
import { Heading } from './core/Heading';
import { VStack } from './core/Stacks';
import { useWorkspaces } from '../hooks/useWorkspaces';
export default function Workspaces() {
const workspaces = useWorkspaces();
return (
<VStack as="ul" className="p-12">
<Heading>Workspaces</Heading>
{workspaces.map((w) => (
<Button key={w.id} color="gray" to={`/workspaces/${w.id}`}>
{w.name}
</Button>
))}
</VStack>
);
}

View File

@@ -0,0 +1,84 @@
import classnames from 'classnames';
import type { HTMLAttributes } from 'react';
import { forwardRef } from 'react';
import { Link } from 'react-router-dom';
import { Icon } from './Icon';
const colorStyles = {
custom: '',
default: 'text-gray-700 enabled:hover:bg-gray-700/10 enabled:hover:text-gray-1000',
gray: 'text-gray-800 bg-gray-100 enabled:hover:bg-gray-500/20 enabled:hover:text-gray-1000',
primary: 'bg-blue-400 text-white hover:bg-blue-500',
secondary: 'bg-violet-400 text-white hover:bg-violet-500',
warning: 'bg-orange-400 text-white hover:bg-orange-500',
danger: 'bg-red-400 text-white hover:bg-red-500',
};
export type ButtonProps = HTMLAttributes<HTMLElement> & {
to?: string;
color?: keyof typeof colorStyles;
size?: 'sm' | 'md';
justify?: 'start' | 'center';
type?: 'button' | 'submit';
forDropdown?: boolean;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const Button = forwardRef<any, ButtonProps>(function Button(
{
to,
className,
children,
forDropdown,
color,
justify = 'center',
size = 'md',
...props
}: ButtonProps,
ref,
) {
if (typeof to === 'string') {
return (
<Link
ref={ref}
to={to}
className={classnames(
className,
'outline-none',
'border border-transparent focus-visible:border-blue-300',
'rounded-md flex items-center',
colorStyles[color || 'default'],
justify === 'start' && 'justify-start',
justify === 'center' && 'justify-center',
size === 'md' && 'h-9 px-3',
size === 'sm' && 'h-7 px-2.5 text-sm',
)}
{...props}
>
{children}
{forDropdown && <Icon icon="triangleDown" className="ml-1 -mr-1" />}
</Link>
);
} else {
return (
<button
ref={ref}
className={classnames(
className,
'outline-none',
'border border-transparent focus-visible:border-blue-300',
'rounded-md flex items-center',
colorStyles[color || 'default'],
justify === 'start' && 'justify-start',
justify === 'center' && 'justify-center',
size === 'md' && 'h-9 px-3',
size === 'sm' && 'h-7 px-2.5 text-sm',
)}
{...props}
>
{children}
{forDropdown && <Icon icon="triangleDown" className="ml-1 -mr-1" />}
</button>
);
}
});

View File

@@ -1,12 +1,12 @@
import * as D from '@radix-ui/react-dialog';
import classnames from 'classnames';
import { motion } from 'framer-motion';
import type { ComponentChildren } from 'preact';
import type { ReactNode } from 'react';
import { IconButton } from './IconButton';
import { HStack, VStack } from './Stacks';
interface Props {
children: ComponentChildren;
children: ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;

View File

@@ -2,12 +2,11 @@ import * as D from '@radix-ui/react-dropdown-menu';
import { CheckIcon } from '@radix-ui/react-icons';
import classnames from 'classnames';
import { motion } from 'framer-motion';
import type { ComponentChildren } from 'preact';
import type { ForwardedRef } from 'preact/compat';
import type { ReactNode, ForwardedRef } from 'react';
import { forwardRef, useImperativeHandle, useLayoutEffect, useState } from 'react';
interface DropdownMenuRadioProps {
children: ComponentChildren;
children: ReactNode;
onValueChange: ((v: { label: string; value: string }) => void) | null;
value: string;
label?: string;
@@ -51,13 +50,13 @@ export function DropdownMenuRadio({
}
export interface DropdownProps {
children: ComponentChildren;
children: ReactNode;
items: (
| {
label: string;
onSelect?: () => void;
disabled?: boolean;
leftSlot?: ComponentChildren;
leftSlot?: ReactNode;
}
| '-----'
)[];
@@ -92,7 +91,7 @@ export function Dropdown({ children, items }: DropdownProps) {
}
interface DropdownMenuPortalProps {
children: ComponentChildren;
children: ReactNode;
}
function DropdownMenuPortal({ children }: DropdownMenuPortalProps) {
@@ -265,7 +264,7 @@ function DropdownMenuSeparator({ className, ...props }: D.DropdownMenuSeparatorP
}
type DropdownMenuTriggerProps = D.DropdownMenuTriggerProps & {
children: ComponentChildren;
children: ReactNode;
className?: string;
};
@@ -278,9 +277,9 @@ function DropdownMenuTrigger({ children, className, ...props }: DropdownMenuTrig
}
interface ItemInnerProps {
leftSlot?: ComponentChildren;
rightSlot?: ComponentChildren;
children: ComponentChildren;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
children: ReactNode;
noHover?: boolean;
className?: string;
}

View File

@@ -134,19 +134,6 @@
}
}
.cm-scroller, .cm-tooltip-autocomplete > ul {
&::-webkit-scrollbar-corner,
&::-webkit-scrollbar {
@apply w-1.5 h-1.5 bg-transparent;
}
&::-webkit-scrollbar-thumb {
@apply bg-gray-200 hover:bg-gray-300 rounded-full;
}
}
/* <-- */
/* NOTE: Extra selector required to override default styles */
.cm-tooltip.cm-tooltip {
@apply shadow-lg bg-gray-50 rounded overflow-hidden text-gray-900 border border-gray-200 z-50 pointer-events-auto;

View File

@@ -5,7 +5,8 @@ import classnames from 'classnames';
import { EditorView } from 'codemirror';
import { formatSdl } from 'format-graphql';
import { useEffect, useRef } from 'react';
import { useUnmount } from 'react-use';
import { useDebounce, useUnmount } from 'react-use';
import { debounce } from '../../../lib/debounce';
import { IconButton } from '../IconButton';
import './Editor.css';
import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions';
@@ -96,15 +97,17 @@ export function _Editor({
readOnly && 'cm-readonly',
)}
>
<IconButton
icon="eye"
className="absolute right-3 bottom-3 z-10"
onClick={() => {
const doc = cm.current?.view.state.doc ?? '';
const insert = formatSdl(doc.toString());
cm.current?.view.dispatch({ changes: { from: 0, to: doc.length, insert } });
}}
/>
{contentType?.includes('graphql') && (
<IconButton
icon="eye"
className="absolute right-3 bottom-3 z-10"
onClick={() => {
const doc = cm.current?.view.state.doc ?? '';
const insert = formatSdl(doc.toString());
cm.current?.view.dispatch({ changes: { from: 0, to: doc.length, insert } });
}}
/>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { closeCompletion, startCompletion } from '@codemirror/autocomplete';
import { EditorView } from 'codemirror';
import { debounce } from '../../lib/debounce';
import { debounce } from '../../../lib/debounce';
/*
* Debounce autocomplete until user stops typing for `millis` milliseconds.

View File

@@ -109,8 +109,10 @@ export const baseExtensions = [
drawSelection(),
dropCursor(),
bracketMatching(),
debouncedAutocompletionDisplay({ millis: 1000 }),
autocompletion({ closeOnBlur: true, interactionDelay: 200, activateOnTyping: false }),
// TODO: Figure out how to debounce showing of autocomplete in a good way
// debouncedAutocompletionDisplay({ millis: 1000 }),
// autocompletion({ closeOnBlur: true, interactionDelay: 200, activateOnTyping: false }),
autocompletion({ closeOnBlur: true, interactionDelay: 300 }),
syntaxHighlighting(myHighlightStyle),
EditorState.allowMultipleSelections.of(true),
];

View File

@@ -1,9 +1,9 @@
import classnames from 'classnames';
import type { ComponentChildren } from 'preact';
import type { ReactNode } from 'react';
type Props = {
className?: string;
children?: ComponentChildren;
children?: ReactNode;
};
export function Heading({ className, children, ...props }: Props) {

View File

@@ -1,5 +1,5 @@
import classnames from 'classnames';
import { forwardRef } from 'preact/compat';
import { forwardRef } from 'react';
import type { ButtonProps } from './Button';
import { Button } from './Button';
import type { IconProps } from './Icon';

View File

@@ -1,5 +1,5 @@
import classnames from 'classnames';
import type { ComponentChildren } from 'preact';
import type { ReactNode } from 'react';
import type { EditorProps } from './Editor';
import { Editor } from './Editor';
import { HStack, VStack } from './Stacks';
@@ -13,8 +13,8 @@ interface Props {
onChange?: (value: string) => void;
useEditor?: Pick<EditorProps, 'contentType' | 'useTemplating'>;
defaultValue?: string;
leftSlot?: ComponentChildren;
rightSlot?: ComponentChildren;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
size?: 'sm' | 'md';
className?: string;
placeholder?: string;

View File

@@ -1,9 +1,9 @@
import * as S from '@radix-ui/react-scroll-area';
import classnames from 'classnames';
import type { ComponentChildren } from 'preact';
import type { ReactNode } from 'react';
interface Props {
children: ComponentChildren;
children: ReactNode;
className?: string;
}

View File

@@ -1,5 +1,5 @@
import classnames from 'classnames';
import type { ComponentChildren, ComponentType } from 'preact';
import type { ComponentType, ReactNode } from 'react';
import { Children, Fragment } from 'react';
const spaceClassesX = {
@@ -24,7 +24,7 @@ const spaceClassesY = {
interface HStackProps extends BaseStackProps {
space?: keyof typeof spaceClassesX;
children?: ComponentChildren;
children?: ReactNode;
}
export function HStack({ className, space, children, ...props }: HStackProps) {
@@ -52,7 +52,7 @@ export function HStack({ className, space, children, ...props }: HStackProps) {
export interface VStackProps extends BaseStackProps {
space?: keyof typeof spaceClassesY;
children: ComponentChildren;
children: ReactNode;
}
export function VStack({ className, space, children, ...props }: VStackProps) {
@@ -83,7 +83,7 @@ interface BaseStackProps {
alignItems?: 'start' | 'center';
justifyContent?: 'start' | 'center' | 'end';
className?: string;
children?: ComponentChildren;
children?: ReactNode;
}
function BaseStack({ className, alignItems, justifyContent, children, as }: BaseStackProps) {

View File

@@ -1,9 +1,9 @@
import classnames from 'classnames';
import type { ComponentChildren } from 'preact';
import type { ReactNode } from 'react';
interface Props {
statusCode: number;
children: ComponentChildren;
children: ReactNode;
}
export function StatusColor({ statusCode, children }: Props) {

View File

@@ -1,20 +1,20 @@
import * as T from '@radix-ui/react-tabs';
import classnames from 'classnames';
import type { ComponentChildren } from 'preact';
import type { ReactNode } from 'react';
import { useState } from 'react';
import { Button } from './Button';
import { ScrollArea } from './ScrollArea';
import { HStack } from './Stacks';
import { Button } from '../Button';
import { ScrollArea } from '../ScrollArea';
import { HStack } from '../Stacks';
import './Tabs.css';
interface Props {
defaultValue?: string;
label: string;
tabs: { value: string; label: ComponentChildren }[];
tabs: { value: string; label: ReactNode }[];
tabListClassName?: string;
className?: string;
children: ComponentChildren;
children: ReactNode;
}
export function Tabs({ defaultValue, label, children, tabs, className, tabListClassName }: Props) {
@@ -23,25 +23,24 @@ export function Tabs({ defaultValue, label, children, tabs, className, tabListCl
<T.Root
defaultValue={defaultValue}
onValueChange={setValue}
className={classnames(
className,
// 'h-full overflow-hidden grid grid-rows-[auto_minmax(0,1fr)]',
'h-full flex flex-col min-h-[min-content]',
)}
className={classnames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}
>
<T.List
aria-label={label}
className={classnames(tabListClassName, 'h-auto flex items-center')}
className={classnames(
tabListClassName,
'h-auto flex items-center overflow-x-auto mb-1 pb-1',
)}
>
<ScrollArea className="w-full pb-2">
<HStack space={1}>
{tabs.map((t) => (
<TabTrigger key={t.value} value={t.value} active={t.value === value}>
{t.label}
</TabTrigger>
))}
</HStack>
</ScrollArea>
{/*<ScrollArea className="w-full pb-2">*/}
<HStack space={1}>
{tabs.map((t) => (
<TabTrigger key={t.value} value={t.value} active={t.value === value}>
{t.label}
</TabTrigger>
))}
</HStack>
{/*</ScrollArea>*/}
</T.List>
{children}
</T.Root>
@@ -50,7 +49,7 @@ export function Tabs({ defaultValue, label, children, tabs, className, tabListCl
interface TabTriggerProps {
value: string;
children: ComponentChildren;
children: ReactNode;
active?: boolean;
}
@@ -72,7 +71,7 @@ export function TabTrigger({ value, children, active }: TabTriggerProps) {
interface TabContentProps {
value: string;
children: ComponentChildren;
children: ReactNode;
className?: string;
}
@@ -81,7 +80,7 @@ export function TabContent({ value, children, className }: TabContentProps) {
<T.Content
forceMount
value={value}
className={classnames(className, 'tab-content', 'w-full overflow-auto flex-grow h-0')}
className={classnames(className, 'tab-content', 'w-full h-full overflow-auto')}
>
{children}
</T.Content>

View File

@@ -1,9 +1,9 @@
import classnames from 'classnames';
import type { ComponentChildren } from 'preact';
import type { ReactNode } from 'react';
interface Props {
className?: string;
children?: ComponentChildren;
children?: ReactNode;
}
export function WindowDragRegion({ className, ...props }: Props) {

View File

@@ -0,0 +1,20 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import type { HttpRequest } from '../lib/models';
import { useRequests } from './useRequests';
export function useActiveRequest(): HttpRequest | null {
const params = useParams<{ requestId?: string }>();
const requests = useRequests();
const [activeRequest, setActiveRequest] = useState<HttpRequest | null>(null);
useEffect(() => {
if (requests.length === 0) {
setActiveRequest(null);
} else {
setActiveRequest(requests.find((r) => r.id === params.requestId) ?? null);
}
}, [requests, params.requestId]);
return activeRequest;
}

View File

@@ -0,0 +1,20 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import type { Workspace } from '../lib/models';
import { useWorkspaces } from './useWorkspaces';
export function useActiveWorkspace(): Workspace | null {
const params = useParams<{ workspaceId?: string }>();
const workspaces = useWorkspaces();
const [activeWorkspace, setActiveWorkspace] = useState<Workspace | null>(null);
useEffect(() => {
if (workspaces.length === 0) {
setActiveWorkspace(null);
} else {
setActiveWorkspace(workspaces.find((w) => w.id === params.workspaceId) ?? null);
}
}, [workspaces, params.workspaceId]);
return activeWorkspace;
}

View File

@@ -0,0 +1,23 @@
import { useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { useNavigate } from 'react-router-dom';
import type { HttpRequest } from '../lib/models';
import { useActiveWorkspace } from './useActiveWorkspace';
export function useCreateRequest({ navigateAfter }: { navigateAfter: boolean }) {
const workspace = useActiveWorkspace();
const navigate = useNavigate();
return useMutation<string, unknown, Pick<HttpRequest, 'name'>>({
mutationFn: async (patch) => {
if (workspace === null) {
throw new Error("Cannot create request when there's no active workspace");
}
return invoke('create_request', { ...patch, workspaceId: workspace?.id });
},
onSuccess: async (requestId) => {
if (navigateAfter) {
navigate(`/workspaces/${workspace?.id}/requests/${requestId}`);
}
},
});
}

View File

@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { HttpRequest } from '../lib/models';
import { requestsQueryKey } from './useRequests';
export function useDeleteRequest(request: HttpRequest | null) {
const queryClient = useQueryClient();
return useMutation<void, string>({
mutationFn: async () => {
if (request == null) return;
await invoke('delete_request', { requestId: request.id });
},
onSuccess: async () => {
if (request == null) return;
await queryClient.invalidateQueries(requestsQueryKey(request.workspaceId));
},
});
}

View File

@@ -0,0 +1,16 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
export function useDeleteResponses(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 }], []);
},
});
}

View File

@@ -1,86 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { route } from 'preact-router';
import type { HttpRequest } from '../lib/models';
import { convertDates } from '../lib/models';
import { responsesQueryKey } from './useResponses';
export function requestsQueryKey(workspaceId: string) {
return ['requests', { workspaceId }];
}
export function useRequests(workspaceId: string) {
return useQuery({
queryKey: requestsQueryKey(workspaceId),
queryFn: async () => {
const requests = (await invoke('requests', { workspaceId })) as HttpRequest[];
return requests.map(convertDates);
},
});
}
export function useRequestUpdate(request: HttpRequest | null) {
return useMutation<void, unknown, Partial<HttpRequest>>({
mutationFn: async (patch) => {
if (request == null) {
throw new Error("Can't update a null request");
}
const updatedRequest = { ...request, ...patch };
await invoke('update_request', {
request: {
...updatedRequest,
createdAt: updatedRequest.createdAt.toISOString().replace('Z', ''),
updatedAt: updatedRequest.updatedAt.toISOString().replace('Z', ''),
},
});
},
});
}
export function useRequestCreate({
workspaceId,
navigateAfter,
}: {
workspaceId: string;
navigateAfter: boolean;
}) {
return useMutation<string, unknown, Pick<HttpRequest, 'name'>>({
mutationFn: async (patch) => invoke('create_request', { ...patch, workspaceId }),
onSuccess: async (requestId) => {
console.log('DONE', { requestId, navigateAfter });
if (navigateAfter) {
route(`/workspaces/${workspaceId}/requests/${requestId}`);
}
},
});
}
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));
},
});
}
export function useDeleteRequest(request: HttpRequest | null) {
const queryClient = useQueryClient();
return useMutation<void, string>({
mutationFn: async () => {
if (request == null) return;
await invoke('delete_request', { requestId: request.id });
},
onSuccess: async () => {
if (request == null) return;
await queryClient.invalidateQueries(requestsQueryKey(request.workspaceId));
},
});
}

View File

@@ -0,0 +1,24 @@
import { useQuery } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { HttpRequest } from '../lib/models';
import { convertDates } from '../lib/models';
import { useActiveWorkspace } from './useActiveWorkspace';
export function requestsQueryKey(workspaceId: string) {
return ['requests', { workspaceId }];
}
export function useRequests() {
const workspace = useActiveWorkspace();
return (
useQuery({
enabled: workspace != null,
queryKey: requestsQueryKey(workspace?.id ?? 'n/a'),
queryFn: async () => {
if (workspace == null) return [];
const requests = (await invoke('requests', { workspaceId: workspace.id })) as HttpRequest[];
return requests.map(convertDates);
},
}).data ?? []
);
}

View File

@@ -0,0 +1,20 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { HttpResponse } from '../lib/models';
export function useDeleteResponse(response: HttpResponse | null) {
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),
);
},
});
}

View File

@@ -1,50 +1,26 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { HttpResponse } from '../lib/models';
import { convertDates } from '../lib/models';
import { useActiveRequest } from './useActiveRequest';
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 }], []);
},
});
export function useResponses() {
const activeRequest = useActiveRequest();
return (
useQuery<HttpResponse[]>({
enabled: activeRequest != null,
initialData: [],
queryKey: responsesQueryKey(activeRequest?.id ?? 'n/a'),
queryFn: async () => {
const responses = (await invoke('responses', {
requestId: activeRequest?.id,
})) as HttpResponse[];
return responses.map(convertDates);
},
}).data ?? []
);
}

View File

@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { HttpRequest } from '../lib/models';
import { responsesQueryKey } from './useResponses';
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));
},
});
}

View File

@@ -0,0 +1,23 @@
import { useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { HttpRequest } from '../lib/models';
export function useUpdateRequest(request: HttpRequest | null) {
return useMutation<void, unknown, Partial<HttpRequest>>({
mutationFn: async (patch) => {
if (request == null) {
throw new Error("Can't update a null request");
}
const updatedRequest = { ...request, ...patch };
await invoke('update_request', {
request: {
...updatedRequest,
createdAt: updatedRequest.createdAt.toISOString().replace('Z', ''),
updatedAt: updatedRequest.updatedAt.toISOString().replace('Z', ''),
},
});
},
});
}

View File

@@ -4,8 +4,10 @@ import { convertDates } from '../lib/models';
import { useQuery } from '@tanstack/react-query';
export function useWorkspaces() {
return useQuery(['workspaces'], async () => {
const workspaces = (await invoke('workspaces')) as Workspace[];
return workspaces.map(convertDates);
});
return (
useQuery(['workspaces'], async () => {
const workspaces = (await invoke('workspaces')) as Workspace[];
return workspaces.map(convertDates);
}).data ?? []
);
}

View File

@@ -23,6 +23,16 @@
cursor: default;
}
/* Style the scrollbars */
::-webkit-scrollbar-corner,
::-webkit-scrollbar {
@apply w-1.5 h-1.5 bg-gray-300/10;
}
::-webkit-scrollbar-thumb {
@apply bg-gray-200 hover:bg-gray-300 rounded-full;
}
:root {
color-scheme: light dark;
--transition-duration: 100ms ease-in-out;

View File

@@ -1,93 +1,9 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { listen } from '@tauri-apps/api/event';
import { MotionConfig } from 'framer-motion';
import { render } from 'preact';
import { HelmetProvider } from 'react-helmet-async';
import { AppRouter } from './components/AppRouter';
import { requestsQueryKey } from './hooks/useRequest';
import { responsesQueryKey } from './hooks/useResponses';
import { DEFAULT_FONT_SIZE } from './lib/constants';
import type { HttpRequest, HttpResponse } from './lib/models';
import { convertDates } from './lib/models';
import ReactDOM from 'react-dom/client';
import { App } from './components/App';
import { setAppearance } from './lib/theme/window';
import './main.css';
setAppearance();
const queryClient = new QueryClient();
await listen('updated_request', ({ payload: request }: { payload: HttpRequest }) => {
queryClient.setQueryData(
requestsQueryKey(request.workspaceId),
(requests: HttpRequest[] = []) => {
const newRequests = [];
let found = false;
for (const r of requests) {
if (r.id === request.id) {
found = true;
newRequests.push(convertDates(request));
} else {
newRequests.push(r);
}
}
if (!found) {
newRequests.push(convertDates(request));
}
return newRequests;
},
);
});
await listen('deleted_request', ({ payload: request }: { payload: HttpRequest }) => {
queryClient.setQueryData(requestsQueryKey(request.workspaceId), (requests: HttpRequest[] = []) =>
requests.filter((r) => r.id !== request.id),
);
});
await listen('updated_response', ({ payload: response }: { payload: HttpResponse }) => {
queryClient.setQueryData(
responsesQueryKey(response.requestId),
(responses: HttpResponse[] = []) => {
const newResponses = [];
let found = false;
for (const r of responses) {
if (r.id === response.id) {
found = true;
newResponses.push(convertDates(response));
} else {
newResponses.push(r);
}
}
if (!found) {
newResponses.push(convertDates(response));
}
return newResponses;
},
);
});
await listen('zoom', ({ payload: zoomDelta }: { payload: number }) => {
const fontSize = parseFloat(window.getComputedStyle(document.documentElement).fontSize);
let newFontSize;
if (zoomDelta === 0) {
newFontSize = DEFAULT_FONT_SIZE;
} else if (zoomDelta > 0) {
newFontSize = Math.min(fontSize * 1.1, DEFAULT_FONT_SIZE * 5);
} else if (zoomDelta < 0) {
newFontSize = Math.max(fontSize * 0.9, DEFAULT_FONT_SIZE * 0.4);
}
document.documentElement.style.fontSize = `${newFontSize}px`;
});
render(
<QueryClientProvider client={queryClient}>
<MotionConfig transition={{ duration: 0.1 }}>
<HelmetProvider>
<AppRouter />
</HelmetProvider>
</MotionConfig>
</QueryClientProvider>,
document.getElementById('root') as HTMLElement,
);
// root holds our app's root DOM Element:
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(<App />);

View File

@@ -1,19 +0,0 @@
import { ButtonLink } from '../components/ButtonLink';
import { Heading } from '../components/Heading';
import { VStack } from '../components/Stacks';
import { useWorkspaces } from '../hooks/useWorkspaces';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function Workspaces(props: { path: string }) {
const workspaces = useWorkspaces();
return (
<VStack as="ul" className="p-12">
<Heading>Workspaces</Heading>
{workspaces.data?.map((w) => (
<ButtonLink key={w.id} color="gray" href={`/workspaces/${w.id}`}>
{w.name}
</ButtonLink>
))}
</VStack>
);
}

View File

@@ -15,8 +15,7 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "preact"
"jsx": "react-jsx"
},
"include": [
"src-web"

View File

@@ -1,10 +1,10 @@
import preact from '@preact/preset-vite';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import topLevelAwait from 'vite-plugin-top-level-await';
// https://vitejs.dev/config/
const config = defineConfig({
plugins: [preact({ devToolsEnabled: true }), topLevelAwait()],
plugins: [react({}), topLevelAwait()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
// prevent vite from obscuring rust errors