diff --git a/src-web/components/GraphQLEditor.tsx b/src-web/components/GraphQLEditor.tsx
index 07d13bf8..d1c50d5e 100644
--- a/src-web/components/GraphQLEditor.tsx
+++ b/src-web/components/GraphQLEditor.tsx
@@ -81,19 +81,22 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi
placeholder="..."
ref={editorViewRef}
actions={
- introspection.error && (
+ (introspection.error || introspection.isLoading) && (
)
}
diff --git a/src-web/components/RequestPane.tsx b/src-web/components/RequestPane.tsx
index cff1b453..38371b9b 100644
--- a/src-web/components/RequestPane.tsx
+++ b/src-web/components/RequestPane.tsx
@@ -21,6 +21,7 @@ import {
} from '../lib/models';
import { BasicAuth } from './BasicAuth';
import { BearerAuth } from './BearerAuth';
+import { CountBadge } from './core/CountBadge';
import { Editor } from './core/Editor';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
@@ -90,7 +91,17 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
},
},
// { value: 'params', label: 'URL Params' },
- { value: 'headers', label: 'Headers' },
+ {
+ value: 'headers',
+ label: (
+
+ Headers
+ h.name && h.value).length}
+ />
+
+ ),
+ },
{
value: 'auth',
label: 'Auth',
@@ -150,10 +161,10 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
{activeRequest.authenticationType === AUTH_TYPE_BASIC ? (
@@ -188,7 +199,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
onChange={() => null}
/>
-
+
{activeRequest.bodyType === BODY_TYPE_JSON ? (
('body');
+
export const ResponsePane = memo(function ResponsePane({ style, className }: Props) {
const [pinnedResponseId, setPinnedResponseId] = useState(null);
const activeRequestId = useActiveRequestId();
@@ -33,10 +39,10 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
const [viewMode, toggleViewMode] = useResponseViewMode(activeResponse?.requestId);
const deleteResponse = useDeleteResponse(activeResponse?.id ?? null);
const deleteAllResponses = useDeleteResponses(activeResponse?.requestId);
+ const [activeTab, setActiveTab] = useActiveTab();
- useEffect(() => {
- setPinnedResponseId(null);
- }, [responses.length]);
+ // Unset pinned response when a new one comes in
+ useEffect(() => setPinnedResponseId(null), [responses.length]);
const contentType = useMemo(
() =>
@@ -45,23 +51,41 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
[activeResponse],
);
+ const tabs = useMemo(
+ () => [
+ { label: 'Body', value: 'body' },
+ {
+ label: (
+
+ Headers
+ h.name && h.value).length ?? 0}
+ />
+
+ ),
+ value: 'headers',
+ },
+ ],
+ [activeResponse?.headers],
+ );
+
return (
{activeResponse && (
<>
-
+
{activeResponse.status}
{activeResponse.statusReason && ` ${activeResponse.statusReason}`}
@@ -71,71 +95,93 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
{Math.round(activeResponse.body.length / 1000)} KB
-
- ({
- label: r.status + ' - ' + r.elapsed + ' ms',
- leftSlot: activeResponse?.id === r.id ? : <>>,
- onSelect: () => setPinnedResponseId(r.id),
- })),
- ]}
- >
-
-
-
+
({
+ label: r.status + ' - ' + r.elapsed + ' ms',
+ leftSlot: activeResponse?.id === r.id ? : <>>,
+ onSelect: () => setPinnedResponseId(r.id),
+ })),
+ ]}
+ >
+
+
>
)}
- {activeResponse === null ? (
-
No Response
- ) : activeResponse?.error ? (
-
-
{activeResponse.error}
-
- ) : viewMode === 'pretty' && contentType.includes('html') ? (
-
- ) : viewMode === 'pretty' && contentType.includes('json') ? (
-
- ) : activeResponse?.body ? (
-
- ) : null}
+ {activeResponse?.error ? (
+
{activeResponse.error}
+ ) : (
+
+
+ {activeResponse === null ? (
+ No Response
+ ) : viewMode === 'pretty' && contentType.includes('html') ? (
+
+ ) : viewMode === 'pretty' && contentType.includes('json') ? (
+
+ ) : activeResponse?.body ? (
+
+ ) : null}
+
+
+
+ {activeResponse?.headers.map((h) => (
+ -
+ {h.name}:{' '}
+ {h.value}
+
+ ))}
+
+
+
+ )}
);
});
diff --git a/src-web/components/TauriListeners.tsx b/src-web/components/TauriListeners.tsx
index 91e4e577..f48831a4 100644
--- a/src-web/components/TauriListeners.tsx
+++ b/src-web/components/TauriListeners.tsx
@@ -7,7 +7,6 @@ import { responsesQueryKey } from '../hooks/useResponses';
import { useTauriEvent } from '../hooks/useTauriEvent';
import { workspacesQueryKey } from '../hooks/useWorkspaces';
import { DEFAULT_FONT_SIZE } from '../lib/constants';
-import { debounce } from '../lib/debounce';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { HttpRequest, HttpResponse, Model, Workspace } from '../lib/models';
import { modelsEq } from '../lib/models';
@@ -42,40 +41,37 @@ export function TauriListeners() {
}
});
- useTauriEvent(
- 'updated_model',
- debounce(({ payload, windowLabel }) => {
- if (shouldIgnoreEvent(payload, windowLabel)) return;
+ useTauriEvent('updated_model', ({ payload, windowLabel }) => {
+ if (shouldIgnoreEvent(payload, windowLabel)) return;
- const queryKey =
- payload.model === 'http_request'
- ? requestsQueryKey(payload)
- : payload.model === 'http_response'
- ? responsesQueryKey(payload)
- : payload.model === 'workspace'
- ? workspacesQueryKey(payload)
- : payload.model === 'key_value'
- ? keyValueQueryKey(payload)
- : null;
+ const queryKey =
+ payload.model === 'http_request'
+ ? requestsQueryKey(payload)
+ : payload.model === 'http_response'
+ ? responsesQueryKey(payload)
+ : payload.model === 'workspace'
+ ? workspacesQueryKey(payload)
+ : payload.model === 'key_value'
+ ? keyValueQueryKey(payload)
+ : null;
- if (queryKey === null) {
- if (payload.model) {
- console.log('Unrecognized updated model:', payload);
- }
- return;
+ if (queryKey === null) {
+ if (payload.model) {
+ console.log('Unrecognized updated model:', payload);
}
+ return;
+ }
- if (payload.model === 'http_request') {
- wasUpdatedExternally(payload.id);
- }
+ if (payload.model === 'http_request') {
+ wasUpdatedExternally(payload.id);
+ }
- if (!shouldIgnoreModel(payload)) {
- queryClient.setQueryData(queryKey, (values) =>
- values?.map((v) => (modelsEq(v, payload) ? payload : v)),
- );
- }
- }, 500),
- );
+ if (!shouldIgnoreModel(payload)) {
+ queryClient.setQueryData(queryKey, (values) =>
+ values?.map((v) => (modelsEq(v, payload) ? payload : v)),
+ );
+ }
+ });
useTauriEvent('deleted_model', ({ payload, windowLabel }) => {
if (shouldIgnoreEvent(payload, windowLabel)) return;
@@ -107,7 +103,8 @@ export function TauriListeners() {
document.documentElement.style.fontSize = `${newFontSize}px`;
});
- return <>>;
+
+ return null;
}
function removeById(model: T) {
diff --git a/src-web/components/core/Banner.tsx b/src-web/components/core/Banner.tsx
new file mode 100644
index 00000000..e993b2f6
--- /dev/null
+++ b/src-web/components/core/Banner.tsx
@@ -0,0 +1,21 @@
+import classnames from 'classnames';
+import type { ReactNode } from 'react';
+
+interface Props {
+ children: ReactNode;
+ className?: string;
+}
+export function Banner({ children, className }: Props) {
+ return (
+
+ );
+}
diff --git a/src-web/components/core/Button.tsx b/src-web/components/core/Button.tsx
index 1adae345..1803e23a 100644
--- a/src-web/components/core/Button.tsx
+++ b/src-web/components/core/Button.tsx
@@ -18,6 +18,7 @@ const colorStyles = {
export type ButtonProps = HTMLAttributes & {
to?: string;
color?: keyof typeof colorStyles;
+ isLoading?: boolean;
size?: 'sm' | 'md' | 'xs';
justify?: 'start' | 'center';
type?: 'button' | 'submit';
@@ -30,6 +31,7 @@ export type ButtonProps = HTMLAttributes & {
const _Button = forwardRef(function Button(
{
to,
+ isLoading,
className,
children,
forDropdown,
@@ -68,8 +70,9 @@ const _Button = forwardRef(function Button(
} else {
return (
);
}
diff --git a/src-web/components/core/CountBadge.tsx b/src-web/components/core/CountBadge.tsx
new file mode 100644
index 00000000..0b4a1f22
--- /dev/null
+++ b/src-web/components/core/CountBadge.tsx
@@ -0,0 +1,12 @@
+interface Props {
+ count: number;
+}
+
+export function CountBadge({ count }: Props) {
+ if (count === 0) return null;
+ return (
+
+ {count}
+
+ );
+}
diff --git a/src-web/components/core/Editor/Editor.css b/src-web/components/core/Editor/Editor.css
index c573d51e..969652c5 100644
--- a/src-web/components/core/Editor/Editor.css
+++ b/src-web/components/core/Editor/Editor.css
@@ -47,7 +47,7 @@
}
.placeholder-widget {
- @apply text-[0.9em] text-gray-800 dark:text-gray-900 px-1 rounded cursor-default dark:shadow;
+ @apply text-xs text-gray-800 dark:text-gray-900 px-1 rounded cursor-default dark:shadow;
/* NOTE: Background and border are translucent so we can see text selection through it */
@apply bg-gray-300/40 border border-gray-300 border-opacity-40 hover:border-opacity-80;
diff --git a/src-web/components/core/Editor/Editor.tsx b/src-web/components/core/Editor/Editor.tsx
index 9bb1be52..c1b5e648 100644
--- a/src-web/components/core/Editor/Editor.tsx
+++ b/src-web/components/core/Editor/Editor.tsx
@@ -33,6 +33,7 @@ export interface EditorProps {
useTemplating?: boolean;
onChange?: (value: string) => void;
onFocus?: () => void;
+ onBlur?: () => void;
singleLine?: boolean;
format?: (v: string) => string;
autocomplete?: GenericCompletionConfig;
@@ -52,6 +53,7 @@ const _Editor = forwardRef(function Editor(
forceUpdateKey,
onChange,
onFocus,
+ onBlur,
className,
singleLine,
format,
@@ -75,6 +77,12 @@ const _Editor = forwardRef(function Editor(
handleFocus.current = onFocus;
}, [onFocus]);
+ // Use ref so we can update the onChange handler without re-initializing the editor
+ const handleBlur = useRef(onBlur);
+ useEffect(() => {
+ handleBlur.current = onBlur;
+ }, [onBlur]);
+
// Update placeholder
const placeholderCompartment = useRef(new Compartment());
useEffect(() => {
@@ -125,6 +133,7 @@ const _Editor = forwardRef(function Editor(
container,
onChange: handleChange,
onFocus: handleFocus,
+ onBlur: handleBlur,
readOnly,
singleLine,
}),
@@ -195,10 +204,12 @@ function getExtensions({
singleLine,
onChange,
onFocus,
+ onBlur,
}: Pick & {
container: HTMLDivElement | null;
onChange: MutableRefObject;
onFocus: MutableRefObject;
+ onBlur: MutableRefObject;
}) {
// TODO: Ensure tooltips render inside the dialog if we are in one.
const parent =
@@ -234,9 +245,8 @@ function getExtensions({
// Handle onFocus
EditorView.domEventHandlers({
- focus: () => {
- onFocus.current?.();
- },
+ focus: onFocus.current,
+ blur: onBlur.current,
}),
// Handle onChange
diff --git a/src-web/components/core/Input.tsx b/src-web/components/core/Input.tsx
index ba79d27b..90bf07e5 100644
--- a/src-web/components/core/Input.tsx
+++ b/src-web/components/core/Input.tsx
@@ -17,6 +17,7 @@ export type InputProps = Omit, 'onChange' | 'on
containerClassName?: string;
onChange?: (value: string) => void;
onFocus?: () => void;
+ onBlur?: () => void;
defaultValue?: string;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
@@ -45,6 +46,8 @@ export const Input = forwardRef(function Inp
defaultValue,
validate,
require,
+ onFocus,
+ onBlur,
forceUpdateKey,
...props
}: InputProps,
@@ -52,6 +55,18 @@ export const Input = forwardRef(function Inp
) {
const [obscured, setObscured] = useState(type === 'password');
const [currentValue, setCurrentValue] = useState(defaultValue ?? '');
+ const [focused, setFocused] = useState(false);
+
+ const handleOnFocus = useCallback(() => {
+ setFocused(true);
+ onFocus?.();
+ }, [onFocus]);
+
+ const handleOnBlur = useCallback(() => {
+ setFocused(false);
+ onBlur?.();
+ }, [onBlur]);
+
const id = `input-${name}`;
const inputClassName = classnames(
className,
@@ -92,7 +107,8 @@ export const Input = forwardRef(function Inp
className={classnames(
containerClassName,
'relative w-full rounded-md text-gray-900',
- 'border border-highlight focus-within:border-focus',
+ 'border border-highlight',
+ focused && 'border-focus',
!isValid && '!border-invalid',
size === 'md' && 'h-md leading-md',
size === 'sm' && 'h-sm leading-sm',
@@ -109,6 +125,8 @@ export const Input = forwardRef(function Inp
placeholder={placeholder}
onChange={handleChange}
className={inputClassName}
+ onFocus={handleOnFocus}
+ onBlur={handleOnBlur}
{...props}
/>
{type === 'password' && (
diff --git a/src-web/components/core/PairEditor.tsx b/src-web/components/core/PairEditor.tsx
index 82259db8..25220783 100644
--- a/src-web/components/core/PairEditor.tsx
+++ b/src-web/components/core/PairEditor.tsx
@@ -337,7 +337,7 @@ const FormRow = memo(function FormRow({
size="sm"
title="Delete header"
onClick={handleDelete}
- className="ml-0.5 opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
+ className="ml-0.5 !opacity-0 group-hover:!opacity-100 focus-visible:!opacity-100"
/>
);
diff --git a/src-web/components/core/Tabs/Tabs.tsx b/src-web/components/core/Tabs/Tabs.tsx
index a7fdeaa7..e0b1f088 100644
--- a/src-web/components/core/Tabs/Tabs.tsx
+++ b/src-web/components/core/Tabs/Tabs.tsx
@@ -10,7 +10,7 @@ import { HStack } from '../Stacks';
export type TabItem =
| {
value: string;
- label: string;
+ label: ReactNode;
}
| {
value: string;
@@ -73,16 +73,17 @@ export function Tabs({
aria-label={label}
className={classnames(
tabListClassName,
- 'h-md flex items-center overflow-x-auto pb-0.5 hide-scrollbars',
+ 'h-md flex items-center overflow-x-auto hide-scrollbars',
// Give space for button focus states within overflow boundary
'px-2 -mx-2',
)}
>
-
+
{tabs.map((t) => {
const isActive = t.value === value;
const btnClassName = classnames(
- isActive ? 'bg-gray-100 text-gray-800' : 'text-gray-600 hover:text-gray-900',
+ '!px-1',
+ isActive ? 'text-gray-900' : 'text-gray-600 hover:text-gray-800',
);
if ('options' in t) {
const option = t.options.items.find(
diff --git a/src-web/hooks/useDebouncedSetState.ts b/src-web/hooks/useDebouncedSetState.ts
index 89e436f5..8433e3bc 100644
--- a/src-web/hooks/useDebouncedSetState.ts
+++ b/src-web/hooks/useDebouncedSetState.ts
@@ -2,7 +2,7 @@ import type { Dispatch, SetStateAction } from 'react';
import { useMemo, useState } from 'react';
import { debounce } from '../lib/debounce';
-export function useDebouncedSetState(
+export function useDebouncedSetState(
defaultValue: T,
delay?: number,
): [T, Dispatch>] {
diff --git a/src-web/hooks/useDebouncedValue.ts b/src-web/hooks/useDebouncedValue.ts
index 83aa1ace..65a0c4cb 100644
--- a/src-web/hooks/useDebouncedValue.ts
+++ b/src-web/hooks/useDebouncedValue.ts
@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import { useDebouncedSetState } from './useDebouncedSetState';
-export function useDebouncedValue(value: T, delay?: number) {
+export function useDebouncedValue(value: T, delay?: number) {
const [state, setState] = useDebouncedSetState(value, delay);
useEffect(() => setState(value), [setState, value]);
return state;
diff --git a/src-web/hooks/useIntrospectGraphQL.ts b/src-web/hooks/useIntrospectGraphQL.ts
index 51b2fa82..49d675f3 100644
--- a/src-web/hooks/useIntrospectGraphQL.ts
+++ b/src-web/hooks/useIntrospectGraphQL.ts
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import type { GraphQLSchema } from 'graphql';
import { buildClientSchema, getIntrospectionQuery } from '../components/core/Editor';
+import { minPromiseMillis } from '../lib/minPromiseMillis';
import type { HttpRequest } from '../lib/models';
import { sendEphemeralRequest } from '../lib/sendEphemeralRequest';
import { useDebouncedValue } from './useDebouncedValue';
@@ -13,23 +14,27 @@ const introspectionRequestBody = JSON.stringify({
export function useIntrospectGraphQL(baseRequest: HttpRequest) {
// Debounce the URL because it can change rapidly, and we don't
// want to send so many requests.
- const debouncedUrl = useDebouncedValue(baseRequest.url);
+ const request = useDebouncedValue(baseRequest);
return useQuery({
- queryKey: ['introspectGraphQL', { url: debouncedUrl }],
- refetchOnWindowFocus: true,
- // staleTime: 1000 * 60 * 60, // 1 hour
+ queryKey: ['introspectGraphQL', { url: request.url, method: request.method }],
refetchInterval: 1000 * 60, // Refetch every minute
queryFn: async () => {
- const response = await sendEphemeralRequest({
- ...baseRequest,
- body: introspectionRequestBody,
- });
+ const response = await minPromiseMillis(
+ sendEphemeralRequest({ ...baseRequest, body: introspectionRequestBody }),
+ 700,
+ );
if (response.error) {
return Promise.reject(new Error(response.error));
}
+ if (response.status < 200 || response.status >= 300) {
+ return Promise.reject(
+ new Error(`Request failed with status ${response.status}.\n\n${response.body}`),
+ );
+ }
+
const { data } = JSON.parse(response.body);
return buildClientSchema(data);
},
diff --git a/src-web/lib/minPromiseMillis.ts b/src-web/lib/minPromiseMillis.ts
new file mode 100644
index 00000000..8287a429
--- /dev/null
+++ b/src-web/lib/minPromiseMillis.ts
@@ -0,0 +1,9 @@
+import { sleep } from './sleep';
+
+export async function minPromiseMillis(promise: Promise, millis: number) {
+ const start = Date.now();
+ const result = await promise;
+ const delayFor = millis - (Date.now() - start);
+ await sleep(delayFor);
+ return result;
+}
diff --git a/src-web/lib/sendEphemeralRequest.ts b/src-web/lib/sendEphemeralRequest.ts
index 946bc121..8f429acd 100644
--- a/src-web/lib/sendEphemeralRequest.ts
+++ b/src-web/lib/sendEphemeralRequest.ts
@@ -2,7 +2,7 @@ import { invoke } from '@tauri-apps/api';
import type { HttpRequest, HttpResponse } from './models';
export function sendEphemeralRequest(request: HttpRequest): Promise {
- // Ensure it's not associated with an ID
- const newRequest = { ...request, id: '' };
+ // Remove some things that we don't want to associate
+ const newRequest = { ...request, id: '', requestId: '', workspaceId: '' };
return invoke('send_ephemeral_request', { request: newRequest });
}
diff --git a/src-web/lib/sleep.ts b/src-web/lib/sleep.ts
new file mode 100644
index 00000000..8e339f37
--- /dev/null
+++ b/src-web/lib/sleep.ts
@@ -0,0 +1,3 @@
+export async function sleep(millis: number) {
+ await new Promise((resolve) => setTimeout(resolve, millis));
+}
diff --git a/tailwind.config.cjs b/tailwind.config.cjs
index b2638d43..3730d891 100644
--- a/tailwind.config.cjs
+++ b/tailwind.config.cjs
@@ -32,6 +32,8 @@ module.exports = {
"sans": ["Inter", "sans-serif"]
},
fontSize: {
+ '2xs': "0.7rem",
+ xs: "0.8rem",
sm: "0.9rem",
base: "1rem",
xl: "1.25rem",