Try new layout and a bunch of editor fixes

This commit is contained in:
Gregory Schier
2023-03-04 19:06:12 -08:00
parent 030ba26c5e
commit 1f5e7dbaa9
28 changed files with 661 additions and 298 deletions

View File

@@ -8,14 +8,22 @@ import type {
import { forwardRef } from 'react';
import { Icon } from './Icon';
export interface ButtonProps<T extends ElementType>
extends ButtonHTMLAttributes<HTMLButtonElement> {
color?: 'primary' | 'secondary' | 'warning' | 'danger';
const colorStyles = {
default: 'hover:bg-gray-500/10 text-gray-600',
gray: 'bg-gray-50 text-gray-800 hover:bg-gray-500/10',
primary: 'bg-blue-400',
secondary: 'bg-violet-400',
warning: 'bg-orange-400',
danger: 'bg-red-400',
};
export type ButtonProps<T extends ElementType> = ButtonHTMLAttributes<HTMLButtonElement> & {
color?: keyof typeof colorStyles;
size?: 'xs' | 'sm' | 'md';
justify?: 'start' | 'center';
forDropdown?: boolean;
as?: T;
}
};
export const Button = forwardRef(function Button<T extends ElementType>(
{
@@ -37,18 +45,14 @@ export const Button = forwardRef(function Button<T extends ElementType>(
type="button"
className={classnames(
className,
'rounded-md flex items-center bg-opacity-80 hover:bg-opacity-100 text-white',
'transition-all rounded-md flex items-center bg-opacity-80 hover:bg-opacity-100 hover:text-white',
// 'active:translate-y-[0.5px] active:scale-[0.99]',
colorStyles[color || 'default'],
justify === 'start' && 'justify-start',
justify === 'center' && 'justify-center',
size === 'md' && 'h-10 px-4',
size === 'sm' && 'h-8 px-3 text-sm',
size === 'xs' && 'h-7 px-3 text-sm',
color === undefined && 'hover:bg-gray-500/[0.1]',
color === 'primary' && 'bg-blue-400',
color === 'secondary' && 'bg-violet-400',
color === 'warning' && 'bg-orange-400',
color === 'danger' && 'bg-red-400',
size === 'xs' && 'h-6 px-3 text-xs',
)}
{...props}
>

View File

@@ -29,25 +29,27 @@ export function Dialog({
<D.Portal container={document.querySelector<HTMLElement>('#radix-portal')}>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
<D.Overlay className="fixed inset-0 bg-gray-900 dark:bg-background opacity-80 shadow-lg" />
<D.Content className={classnames(className, 'dialog-content', 'fixed inset-0')}>
<div
className={classnames(
className,
'absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] bg-gray-50',
'w-[20rem] max-h-[80vh] p-5 rounded-lg overflow-auto',
wide && 'w-[80vw] max-w-[50rem]',
)}
>
<D.Close asChild className="ml-auto absolute right-1 top-1">
<IconButton aria-label="Close" icon="x" size="sm" />
</D.Close>
<VStack space={3}>
<HStack items="center" className="pb-3">
<D.Title className="text-xl font-semibold">{title}</D.Title>
</HStack>
{description && <D.Description>{description}</D.Description>}
<div>{children}</div>
</VStack>
<D.Content>
<div className={classnames(className, 'fixed inset-0 pointer-events-none')}>
<div
className={classnames(
className,
'absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] bg-gray-50',
'w-[20rem] max-h-[80vh] p-5 rounded-lg overflow-auto',
wide && 'w-[80vw] max-w-[50rem]',
)}
>
<D.Close asChild className="ml-auto absolute right-1 top-1">
<IconButton aria-label="Close" icon="x" size="sm" />
</D.Close>
<VStack space={3}>
<HStack items="center" className="pb-3">
<D.Title className="text-xl font-semibold">{title}</D.Title>
</HStack>
{description && <D.Description>{description}</D.Description>}
<div>{children}</div>
</VStack>
</div>
</div>
</D.Content>
</motion.div>

View File

@@ -0,0 +1,23 @@
import * as Separator from '@radix-ui/react-separator';
import classnames from 'classnames';
interface Props {
orientation?: 'horizontal' | 'vertical';
decorative?: boolean;
className?: string;
}
export function Divider({ className, orientation = 'horizontal', decorative }: Props) {
return (
<Separator.Root
className={classnames(
className,
'bg-gray-50',
orientation === 'horizontal' && 'w-full h-[1px]',
orientation === 'vertical' && 'h-full w-[1px]',
)}
orientation={orientation}
decorative={decorative}
/>
);
}

View File

@@ -264,7 +264,11 @@ function DropdownMenuSeparator({ className, ...props }: D.DropdownMenuSeparatorP
function DropdownMenuTrigger({ children, className, ...props }: D.DropdownMenuTriggerProps) {
return (
<D.Trigger asChild className={classnames(className, 'focus:outline-none')} {...props}>
<D.Trigger
asChild
className={classnames(className, 'focus:outline-none focus:border-0 focus:shadow-none')}
{...props}
>
{children}
</D.Trigger>
);

View File

@@ -5,45 +5,62 @@
}
.cm-wrapper .cm-editor {
@apply inset-0;
position: absolute !important;
left: 0;
right: 0;
top: 0;
bottom: 0;
font-size: 0.85em;
}
.cm-editor {
@apply w-full block;
&.cm-focused {
outline: none !important;
}
.cm-line {
@apply text-gray-900 pl-1 pr-1.5;
}
.cm-placeholder {
@apply text-placeholder;
}
.placeholder-widget {
@apply text-xs text-white/90 bg-blue-400/80 py-[1px] px-1 mx-[1px] rounded cursor-default hover:bg-blue-400 hover:text-white;
text-shadow: 0 0 1px rgba(0, 0, 0, 0.9);
}
}
.cm-singleline .cm-scroller {
overflow: hidden !important;;
.cm-singleline {
.cm-editor {
@apply h-full w-full;
}
.cm-scroller {
font-family: inherit;
overflow: hidden !important;;
}
.cm-line {
@apply px-0;
}
}
.cm-editor .placeholder-widget {
@apply text-xs text-white bg-blue-400 py-[1px] px-1 mx-[1px] rounded cursor-default hover:bg-blue-500;
text-shadow: 0 0 1px rgba(0, 0, 0, 0.9);
.cm-multiline {
.cm-editor {
@apply h-full;
}
.cm-scroller {
@apply rounded;
}
}
.cm-multiline .cm-editor .cm-scroller {
@apply rounded-lg bg-gray-50;
}
.cm-editor.cm-focused {
outline: none !important;
}
.cm-multiline .cm-editor.cm-focused .cm-scroller {
box-shadow: 0 0 0 1px hsl(var(--color-blue-400)/0.4);
}
.cm-editor .cm-line {
color: hsl(var(--color-gray-900));
}
.cm-multiline .cm-editor .cm-line {
padding-left: 1em;
padding-right: 1.5em;
/* Active border state if we want it */
/*box-shadow: 0 0 0 1px hsl(var(--color-blue-400)/0.4);*/
}
.cm-singleline .cm-editor .cm-scroller {
@@ -52,7 +69,8 @@
}
.cm-editor .cm-gutters {
@apply bg-gray-50 border-r-0 text-gray-200;
/*@apply bg-gray-50 border-r-0 text-gray-200;*/
@apply bg-transparent border-0 text-gray-200;
}
.cm-editor .cm-gutterElement {
@@ -113,39 +131,56 @@
@apply bg-gray-200;
}
/* --> Add padding to container. For some reason, using padding on both adds an extra
* 1px offset so we need to use a combination of padding and margin.
*/
.cm-editor .cm-gutters {
@apply pt-1;
.cm-singleline .cm-editor {
.cm-content {
@apply h-full flex items-center;
}
}
.cm-editor .cm-content {
@apply mt-1;
.cm-scroller {
&::-webkit-scrollbar-corner,
&::-webkit-scrollbar {
@apply w-[5px] h-[5px] bg-transparent;
}
&::-webkit-scrollbar-thumb {
@apply bg-gray-100 bg-opacity-30 rounded-full;
}
}
.cm-editor.cm-focused .cm-scroller::-webkit-scrollbar-thumb {
@apply bg-opacity-80;
}
/* <-- */
/* NOTE: Extra selector required to override default styles */
.cm-tooltip.cm-tooltip {
@apply shadow-lg bg-background rounded overflow-hidden text-gray-900 border border-gray-100/70 z-50;
}
@apply shadow-lg bg-background rounded overflow-hidden text-gray-900 border border-gray-100/70 z-50 pointer-events-auto;
.cm-tooltip.cm-tooltip * {
@apply transition-none;
}
* {
@apply transition-none;
}
.cm-tooltip.cm-tooltip.cm-tooltip-autocomplete > ul {
@apply p-1 max-h-[40vh];
}
&.cm-tooltip-autocomplete {
& > ul {
@apply p-1 max-h-[40vh];
}
.cm-tooltip.cm-tooltip.cm-tooltip-autocomplete > ul > li {
@apply cursor-default py-1 px-2 rounded-sm text-gray-500;
}
& > ul > li {
@apply cursor-default px-2 rounded-sm text-gray-500 h-7 flex items-center;
}
.cm-tooltip.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected] {
@apply bg-gray-50 text-gray-800;
}
& > ul > li[aria-selected] {
@apply bg-gray-50 text-gray-800;
}
.cm-tooltip.cm-tooltip.cm-tooltip-autocomplete .cm-completionIcon {
@apply text-sm;
& > ul > li:hover {
@apply text-gray-700;
}
.cm-completionIcon {
@apply text-sm flex items-center pb-0.5;
}
}
}

View File

@@ -1,4 +1,5 @@
import { defaultKeymap } from '@codemirror/commands';
import type { Extension } from '@codemirror/state';
import { Compartment, EditorState } from '@codemirror/state';
import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view';
import classnames from 'classnames';
@@ -9,29 +10,30 @@ import './Editor.css';
import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions';
import { singleLineExt } from './singleLine';
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
contentType: string;
valueKey?: string;
export interface EditorProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
contentType?: string;
autoFocus?: boolean;
valueKey?: string | number;
defaultValue?: string;
placeholder?: string;
tooltipContainer?: HTMLElement;
useTemplating?: boolean;
onChange?: (value: string) => void;
onSubmit?: () => void;
singleLine?: boolean;
}
export default function Editor({
contentType,
autoFocus,
placeholder,
valueKey,
useTemplating,
defaultValue,
onChange,
onSubmit,
className,
singleLine,
...props
}: Props) {
}: EditorProps) {
const [cm, setCm] = useState<{ view: EditorView; langHolder: Compartment } | null>(null);
const ref = useRef<HTMLDivElement>(null);
const extensions = useMemo(
@@ -39,7 +41,6 @@ export default function Editor({
getExtensions({
container: ref.current,
placeholder,
onSubmit,
singleLine,
onChange,
contentType,
@@ -48,25 +49,23 @@ export default function Editor({
[contentType, ref.current],
);
const newState = (langHolder: Compartment) => {
const langExt = getLanguageExtension({ contentType, useTemplating });
return EditorState.create({
doc: `${defaultValue ?? ''}`,
extensions: [...extensions, langHolder.of(langExt)],
});
};
// Create codemirror instance when ref initializes
useEffect(() => {
if (ref.current === null) return;
let view: EditorView | null = null;
try {
const langHolder = new Compartment();
const langExt = getLanguageExtension({ contentType, useTemplating });
const state = EditorState.create({
doc: `${defaultValue ?? ''}`,
extensions: [...extensions, langHolder.of(langExt)],
});
view = new EditorView({
state: newState(langHolder),
state,
parent: ref.current,
});
setCm({ view, langHolder });
if (autoFocus && view) view.focus();
} catch (e) {
console.log('Failed to initialize Codemirror', e);
}
@@ -108,17 +107,19 @@ function getExtensions({
singleLine,
placeholder,
onChange,
onSubmit,
contentType,
useTemplating,
}: Pick<
Props,
'singleLine' | 'onChange' | 'onSubmit' | 'contentType' | 'useTemplating' | 'placeholder'
EditorProps,
'singleLine' | 'onChange' | 'contentType' | 'useTemplating' | 'placeholder'
> & { container: HTMLDivElement | null }) {
const ext = getLanguageExtension({ contentType, useTemplating });
// TODO: This is a hack to get the tooltips to render in the correct place when inside a modal dialog
const parent = container?.closest<HTMLDivElement>('.dialog-content') ?? undefined;
// TODO: Ensure tooltips render inside the dialog if we are in one.
const parent =
container?.closest<HTMLDivElement>('[role="dialog"]') ??
document.querySelector<HTMLDivElement>('#cm-portal') ??
undefined;
return [
...baseExtensions,
@@ -130,11 +131,15 @@ function getExtensions({
...(placeholder ? [placeholderExt(placeholder)] : []),
// Handle onSubmit
...(onSubmit
...(singleLine
? [
EditorView.domEventHandlers({
keydown: (e) => {
if (e.key === 'Enter') onSubmit?.();
if (e.key === 'Enter') {
const el = e.currentTarget as HTMLElement;
const form = el.closest('form');
form?.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
}
},
}),
]
@@ -147,3 +152,24 @@ function getExtensions({
}),
];
}
const newState = ({
langHolder,
contentType,
useTemplating,
defaultValue,
extensions,
}: {
langHolder: Compartment;
contentType?: string;
useTemplating?: boolean;
defaultValue?: string;
extensions: Extension[];
}) => {
console.log('NEW STATE', defaultValue);
const langExt = getLanguageExtension({ contentType, useTemplating });
return EditorState.create({
doc: `${defaultValue ?? ''}`,
extensions: [...extensions, langHolder.of(langExt)],
});
};

View File

@@ -31,7 +31,6 @@ import {
keymap,
lineNumbers,
rectangularSelection,
tooltips,
} from '@codemirror/view';
import { tags as t } from '@lezer/highlight';
import { twig } from './twig/extension';
@@ -90,10 +89,10 @@ export function getLanguageExtension({
contentType,
useTemplating,
}: {
contentType: string;
contentType?: string;
useTemplating?: boolean;
}) {
const justContentType = contentType.split(';')[0] ?? contentType;
const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
const base = syntaxExtensions[justContentType] ?? json();
if (!useTemplating) {
return [base];
@@ -108,7 +107,7 @@ export const baseExtensions = [
drawSelection(),
dropCursor(),
bracketMatching(),
autocompletion({ closeOnBlur: true }),
autocompletion({ closeOnBlur: true, interactionDelay: 200 }),
syntaxHighlighting(myHighlightStyle),
EditorState.allowMultipleSelections.of(true),
];

View File

@@ -1,5 +1,4 @@
import type { CompletionContext } from '@codemirror/autocomplete';
import { match } from 'assert';
const openTag = '${[ ';
const closeTag = ' ]}';
@@ -18,7 +17,7 @@ const variables = [
];
const MIN_MATCH_VAR = 2;
const MIN_MATCH_NAME = 2;
const MIN_MATCH_NAME = 4;
export function completions(context: CompletionContext) {
const toStartOfName = context.matchBefore(/\w*/);

View File

@@ -1,22 +1,25 @@
import classnames from 'classnames';
import { HTMLAttributes } from 'react';
import type { HTMLAttributes } from 'react';
const colsClasses = {
none: 'grid-cols-none',
1: 'grid-cols-1',
2: 'grid-cols-2',
3: 'grid-cols-2',
};
const rowsClasses = {
none: 'grid-rows-none',
1: 'grid-rows-1',
2: 'grid-rows-2',
3: 'grid-rows-2',
};
const gapClasses = {
0: 'gap-0',
1: 'gap-1',
2: 'gap-2',
3: 'gap-3',
};
type Props = HTMLAttributes<HTMLElement> & {

View File

@@ -1,5 +1,5 @@
import type { FormEvent } from 'react';
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import type { HttpHeader } from '../lib/models';
import { IconButton } from './IconButton';
import { Input } from './Input';
@@ -7,82 +7,107 @@ import { HStack, VStack } from './Stacks';
export function HeaderEditor() {
const [headers, setHeaders] = useState<HttpHeader[]>([]);
const [newHeader, setNewHeader] = useState<HttpHeader>({ name: '', value: '' });
const handleSubmit = (e?: FormEvent) => {
console.log('SUBMIT');
e?.preventDefault();
setHeaders([...headers, newHeader]);
setNewHeader({ name: '', value: '' });
};
const [newHeaderName, setNewHeaderName] = useState<string>('');
const [newHeaderValue, setNewHeaderValue] = useState<string>('');
const handleSubmit = useCallback(
(e?: FormEvent) => {
e?.preventDefault();
setHeaders([...headers, { name: newHeaderName, value: newHeaderValue }]);
setNewHeaderName('');
setNewHeaderValue('');
},
[newHeaderName, newHeaderValue],
);
const handleChangeHeader = useCallback(
(header: Partial<HttpHeader>, index: number) => {
setHeaders((headers) =>
headers.map((h, i) => {
if (i === index) return h;
const newHeader: HttpHeader = { ...h, ...header };
console.log('NEW HEADER', newHeader);
return newHeader;
}),
);
},
[headers],
);
const handleDelete = (index: number) => {
setHeaders((headers) => headers.filter((_, i) => i !== index));
};
const handleChangeHeader = (header: HttpHeader, index: number) => {
setHeaders((headers) => headers.map((h, i) => (i === index ? header : h)));
};
console.log('HEADERS', headers);
return (
<form onSubmit={handleSubmit}>
<VStack space={2}>
{headers.map((header, i) => (
<FormRow
key={`${headers.length}:${i}`}
header={header}
onChange={(h) => handleChangeHeader(h, i)}
key={`${headers.length}-${i}`}
valueKey={`${headers.length}-${i}`}
name={header.name}
value={header.value}
onChangeName={(name) => handleChangeHeader({ name }, i)}
onChangeValue={(value) => handleChangeHeader({ value }, i)}
onDelete={() => handleDelete(i)}
onSubmit={handleSubmit}
/>
))}
<FormRow addSubmit onChange={setNewHeader} header={newHeader} onSubmit={handleSubmit} />
<FormRow
autoFocus
addSubmit
valueKey={headers.length}
onChangeName={setNewHeaderName}
onChangeValue={setNewHeaderValue}
name={newHeaderName}
value={newHeaderValue}
/>
</VStack>
</form>
);
}
function FormRow({
header,
autoFocus,
valueKey,
name,
value,
addSubmit,
onChange,
onSubmit,
onChangeName,
onChangeValue,
onDelete,
}: {
header: HttpHeader;
autoFocus?: boolean;
valueKey: string | number;
name: string;
value: string;
addSubmit?: boolean;
onSubmit?: () => void;
onChange: (header: HttpHeader) => void;
onChangeName: (name: string) => void;
onChangeValue: (value: string) => void;
onDelete?: () => void;
}) {
return (
<div>
<HStack space={2}>
<Input
autoFocus
useEditor
useTemplating
hideLabel
autoFocus={autoFocus}
useEditor={{ useTemplating: true, valueKey }}
name="name"
label="Name"
placeholder="name"
onSubmit={onSubmit}
value={header.name}
hideLabel
onChange={(name) => {
onChange({ name, value: header.value });
}}
defaultValue={name}
onChange={onChangeName}
/>
<Input
hideLabel
name="value"
label="Value"
useEditor
useTemplating
onSubmit={onSubmit}
useEditor={{ useTemplating: true, valueKey }}
placeholder="value"
value={header.value}
hideLabel
onChange={(value) => {
onChange({ name: header.name, value });
}}
defaultValue={value}
onChange={onChangeValue}
/>
{onDelete && <IconButton size="sm" icon="trash" onClick={onDelete} />}
</HStack>

View File

@@ -3,7 +3,6 @@ import {
CameraIcon,
CheckIcon,
CodeIcon,
Cross1Icon,
Cross2Icon,
EyeOpenIcon,
GearIcon,

View File

@@ -1,19 +1,22 @@
import classnames from 'classnames';
import type { InputHTMLAttributes, ReactNode } from 'react';
import type { EditorProps } from './Editor/Editor';
import Editor from './Editor/Editor';
import { HStack, VStack } from './Stacks';
interface Props extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'onChange'> {
interface Props
extends Omit<
InputHTMLAttributes<HTMLInputElement>,
'size' | 'onChange' | 'onSubmit' | 'defaultValue'
> {
name: string;
label: string;
hideLabel?: boolean;
labelClassName?: string;
containerClassName?: string;
onChange?: (value: string) => void;
onSubmit?: () => void;
contentType?: string;
useTemplating?: boolean;
useEditor?: boolean;
useEditor?: Pick<EditorProps, 'contentType' | 'useTemplating' | 'valueKey'>;
defaultValue?: string;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
size?: 'sm' | 'md';
@@ -25,13 +28,10 @@ export function Input({
className,
containerClassName,
labelClassName,
onSubmit,
onChange,
placeholder,
useTemplating,
size = 'md',
useEditor,
contentType,
onChange,
name,
leftSlot,
rightSlot,
@@ -55,7 +55,7 @@ export function Input({
items="center"
className={classnames(
containerClassName,
'relative w-full rounded-md overflow-hidden text-gray-900 bg-gray-200/10',
'relative w-full rounded-md text-gray-900 bg-gray-200/10',
'border border-gray-500/10 focus-within:border-blue-400/40',
size === 'md' && 'h-10',
size === 'sm' && 'h-8',
@@ -66,13 +66,15 @@ export function Input({
<Editor
id={id}
singleLine
contentType={contentType ?? 'text/plain'}
useTemplating={useTemplating}
defaultValue={defaultValue}
onChange={onChange}
onSubmit={onSubmit}
placeholder={placeholder}
className={className}
onChange={onChange}
className={classnames(
className,
'!bg-transparent min-w-0 pl-3 pr-2 h-full w-full focus:outline-none',
)}
{...props}
{...useEditor}
/>
) : (
<input
@@ -82,7 +84,7 @@ export function Input({
defaultValue={defaultValue}
className={classnames(
className,
'!bg-transparent min-w-0 pl-3 pr-2 h-full w-full focus:outline-none placeholder:text-gray-500/40',
'!bg-transparent min-w-0 pl-3 pr-2 h-full w-full focus:outline-none placeholder:text-placeholder',
leftSlot && '!pl-1',
rightSlot && '!pr-1',
)}

View File

@@ -0,0 +1,15 @@
import classnames from 'classnames';
import type { ReactNode } from 'react';
export interface LayoutPaneProps {
children?: ReactNode;
className?: string;
}
export function LayoutPane({ className, children }: LayoutPaneProps) {
return (
<div className={classnames(className, 'w-full h-full p-2')} data-tauri-drag-region>
<div className={classnames('w-full h-full bg-gray-50/50 rounded-lg')}>{children}</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import classnames from 'classnames';
import { useDeleteRequest, useRequestUpdate, useSendRequest } from '../hooks/useRequest';
import type { HttpRequest } from '../lib/models';
import { Button } from './Button';
import { Divider } from './Divider';
import Editor from './Editor/Editor';
import type { LayoutPaneProps } from './LayoutPane';
import { LayoutPane } from './LayoutPane';
import { ScrollArea } from './ScrollArea';
import { HStack } from './Stacks';
import { UrlBar } from './UrlBar';
interface Props extends LayoutPaneProps {
request: HttpRequest;
}
export function RequestPane({ request, ...props }: Props) {
const updateRequest = useRequestUpdate(request ?? null);
const sendRequest = useSendRequest(request ?? null);
return (
<LayoutPane {...props}>
<div className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)] grid-cols-1 pt-1 pb-2">
{/*<HStack as={WindowDragRegion} items="center" className="pl-3 pr-1.5">*/}
{/* Test Request*/}
{/* <IconButton size="sm" icon="trash" onClick={() => deleteRequest.mutate()} />*/}
{/*</HStack>*/}
<div>
<UrlBar
className="bg-transparent border-0 mb-1"
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}
/>
<div className="mx-2">
<Divider />
</div>
</div>
{/*<Divider className="mb-2" />*/}
<ScrollArea className="max-w-full pb-2 mx-2">
<HStack className="mt-2 hide-scrollbar" space={1}>
{['JSON', 'Params', 'Headers', 'Auth', 'Docs'].map((label, i) => (
<Button
key={label}
size="xs"
color={i === 0 && 'gray'}
className={i !== 0 && 'opacity-50 hover:opacity-60'}
>
{label}
</Button>
))}
</HStack>
</ScrollArea>
<div className="px-0">
<Editor
valueKey={request.id}
useTemplating
defaultValue={request.body ?? ''}
contentType="application/json"
onChange={(body) => updateRequest.mutate({ body })}
/>
</div>
</div>
</LayoutPane>
);
}

View File

@@ -1,19 +1,20 @@
import { motion } from 'framer-motion';
import classnames from 'classnames';
import { useEffect, useMemo, useState } from 'react';
import { useDeleteAllResponses, useDeleteResponse, useResponses } from '../hooks/useResponses';
import { Divider } from './Divider';
import { Dropdown } from './Dropdown';
import Editor from './Editor/Editor';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
import { HStack, VStack } from './Stacks';
import { WindowDragRegion } from './WindowDragRegion';
import type { LayoutPaneProps } from './LayoutPane';
import { LayoutPane } from './LayoutPane';
import { HStack } from './Stacks';
interface Props {
interface Props extends LayoutPaneProps {
requestId: string;
error: string | null;
}
export function ResponsePane({ requestId, error }: Props) {
export function ResponsePane({ requestId, className, ...props }: Props) {
const [activeResponseId, setActiveResponseId] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'pretty' | 'raw'>('pretty');
const responses = useResponses(requestId);
@@ -22,7 +23,6 @@ export function ResponsePane({ requestId, error }: Props) {
: responses.data[responses.data.length - 1];
const deleteResponse = useDeleteResponse(response);
const deleteAllResponses = useDeleteAllResponses(response?.requestId);
error = response?.error ?? error;
useEffect(() => {
setActiveResponseId(null);
@@ -44,49 +44,29 @@ export function ResponsePane({ requestId, error }: Props) {
}, [response?.body, contentType]);
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,
},
'-----',
...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="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 && (
<>
<LayoutPane className={classnames(className)} {...props}>
<div className="max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 py-1 px-2">
{/*<HStack as={WindowDragRegion} items="center" className="pl-1.5 pr-1">*/}
{/*</HStack>*/}
{response?.error && (
<div className="text-white bg-red-500 px-2 py-1 rounded">{response.error}</div>
)}
{response && (
<>
<div className="mb-2">
<HStack
data-tauri-drag-region
items="center"
className="italic text-gray-500 text-sm w-full h-10 mb-3 flex-shrink-0"
className="italic text-gray-500 text-sm w-full mb-1 flex-shrink-0"
>
<div className="whitespace-nowrap">
{response.updatedAt.toISOString()}
&nbsp;&bull;&nbsp;
<div data-tauri-drag-region className="whitespace-nowrap">
{response.status}
{response.statusReason && ` ${response.statusReason}`}
&nbsp;&bull;&nbsp;
{response.elapsed}ms &nbsp;&bull;&nbsp;
{Math.round(response.body.length / 1000)} KB
</div>
<HStack items="center" className="ml-auto">
{contentType.includes('html') && (
<IconButton
@@ -96,26 +76,49 @@ export function ResponsePane({ requestId, error }: Props) {
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="gear" className="ml-auto" size="sm" />
</Dropdown>
</HStack>
</HStack>
{viewMode === 'pretty' && contentForIframe !== null ? (
<iframe
title="Response preview"
srcDoc={contentForIframe}
sandbox="allow-scripts allow-same-origin"
className="h-full w-full rounded-lg"
/>
) : response?.body ? (
<Editor
valueKey={`${contentType}:${response.body}`}
defaultValue={response?.body}
contentType={contentType}
/>
) : null}
</>
)}
</VStack>
</motion.div>
</VStack>
<Divider />
</div>
{viewMode === 'pretty' && contentForIframe !== null ? (
<iframe
title="Response preview"
srcDoc={contentForIframe}
sandbox="allow-scripts allow-same-origin"
className="h-full w-full rounded-lg"
/>
) : response?.body ? (
<Editor
valueKey={`${contentType}:${response.body}`}
defaultValue={response?.body}
contentType={contentType}
/>
) : null}
</>
)}
</div>
</LayoutPane>
);
}

View File

@@ -0,0 +1,34 @@
import * as S from '@radix-ui/react-scroll-area';
import classnames from 'classnames';
import type { ReactNode } from 'react';
interface Props {
children: ReactNode;
className?: string;
}
export function ScrollArea({ children, className }: Props) {
return (
<S.Root className={classnames(className, 'group')} type="always">
<S.Viewport>{children}</S.Viewport>
<ScrollBar orientation="vertical" />
<ScrollBar orientation="horizontal" />
<S.Corner />
</S.Root>
);
}
function ScrollBar({ orientation }: { orientation: 'vertical' | 'horizontal' }) {
return (
<S.Scrollbar
orientation={orientation}
className={classnames(
'flex bg-transparent rounded-full',
orientation === 'vertical' && 'w-1',
orientation === 'horizontal' && 'h-1 flex-col',
)}
>
<S.Thumb className="flex-1 bg-gray-50 group-hover:bg-gray-100 rounded-full" />
</S.Scrollbar>
);
}

View File

@@ -7,6 +7,7 @@ import useTheme from '../hooks/useTheme';
import type { HttpRequest } from '../lib/models';
import { Button } from './Button';
import { Dialog } from './Dialog';
import { DropdownMenuRadio } from './Dropdown';
import { HeaderEditor } from './HeaderEditor';
import { IconButton } from './IconButton';
import { Input } from './Input';
@@ -24,10 +25,7 @@ export function Sidebar({ className, activeRequestId, workspaceId, requests, ...
const { toggleTheme } = useTheme();
const [open, setOpen] = useState<boolean>(false);
return (
<div
className={classnames(className, 'w-52 bg-gray-50/40 h-full border-gray-500/10')}
{...props}
>
<div className={classnames(className, 'w-52 bg-gray-50 h-full')} {...props}>
<HStack as={WindowDragRegion} items="center" className="pr-1" justify="end">
<Dialog wide open={open} onOpenChange={setOpen} title="Edit Headers">
<HeaderEditor />
@@ -51,7 +49,7 @@ export function Sidebar({ className, activeRequestId, workspaceId, requests, ...
}}
/>
</HStack>
<VStack as="ul" className="py-3" space={1}>
<VStack as="ul" className="pb-3" space={1}>
{requests.map((r) => (
<SidebarItem key={r.id} request={r} active={r.id === activeRequestId} />
))}
@@ -66,7 +64,7 @@ function SidebarItem({ request, active }: { request: HttpRequest; active: boolea
<Button
as={Link}
to={`/workspaces/${request.workspaceId}/requests/${request.id}`}
className={classnames('w-full', active && 'bg-gray-50')}
className={classnames('w-full', active && 'bg-gray-500/[0.1]')}
size="xs"
justify="start"
>

View File

@@ -34,6 +34,7 @@ export function HStack({ className, space, children, ...props }: HStackProps) {
{i > 0 ? (
<div
className={classnames(spaceClassesX[space], 'pointer-events-none')}
data-spacer=""
aria-hidden
/>
) : null}
@@ -61,6 +62,7 @@ export function VStack({ className, space, children, ...props }: VStackProps) {
{i > 0 ? (
<div
className={classnames(spaceClassesY[space], 'pointer-events-none')}
data-spacer=""
aria-hidden
/>
) : null}

View File

@@ -11,9 +11,18 @@ interface Props {
url: string;
onMethodChange: (method: string) => void;
onUrlChange: (url: string) => void;
className?: string;
}
export function UrlBar({ sendRequest, loading, onMethodChange, method, onUrlChange, url }: Props) {
export function UrlBar({
className,
sendRequest,
loading,
onMethodChange,
method,
onUrlChange,
url,
}: Props) {
const handleSendRequest = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
sendRequest();
@@ -23,13 +32,12 @@ export function UrlBar({ sendRequest, loading, onMethodChange, method, onUrlChan
<form onSubmit={handleSendRequest} className="w-full flex items-center">
<Input
hideLabel
useEditor
useTemplating
onSubmit={sendRequest}
contentType="url"
useEditor={{ useTemplating: true, contentType: 'url' }}
size="sm"
className="font-mono text-sm"
name="url"
label="Enter URL"
className="font-mono"
containerClassName={className}
onChange={onUrlChange}
defaultValue={url}
placeholder="Enter a URL..."
@@ -51,7 +59,7 @@ export function UrlBar({ sendRequest, loading, onMethodChange, method, onUrlChan
type="button"
disabled={loading}
size="sm"
className="ml-1 !px-2"
className="ml-1 mr-2 !px-2 !text-gray-800"
justify="start"
>
{method.toUpperCase()}
@@ -60,11 +68,13 @@ export function UrlBar({ sendRequest, loading, onMethodChange, method, onUrlChan
}
rightSlot={
<IconButton
type="submit"
size="sm"
icon={loading ? 'update' : 'paper-plane'}
spin={loading}
disabled={loading}
size="sm"
className="mr-1 !px-2"
className="mx-1 !px-4"
title="Send Request"
/>
}
/>

View File

@@ -1,12 +1,12 @@
import classnames from 'classnames';
import { HTMLAttributes } from 'react';
import type { HTMLAttributes } from 'react';
type Props = HTMLAttributes<HTMLDivElement>;
export function WindowDragRegion({ className, ...props }: Props) {
return (
<div
className={classnames(className, 'w-full h-11 flex-shrink-0 border-b border-gray-500/10')}
className={classnames(className, 'w-full h-14 flex-shrink-0')}
data-tauri-drag-region=""
{...props}
/>