A bunch more small things

This commit is contained in:
Gregory Schier
2023-02-25 23:04:31 -08:00
parent 83bb18df03
commit d85c021305
25 changed files with 1749 additions and 918 deletions

View File

@@ -1,4 +1,10 @@
import { ButtonHTMLAttributes, ComponentPropsWithoutRef, ElementType } from 'react';
import {
ButtonHTMLAttributes,
ComponentPropsWithoutRef,
ElementType,
ForwardedRef,
forwardRef,
} from 'react';
import classnames from 'classnames';
import { Icon } from './Icon';
@@ -11,19 +17,23 @@ export interface ButtonProps<T extends ElementType>
as?: T;
}
export function Button<T extends ElementType>({
className,
as,
justify = 'center',
children,
size = 'md',
forDropdown,
color,
...props
}: ButtonProps<T> & Omit<ComponentPropsWithoutRef<T>, keyof ButtonProps<T>>) {
export const Button = forwardRef(function Button<T extends ElementType>(
{
className,
as,
justify = 'center',
children,
size = 'md',
forDropdown,
color,
...props
}: ButtonProps<T> & Omit<ComponentPropsWithoutRef<T>, keyof ButtonProps<T>>,
ref: ForwardedRef<HTMLButtonElement>,
) {
const Component = as || 'button';
return (
<Component
ref={ref}
className={classnames(
className,
'rounded-md flex items-center',
@@ -43,4 +53,4 @@ export function Button<T extends ElementType>({
{forDropdown && <Icon icon="triangle-down" className="ml-1 -mr-1" />}
</Component>
);
}
});

View File

@@ -133,39 +133,39 @@ function DropdownMenuItem({
);
}
type DropdownMenuCheckboxItemProps = DropdownMenu.DropdownMenuCheckboxItemProps & ItemInnerProps;
// type DropdownMenuCheckboxItemProps = DropdownMenu.DropdownMenuCheckboxItemProps & ItemInnerProps;
//
// function DropdownMenuCheckboxItem({
// leftSlot,
// rightSlot,
// children,
// ...props
// }: DropdownMenuCheckboxItemProps) {
// return (
// <DropdownMenu.CheckboxItem asChild {...props}>
// <ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
// {children}
// </ItemInner>
// </DropdownMenu.CheckboxItem>
// );
// }
function DropdownMenuCheckboxItem({
leftSlot,
rightSlot,
children,
...props
}: DropdownMenuCheckboxItemProps) {
return (
<DropdownMenu.CheckboxItem asChild {...props}>
<ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
{children}
</ItemInner>
</DropdownMenu.CheckboxItem>
);
}
type DropdownMenuSubTriggerProps = DropdownMenu.DropdownMenuSubTriggerProps & ItemInnerProps;
function DropdownMenuSubTrigger({
leftSlot,
rightSlot,
children,
...props
}: DropdownMenuSubTriggerProps) {
return (
<DropdownMenu.SubTrigger asChild {...props}>
<ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
{children}
</ItemInner>
</DropdownMenu.SubTrigger>
);
}
// type DropdownMenuSubTriggerProps = DropdownMenu.DropdownMenuSubTriggerProps & ItemInnerProps;
//
// function DropdownMenuSubTrigger({
// leftSlot,
// rightSlot,
// children,
// ...props
// }: DropdownMenuSubTriggerProps) {
// return (
// <DropdownMenu.SubTrigger asChild {...props}>
// <ItemInner leftSlot={leftSlot} rightSlot={rightSlot}>
// {children}
// </ItemInner>
// </DropdownMenu.SubTrigger>
// );
// }
type DropdownMenuRadioItemProps = Omit<
DropdownMenu.DropdownMenuRadioItemProps & ItemInnerProps,
@@ -189,22 +189,22 @@ function DropdownMenuRadioItem({ rightSlot, children, ...props }: DropdownMenuRa
);
}
const DropdownMenuSubContent = forwardRef<HTMLDivElement, DropdownMenu.DropdownMenuSubContentProps>(
function DropdownMenuSubContent(
{ className, ...props }: DropdownMenu.DropdownMenuSubContentProps,
ref,
) {
return (
<DropdownMenu.SubContent
ref={ref}
alignOffset={0}
sideOffset={4}
className={classnames(className, dropdownMenuClasses)}
{...props}
/>
);
},
);
// const DropdownMenuSubContent = forwardRef<HTMLDivElement, DropdownMenu.DropdownMenuSubContentProps>(
// function DropdownMenuSubContent(
// { className, ...props }: DropdownMenu.DropdownMenuSubContentProps,
// ref,
// ) {
// return (
// <DropdownMenu.SubContent
// ref={ref}
// alignOffset={0}
// sideOffset={4}
// className={classnames(className, dropdownMenuClasses)}
// {...props}
// />
// );
// },
// );
function DropdownMenuLabel({ className, children, ...props }: DropdownMenu.DropdownMenuLabelProps) {
return (
@@ -216,14 +216,14 @@ function DropdownMenuLabel({ className, children, ...props }: DropdownMenu.Dropd
);
}
function DropdownMenuSeparator({ className, ...props }: DropdownMenu.DropdownMenuSeparatorProps) {
return (
<DropdownMenu.Separator
className={classnames(className, 'h-[1px] bg-gray-400 bg-opacity-30 my-1')}
{...props}
/>
);
}
// function DropdownMenuSeparator({ className, ...props }: DropdownMenu.DropdownMenuSeparatorProps) {
// return (
// <DropdownMenu.Separator
// className={classnames(className, 'h-[1px] bg-gray-400 bg-opacity-30 my-1')}
// {...props}
// />
// );
// }
function DropdownMenuTrigger({
children,
@@ -236,7 +236,7 @@ function DropdownMenuTrigger({
className={classnames(className, 'focus:outline-none')}
{...props}
>
<>{children}</>
{children}
</DropdownMenu.Trigger>
);
}

View File

@@ -89,7 +89,12 @@
.cm-editor .cm-activeLineGutter,
.cm-editor .cm-activeLine {
background-color: hsl(var(--color-gray-50));
background-color: transparent;
}
.cm-editor.cm-focused .cm-activeLineGutter,
.cm-editor.cm-focused .cm-activeLine {
background-color: hsl(var(--color-gray-100)/0.3);
}
.cm-editor * {
@@ -101,9 +106,9 @@
}
.cm-editor .cm-selectionBackground {
background-color: hsl(var(--color-gray-100));
background-color: hsl(var(--color-gray-200));
}
.cm-editor.cm-focused .cm-selectionBackground {
background-color: hsl(var(--color-gray-100));
background-color: hsl(var(--color-gray-200));
}

View File

@@ -1,14 +1,42 @@
import useCodeMirror from '../../hooks/useCodemirror';
import './Editor.css';
import { useEffect, useMemo, useRef } from 'react';
import { EditorView } from 'codemirror';
import { baseExtensions, syntaxExtension } from './extensions';
import { EditorState } from '@codemirror/state';
interface Props {
contentType: string;
initialValue?: string;
value?: string;
defaultValue?: string | null;
onChange?: (value: string) => void;
}
export default function Editor(props: Props) {
const { ref } = useCodeMirror(props);
export default function Editor({ contentType, defaultValue, onChange }: Props) {
const ref = useRef<HTMLDivElement>(null);
const extensions = useMemo(() => {
const ext = syntaxExtension(contentType);
return [
...baseExtensions,
...(ext ? [ext] : []),
EditorView.updateListener.of((update) => {
if (typeof onChange === 'function') {
onChange(update.state.doc.toString());
}
}),
];
}, [contentType]);
useEffect(() => {
if (ref.current === null) return;
const view = new EditorView({
state: EditorState.create({
doc: defaultValue ?? '',
extensions: extensions,
}),
parent: ref.current,
});
return () => view?.destroy();
}, [ref.current]);
return <div ref={ref} className="cm-wrapper" />;
}

View File

@@ -0,0 +1,99 @@
import {
bracketMatching,
defaultHighlightStyle,
foldGutter,
foldKeymap,
HighlightStyle,
indentOnInput,
LanguageSupport,
syntaxHighlighting,
} from '@codemirror/language';
import {
crosshairCursor,
drawSelection,
dropCursor,
highlightActiveLine,
highlightActiveLineGutter,
highlightSpecialChars,
keymap,
lineNumbers,
rectangularSelection,
} from '@codemirror/view';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
import {
autocompletion,
closeBrackets,
closeBracketsKeymap,
completionKeymap,
} from '@codemirror/autocomplete';
import { lintKeymap } from '@codemirror/lint';
import { EditorState } from '@codemirror/state';
import { json } from '@codemirror/lang-json';
import { javascript } from '@codemirror/lang-javascript';
import { html } from '@codemirror/lang-html';
import { tags } from '@lezer/highlight';
export const myHighlightStyle = HighlightStyle.define([
{
tag: [tags.documentMeta, tags.blockComment, tags.lineComment, tags.docComment, tags.comment],
color: '#757b93',
},
{ tag: tags.name, color: '#4699de' },
{ tag: tags.variableName, color: '#31c434' },
{ tag: tags.bool, color: '#e864f6' },
{ tag: tags.attributeName, color: '#8f68ff' },
{ tag: tags.attributeValue, color: '#ff964b' },
{ tag: [tags.keyword, tags.string], color: '#e8b045' },
{ tag: tags.comment, color: '#cec4cc', fontStyle: 'italic' },
]);
const syntaxExtensions: Record<string, LanguageSupport> = {
'application/json': json(),
'application/javascript': javascript(),
'text/html': html(),
};
export function syntaxExtension(contentType: string): LanguageSupport | undefined {
return syntaxExtensions[contentType];
}
export const baseExtensions = [
lineNumbers(),
highlightActiveLineGutter(),
highlightSpecialChars(),
history(),
foldGutter({
markerDOM: (open) => {
const el = document.createElement('div');
el.classList.add('fold-gutter-icon');
el.tabIndex = -1;
if (open) {
el.setAttribute('data-open', '');
}
return el;
},
}),
drawSelection(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
bracketMatching(),
closeBrackets(),
autocompletion(),
rectangularSelection(),
crosshairCursor(),
highlightActiveLine(),
highlightSelectionMatches(),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
...lintKeymap,
]),
syntaxHighlighting(myHighlightStyle),
];

View File

@@ -1,12 +1,16 @@
import { forwardRef } from 'react';
import { Icon, IconProps } from './Icon';
import { Button, ButtonProps } from './Button';
type Props = Omit<IconProps, 'size'> & ButtonProps<typeof Button>;
export function IconButton({ icon, spin, ...props }: Props) {
export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
{ icon, spin, ...props }: Props,
ref,
) {
return (
<Button className="group" {...props}>
<Button ref={ref} className="group" {...props}>
<Icon icon={icon} spin={spin} className="text-gray-700 group-hover:text-gray-900" />
</Button>
);
}
});

View File

@@ -0,0 +1,10 @@
import {Outlet} from 'react-router-dom';
export function Layout() {
return (
<div className="w-full h-full">
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { useDeleteAllResponses, useDeleteResponse, useResponses } from '../hooks/useResponses';
import { motion } from 'framer-motion';
import { HStack, VStack } from './Stacks';
import Editor from './Editor/Editor';
import { useMemo } from 'react';
import { WindowDragRegion } from './WindowDragRegion';
import { Dropdown } from './Dropdown';
import { IconButton } from './IconButton';
interface Props {
requestId: string;
error: string | null;
}
export function ResponsePane({ requestId, error }: Props) {
const responses = useResponses(requestId);
const response = responses.data[0];
const deleteResponse = useDeleteResponse(response);
const deleteAllResponses = useDeleteAllResponses(response?.requestId);
const contentType = useMemo(
() =>
response?.headers.find((h) => h.name.toLowerCase() === 'content-type')?.value ?? 'text/plain',
[response],
);
const contentForIframe: string = useMemo(() => {
if (response == null) return '';
if (response.body.includes('<head>')) {
return response.body.replace(/<head>/gi, `<head><base href="${response.url}"/>`);
}
return response.body;
}, [response?.id]);
return (
<VStack className="w-full">
<HStack as={WindowDragRegion} items="center" className="pl-1.5 pr-1">
<Dropdown
items={[
{
label: 'Clear Response',
onSelect: deleteResponse.mutate,
disabled: responses.data.length === 0,
},
{
label: 'Clear All Responses',
onSelect: deleteAllResponses.mutate,
disabled: responses.data.length === 0,
},
]}
>
<IconButton icon="gear" className="ml-auto" size="sm" />
</Dropdown>
</HStack>
<motion.div animate={{ opacity: 1 }} initial={{ opacity: 0 }} className="w-full h-full">
<VStack className="pr-3 pl-1.5 py-3" space={3}>
{error && <div className="text-white bg-red-500 px-3 py-1 rounded">{error}</div>}
{response && (
<>
<HStack
items="center"
className="italic text-gray-500 text-sm w-full pointer-events-none h-10 mb-3 flex-shrink-0"
>
{response.status}
{response.statusReason && ` ${response.statusReason}`}
&nbsp;&bull;&nbsp;
{response.elapsed}ms
</HStack>
{contentType.includes('html') ? (
<iframe
title="Response preview"
srcDoc={contentForIframe}
sandbox="allow-scripts allow-same-origin"
className="h-full w-full rounded-lg"
/>
) : response?.body ? (
<Editor
key={response.body}
defaultValue={response?.body}
contentType={contentType}
/>
) : null}
</>
)}
</VStack>
</motion.div>
</VStack>
);
}

View File

@@ -5,17 +5,18 @@ import { Button } from './Button';
import useTheme from '../hooks/useTheme';
import { HStack, VStack } from './Stacks';
import { WindowDragRegion } from './WindowDragRegion';
import { Request } from '../hooks/useWorkspaces';
import { invoke } from '@tauri-apps/api';
import { HttpRequest } from '../lib/models';
import { Link } from 'react-router-dom';
import { useRequestCreate } from '../hooks/useRequest';
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
workspaceId: string;
requests: Request[];
requestId?: string;
requests: HttpRequest[];
activeRequestId?: string;
}
export function Sidebar({ className, requestId, workspaceId, requests, ...props }: Props) {
export function Sidebar({ className, activeRequestId, workspaceId, requests, ...props }: Props) {
const createRequest = useRequestCreate(workspaceId);
const { toggleTheme } = useTheme();
return (
<div
@@ -27,31 +28,30 @@ export function Sidebar({ className, requestId, workspaceId, requests, ...props
<IconButton
size="sm"
icon="camera"
onClick={async () => {
const req = await invoke('upsert_request', {
workspaceId,
id: null,
name: 'Test Request',
});
console.log('UPSERTED', req);
}}
onClick={() => createRequest.mutate({ name: 'Test Request' })}
/>
</HStack>
<VStack as="ul" className="py-2" space={1}>
{requests.map((r) => (
<li key={r.id} className="mx-2">
<Button
as={Link}
to={`/workspaces/${workspaceId}/requests/${r.id}`}
className={classnames('w-full', requestId === r.id && 'bg-gray-50')}
size="sm"
justify="start"
>
{r.name}
</Button>
</li>
<SidebarItem key={r.id} request={r} active={r.id === activeRequestId} />
))}
</VStack>
</div>
);
}
function SidebarItem({ request, active }: { request: HttpRequest; active: boolean }) {
return (
<li key={request.id} className="mx-2">
<Button
as={Link}
to={`/workspaces/${request.workspaceId}/requests/${request.id}`}
className={classnames('w-full', active && 'bg-gray-50')}
size="sm"
justify="start"
>
{request.name}
</Button>
</li>
);
}

View File

@@ -7,9 +7,9 @@ import { IconButton } from './IconButton';
interface Props {
sendRequest: () => void;
loading: boolean;
method: { label: string; value: string };
method: string;
url: string;
onMethodChange: (method: { label: string; value: string }) => void;
onMethodChange: (method: string) => void;
onUrlChange: (url: string) => void;
}
@@ -27,12 +27,12 @@ export function UrlBar({ sendRequest, loading, onMethodChange, method, onUrlChan
label="Enter URL"
className="font-mono"
onChange={(e) => onUrlChange(e.currentTarget.value)}
value={url}
defaultValue={url}
placeholder="Enter a URL..."
leftSlot={
<DropdownMenuRadio
onValueChange={onMethodChange}
value={method.value}
onValueChange={(v) => onMethodChange(v.value)}
value={method.toUpperCase()}
items={[
{ label: 'GET', value: 'GET' },
{ label: 'PUT', value: 'PUT' },
@@ -43,8 +43,8 @@ export function UrlBar({ sendRequest, loading, onMethodChange, method, onUrlChan
{ label: 'HEAD', value: 'HEAD' },
]}
>
<Button disabled={loading} size="sm" className="ml-1" justify="start">
{method.label}
<Button type="button" disabled={loading} size="sm" className="ml-1" justify="start">
{method.toUpperCase()}
</Button>
</DropdownMenuRadio>
}