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

133
package-lock.json generated
View File

@@ -22,6 +22,8 @@
"@radix-ui/react-dropdown-menu": "^2.0.2", "@radix-ui/react-dropdown-menu": "^2.0.2",
"@radix-ui/react-icons": "^1.2.0", "@radix-ui/react-icons": "^1.2.0",
"@radix-ui/react-popover": "1.0.3", "@radix-ui/react-popover": "1.0.3",
"@radix-ui/react-scroll-area": "^1.0.2",
"@radix-ui/react-separator": "^1.0.1",
"@tanstack/react-query": "^4.24.10", "@tanstack/react-query": "^4.24.10",
"@tauri-apps/api": "^1.2.0", "@tauri-apps/api": "^1.2.0",
"classnames": "^2.3.2", "classnames": "^2.3.2",
@@ -50,6 +52,7 @@
"eslint-plugin-jsx-a11y": "^6.7.1", "eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.32.2", "eslint-plugin-react": "^7.32.2",
"postcss": "^8.4.21", "postcss": "^8.4.21",
"postcss-nesting": "^11.2.1",
"prettier": "^2.8.4", "prettier": "^2.8.4",
"tailwindcss": "^3.2.7", "tailwindcss": "^3.2.7",
"typescript": "^4.6.4", "typescript": "^4.6.4",
@@ -559,6 +562,23 @@
"w3c-keyname": "^2.2.4" "w3c-keyname": "^2.2.4"
} }
}, },
"node_modules/@csstools/selector-specificity": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-2.1.1.tgz",
"integrity": "sha512-jwx+WCqszn53YHOfvFMJJRd/B2GqkCBt+1MJSG6o5/s8+ytHMvDZXsJgUEWLk12UnLd7HYKac4BYU5i/Ron1Cw==",
"dev": true,
"engines": {
"node": "^14 || ^16 || >=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/csstools"
},
"peerDependencies": {
"postcss": "^8.4",
"postcss-selector-parser": "^6.0.10"
}
},
"node_modules/@emotion/is-prop-valid": { "node_modules/@emotion/is-prop-valid": {
"version": "0.8.8", "version": "0.8.8",
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz",
@@ -1267,6 +1287,14 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/@radix-ui/number": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.0.0.tgz",
"integrity": "sha512-Ofwh/1HX69ZfJRiRBMTy7rgjAzHmwe4kW9C9Y99HTRUcYLUuVT0KESFj15rPjRgKJs20GPq8Bm5aEDJ8DuA3vA==",
"dependencies": {
"@babel/runtime": "^7.13.10"
}
},
"node_modules/@radix-ui/primitive": { "node_modules/@radix-ui/primitive": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.0.tgz",
@@ -1585,6 +1613,40 @@
"react-dom": "^16.8 || ^17.0 || ^18.0" "react-dom": "^16.8 || ^17.0 || ^18.0"
} }
}, },
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.0.2.tgz",
"integrity": "sha512-k8VseTxI26kcKJaX0HPwkvlNBPTs56JRdYzcZ/vzrNUkDlvXBy8sMc7WvCpYzZkHgb+hd72VW9MqkqecGtuNgg==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/number": "1.0.0",
"@radix-ui/primitive": "1.0.0",
"@radix-ui/react-compose-refs": "1.0.0",
"@radix-ui/react-context": "1.0.0",
"@radix-ui/react-direction": "1.0.0",
"@radix-ui/react-presence": "1.0.0",
"@radix-ui/react-primitive": "1.0.1",
"@radix-ui/react-use-callback-ref": "1.0.0",
"@radix-ui/react-use-layout-effect": "1.0.0"
},
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.0.1.tgz",
"integrity": "sha512-uc6Izot0D8uVz6T2nSb/HI7OaxkeaD50GgKr3W6HORnbfGVrG7LWuy+g6Fd58n8wHbrRblSYJZEfcjgymMlJjw==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-primitive": "1.0.1"
},
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
}
},
"node_modules/@radix-ui/react-slot": { "node_modules/@radix-ui/react-slot": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz",
@@ -5484,6 +5546,26 @@
"postcss": "^8.2.14" "postcss": "^8.2.14"
} }
}, },
"node_modules/postcss-nesting": {
"version": "11.2.1",
"resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-11.2.1.tgz",
"integrity": "sha512-E6Jq74Jo/PbRAtZioON54NPhUNJYxVWhwxbweYl1vAoBYuGlDIts5yhtKiZFLvkvwT73e/9nFrW3oMqAtgG+GQ==",
"dev": true,
"dependencies": {
"@csstools/selector-specificity": "^2.0.0",
"postcss-selector-parser": "^6.0.10"
},
"engines": {
"node": "^14 || ^16 || >=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/csstools"
},
"peerDependencies": {
"postcss": "^8.4"
}
},
"node_modules/postcss-selector-parser": { "node_modules/postcss-selector-parser": {
"version": "6.0.11", "version": "6.0.11",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.11.tgz", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.11.tgz",
@@ -7164,6 +7246,13 @@
"w3c-keyname": "^2.2.4" "w3c-keyname": "^2.2.4"
} }
}, },
"@csstools/selector-specificity": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-2.1.1.tgz",
"integrity": "sha512-jwx+WCqszn53YHOfvFMJJRd/B2GqkCBt+1MJSG6o5/s8+ytHMvDZXsJgUEWLk12UnLd7HYKac4BYU5i/Ron1Cw==",
"dev": true,
"requires": {}
},
"@emotion/is-prop-valid": { "@emotion/is-prop-valid": {
"version": "0.8.8", "version": "0.8.8",
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz",
@@ -7639,6 +7728,14 @@
"fastq": "^1.6.0" "fastq": "^1.6.0"
} }
}, },
"@radix-ui/number": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.0.0.tgz",
"integrity": "sha512-Ofwh/1HX69ZfJRiRBMTy7rgjAzHmwe4kW9C9Y99HTRUcYLUuVT0KESFj15rPjRgKJs20GPq8Bm5aEDJ8DuA3vA==",
"requires": {
"@babel/runtime": "^7.13.10"
}
},
"@radix-ui/primitive": { "@radix-ui/primitive": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.0.tgz",
@@ -7888,6 +7985,32 @@
"@radix-ui/react-use-controllable-state": "1.0.0" "@radix-ui/react-use-controllable-state": "1.0.0"
} }
}, },
"@radix-ui/react-scroll-area": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.0.2.tgz",
"integrity": "sha512-k8VseTxI26kcKJaX0HPwkvlNBPTs56JRdYzcZ/vzrNUkDlvXBy8sMc7WvCpYzZkHgb+hd72VW9MqkqecGtuNgg==",
"requires": {
"@babel/runtime": "^7.13.10",
"@radix-ui/number": "1.0.0",
"@radix-ui/primitive": "1.0.0",
"@radix-ui/react-compose-refs": "1.0.0",
"@radix-ui/react-context": "1.0.0",
"@radix-ui/react-direction": "1.0.0",
"@radix-ui/react-presence": "1.0.0",
"@radix-ui/react-primitive": "1.0.1",
"@radix-ui/react-use-callback-ref": "1.0.0",
"@radix-ui/react-use-layout-effect": "1.0.0"
}
},
"@radix-ui/react-separator": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.0.1.tgz",
"integrity": "sha512-uc6Izot0D8uVz6T2nSb/HI7OaxkeaD50GgKr3W6HORnbfGVrG7LWuy+g6Fd58n8wHbrRblSYJZEfcjgymMlJjw==",
"requires": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-primitive": "1.0.1"
}
},
"@radix-ui/react-slot": { "@radix-ui/react-slot": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz",
@@ -10597,6 +10720,16 @@
"postcss-selector-parser": "^6.0.10" "postcss-selector-parser": "^6.0.10"
} }
}, },
"postcss-nesting": {
"version": "11.2.1",
"resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-11.2.1.tgz",
"integrity": "sha512-E6Jq74Jo/PbRAtZioON54NPhUNJYxVWhwxbweYl1vAoBYuGlDIts5yhtKiZFLvkvwT73e/9nFrW3oMqAtgG+GQ==",
"dev": true,
"requires": {
"@csstools/selector-specificity": "^2.0.0",
"postcss-selector-parser": "^6.0.10"
}
},
"postcss-selector-parser": { "postcss-selector-parser": {
"version": "6.0.11", "version": "6.0.11",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.11.tgz", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.11.tgz",

View File

@@ -27,6 +27,8 @@
"@radix-ui/react-dropdown-menu": "^2.0.2", "@radix-ui/react-dropdown-menu": "^2.0.2",
"@radix-ui/react-icons": "^1.2.0", "@radix-ui/react-icons": "^1.2.0",
"@radix-ui/react-popover": "1.0.3", "@radix-ui/react-popover": "1.0.3",
"@radix-ui/react-scroll-area": "^1.0.2",
"@radix-ui/react-separator": "^1.0.1",
"@tanstack/react-query": "^4.24.10", "@tanstack/react-query": "^4.24.10",
"@tauri-apps/api": "^1.2.0", "@tauri-apps/api": "^1.2.0",
"classnames": "^2.3.2", "classnames": "^2.3.2",
@@ -55,6 +57,7 @@
"eslint-plugin-jsx-a11y": "^6.7.1", "eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.32.2", "eslint-plugin-react": "^7.32.2",
"postcss": "^8.4.21", "postcss": "^8.4.21",
"postcss-nesting": "^11.2.1",
"prettier": "^2.8.4", "prettier": "^2.8.4",
"tailwindcss": "^3.2.7", "tailwindcss": "^3.2.7",
"typescript": "^4.6.4", "typescript": "^4.6.4",

View File

@@ -1,6 +1,7 @@
module.exports = { module.exports = {
plugins: { plugins: [
tailwindcss: {}, require('tailwindcss'),
autoprefixer: {}, require('autoprefixer'),
}, require('postcss-nesting')
]
} }

View File

@@ -1,7 +1,7 @@
use tauri::{Runtime, Window}; use tauri::{Runtime, Window};
const TRAFFIC_LIGHT_OFFSET_X: f64 = 15.0; const TRAFFIC_LIGHT_OFFSET_X: f64 = 15.0;
const TRAFFIC_LIGHT_OFFSET_Y: f64 = 20.0; const TRAFFIC_LIGHT_OFFSET_Y: f64 = 26.0;
pub trait WindowExt { pub trait WindowExt {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]

View File

@@ -1,19 +1,17 @@
import classnames from 'classnames';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Editor from './components/Editor/Editor';
import { HStack, VStack } from './components/Stacks';
import { WindowDragRegion } from './components/WindowDragRegion';
import { Sidebar } from './components/Sidebar';
import { UrlBar } from './components/UrlBar';
import { Grid } from './components/Grid';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { Grid } from './components/Grid';
import { RequestPane } from './components/RequestPane';
import { ResponsePane } from './components/ResponsePane';
import { Sidebar } from './components/Sidebar';
import { HStack } from './components/Stacks';
import { import {
useDeleteRequest, useDeleteRequest,
useRequests, useRequests,
useRequestUpdate, useRequestUpdate,
useSendRequest, useSendRequest,
} from './hooks/useRequest'; } from './hooks/useRequest';
import { ResponsePane } from './components/ResponsePane';
import { IconButton } from './components/IconButton';
type Params = { type Params = {
workspaceId: string; workspaceId: string;
@@ -26,56 +24,19 @@ function App() {
const { data: requests } = useRequests(workspaceId); const { data: requests } = useRequests(workspaceId);
const request = requests?.find((r) => r.id === p.requestId); const request = requests?.find((r) => r.id === p.requestId);
const updateRequest = useRequestUpdate(request ?? null);
const sendRequest = useSendRequest(request ?? null);
const deleteRequest = useDeleteRequest(request ?? null);
useEffect(() => {
const listener = async (e: KeyboardEvent) => {
if (e.metaKey && (e.key === 'Enter' || e.key === 'r')) {
await sendRequest.mutate();
}
};
document.documentElement.addEventListener('keypress', listener);
return () => document.documentElement.removeEventListener('keypress', listener);
}, []);
const [screenWidth, setScreenWidth] = useState(window.innerWidth); const [screenWidth, setScreenWidth] = useState(window.innerWidth);
useEffect(() => { useEffect(() => {
console.log('SCREEN WIDTH', document.documentElement.clientWidth);
window.addEventListener('resize', () => setScreenWidth(window.innerWidth)); window.addEventListener('resize', () => setScreenWidth(window.innerWidth));
}, []); }, []);
const isH = screenWidth > 900;
return ( return (
<div className="grid grid-cols-[auto_1fr] h-full text-gray-900"> <div className="grid grid-cols-[auto_1fr] h-full text-gray-900">
<Sidebar requests={requests ?? []} workspaceId={workspaceId} activeRequestId={request?.id} /> <Sidebar requests={requests ?? []} workspaceId={workspaceId} activeRequestId={request?.id} />
{request && ( {request && (
<Grid cols={screenWidth > 700 ? 2 : 1} rows={screenWidth > 700 ? 1 : 2}> <Grid cols={isH ? 2 : 1} rows={isH ? 1 : 2} gap={2}>
<VStack className="w-full"> <RequestPane request={request} className={classnames(isH ? 'pr-0' : 'pb-0')} />
<HStack as={WindowDragRegion} items="center" className="pl-3 pr-1.5"> <ResponsePane requestId={request.id} className={classnames(isH ? 'pl-0' : 'pt-0')} />
Test Request
<IconButton size="sm" icon="trash" onClick={() => deleteRequest.mutate()} />
</HStack>
<VStack className="pl-3 px-1.5 py-3" space={3}>
<UrlBar
key={request.id}
method={request.method}
url={request.url}
loading={sendRequest.isLoading}
onMethodChange={(method) => updateRequest.mutate({ method })}
onUrlChange={(url) => updateRequest.mutate({ url })}
sendRequest={sendRequest.mutate}
/>
<Editor
valueKey={request.id}
useTemplating
defaultValue={request.body ?? ''}
contentType="application/json"
onChange={(body) => updateRequest.mutate({ body })}
/>
</VStack>
</VStack>
<ResponsePane requestId={request.id} error={sendRequest.error} />
</Grid> </Grid>
)} )}
</div> </div>

View File

@@ -8,14 +8,22 @@ import type {
import { forwardRef } from 'react'; import { forwardRef } from 'react';
import { Icon } from './Icon'; import { Icon } from './Icon';
export interface ButtonProps<T extends ElementType> const colorStyles = {
extends ButtonHTMLAttributes<HTMLButtonElement> { default: 'hover:bg-gray-500/10 text-gray-600',
color?: 'primary' | 'secondary' | 'warning' | 'danger'; 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'; size?: 'xs' | 'sm' | 'md';
justify?: 'start' | 'center'; justify?: 'start' | 'center';
forDropdown?: boolean; forDropdown?: boolean;
as?: T; as?: T;
} };
export const Button = forwardRef(function Button<T extends ElementType>( export const Button = forwardRef(function Button<T extends ElementType>(
{ {
@@ -37,18 +45,14 @@ export const Button = forwardRef(function Button<T extends ElementType>(
type="button" type="button"
className={classnames( className={classnames(
className, 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]', // 'active:translate-y-[0.5px] active:scale-[0.99]',
colorStyles[color || 'default'],
justify === 'start' && 'justify-start', justify === 'start' && 'justify-start',
justify === 'center' && 'justify-center', justify === 'center' && 'justify-center',
size === 'md' && 'h-10 px-4', size === 'md' && 'h-10 px-4',
size === 'sm' && 'h-8 px-3 text-sm', size === 'sm' && 'h-8 px-3 text-sm',
size === 'xs' && 'h-7 px-3 text-sm', size === 'xs' && 'h-6 px-3 text-xs',
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',
)} )}
{...props} {...props}
> >

View File

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

View File

@@ -5,45 +5,62 @@
} }
.cm-wrapper .cm-editor { .cm-wrapper .cm-editor {
@apply inset-0;
position: absolute !important; position: absolute !important;
left: 0; font-size: 0.85em;
right: 0;
top: 0;
bottom: 0;
} }
.cm-editor { .cm-editor {
@apply w-full block; @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 { .cm-multiline {
@apply text-xs text-white bg-blue-400 py-[1px] px-1 mx-[1px] rounded cursor-default hover:bg-blue-500; .cm-editor {
text-shadow: 0 0 1px rgba(0, 0, 0, 0.9); @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 { .cm-multiline .cm-editor.cm-focused .cm-scroller {
box-shadow: 0 0 0 1px hsl(var(--color-blue-400)/0.4); /* Active border state if we want it */
} /*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;
} }
.cm-singleline .cm-editor .cm-scroller { .cm-singleline .cm-editor .cm-scroller {
@@ -52,7 +69,8 @@
} }
.cm-editor .cm-gutters { .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 { .cm-editor .cm-gutterElement {
@@ -113,39 +131,56 @@
@apply bg-gray-200; @apply bg-gray-200;
} }
/* --> Add padding to container. For some reason, using padding on both adds an extra .cm-singleline .cm-editor {
* 1px offset so we need to use a combination of padding and margin. .cm-content {
*/ @apply h-full flex items-center;
.cm-editor .cm-gutters { }
@apply pt-1;
} }
.cm-editor .cm-content { .cm-scroller {
@apply mt-1; &::-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 { .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 { &.cm-tooltip-autocomplete {
@apply p-1 max-h-[40vh]; & > ul {
} @apply p-1 max-h-[40vh];
}
.cm-tooltip.cm-tooltip.cm-tooltip-autocomplete > ul > li { & > ul > li {
@apply cursor-default py-1 px-2 rounded-sm text-gray-500; @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] { & > ul > li[aria-selected] {
@apply bg-gray-50 text-gray-800; @apply bg-gray-50 text-gray-800;
} }
.cm-tooltip.cm-tooltip.cm-tooltip-autocomplete .cm-completionIcon { & > ul > li:hover {
@apply text-sm; @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 { defaultKeymap } from '@codemirror/commands';
import type { Extension } from '@codemirror/state';
import { Compartment, EditorState } from '@codemirror/state'; import { Compartment, EditorState } from '@codemirror/state';
import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view'; import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view';
import classnames from 'classnames'; import classnames from 'classnames';
@@ -9,29 +10,30 @@ import './Editor.css';
import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions'; import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions';
import { singleLineExt } from './singleLine'; import { singleLineExt } from './singleLine';
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> { export interface EditorProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
contentType: string; contentType?: string;
valueKey?: string; autoFocus?: boolean;
valueKey?: string | number;
defaultValue?: string;
placeholder?: string; placeholder?: string;
tooltipContainer?: HTMLElement; tooltipContainer?: HTMLElement;
useTemplating?: boolean; useTemplating?: boolean;
onChange?: (value: string) => void; onChange?: (value: string) => void;
onSubmit?: () => void;
singleLine?: boolean; singleLine?: boolean;
} }
export default function Editor({ export default function Editor({
contentType, contentType,
autoFocus,
placeholder, placeholder,
valueKey, valueKey,
useTemplating, useTemplating,
defaultValue, defaultValue,
onChange, onChange,
onSubmit,
className, className,
singleLine, singleLine,
...props ...props
}: Props) { }: EditorProps) {
const [cm, setCm] = useState<{ view: EditorView; langHolder: Compartment } | null>(null); const [cm, setCm] = useState<{ view: EditorView; langHolder: Compartment } | null>(null);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const extensions = useMemo( const extensions = useMemo(
@@ -39,7 +41,6 @@ export default function Editor({
getExtensions({ getExtensions({
container: ref.current, container: ref.current,
placeholder, placeholder,
onSubmit,
singleLine, singleLine,
onChange, onChange,
contentType, contentType,
@@ -48,25 +49,23 @@ export default function Editor({
[contentType, ref.current], [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 // Create codemirror instance when ref initializes
useEffect(() => { useEffect(() => {
if (ref.current === null) return; if (ref.current === null) return;
let view: EditorView | null = null; let view: EditorView | null = null;
try { try {
const langHolder = new Compartment(); const langHolder = new Compartment();
const langExt = getLanguageExtension({ contentType, useTemplating });
const state = EditorState.create({
doc: `${defaultValue ?? ''}`,
extensions: [...extensions, langHolder.of(langExt)],
});
view = new EditorView({ view = new EditorView({
state: newState(langHolder), state,
parent: ref.current, parent: ref.current,
}); });
setCm({ view, langHolder }); setCm({ view, langHolder });
if (autoFocus && view) view.focus();
} catch (e) { } catch (e) {
console.log('Failed to initialize Codemirror', e); console.log('Failed to initialize Codemirror', e);
} }
@@ -108,17 +107,19 @@ function getExtensions({
singleLine, singleLine,
placeholder, placeholder,
onChange, onChange,
onSubmit,
contentType, contentType,
useTemplating, useTemplating,
}: Pick< }: Pick<
Props, EditorProps,
'singleLine' | 'onChange' | 'onSubmit' | 'contentType' | 'useTemplating' | 'placeholder' 'singleLine' | 'onChange' | 'contentType' | 'useTemplating' | 'placeholder'
> & { container: HTMLDivElement | null }) { > & { container: HTMLDivElement | null }) {
const ext = getLanguageExtension({ contentType, useTemplating }); 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 // TODO: Ensure tooltips render inside the dialog if we are in one.
const parent = container?.closest<HTMLDivElement>('.dialog-content') ?? undefined; const parent =
container?.closest<HTMLDivElement>('[role="dialog"]') ??
document.querySelector<HTMLDivElement>('#cm-portal') ??
undefined;
return [ return [
...baseExtensions, ...baseExtensions,
@@ -130,11 +131,15 @@ function getExtensions({
...(placeholder ? [placeholderExt(placeholder)] : []), ...(placeholder ? [placeholderExt(placeholder)] : []),
// Handle onSubmit // Handle onSubmit
...(onSubmit ...(singleLine
? [ ? [
EditorView.domEventHandlers({ EditorView.domEventHandlers({
keydown: (e) => { 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, keymap,
lineNumbers, lineNumbers,
rectangularSelection, rectangularSelection,
tooltips,
} from '@codemirror/view'; } from '@codemirror/view';
import { tags as t } from '@lezer/highlight'; import { tags as t } from '@lezer/highlight';
import { twig } from './twig/extension'; import { twig } from './twig/extension';
@@ -90,10 +89,10 @@ export function getLanguageExtension({
contentType, contentType,
useTemplating, useTemplating,
}: { }: {
contentType: string; contentType?: string;
useTemplating?: boolean; useTemplating?: boolean;
}) { }) {
const justContentType = contentType.split(';')[0] ?? contentType; const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
const base = syntaxExtensions[justContentType] ?? json(); const base = syntaxExtensions[justContentType] ?? json();
if (!useTemplating) { if (!useTemplating) {
return [base]; return [base];
@@ -108,7 +107,7 @@ export const baseExtensions = [
drawSelection(), drawSelection(),
dropCursor(), dropCursor(),
bracketMatching(), bracketMatching(),
autocompletion({ closeOnBlur: true }), autocompletion({ closeOnBlur: true, interactionDelay: 200 }),
syntaxHighlighting(myHighlightStyle), syntaxHighlighting(myHighlightStyle),
EditorState.allowMultipleSelections.of(true), EditorState.allowMultipleSelections.of(true),
]; ];

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,22 @@
import classnames from 'classnames'; import classnames from 'classnames';
import type { InputHTMLAttributes, ReactNode } from 'react'; import type { InputHTMLAttributes, ReactNode } from 'react';
import type { EditorProps } from './Editor/Editor';
import Editor from './Editor/Editor'; import Editor from './Editor/Editor';
import { HStack, VStack } from './Stacks'; 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; name: string;
label: string; label: string;
hideLabel?: boolean; hideLabel?: boolean;
labelClassName?: string; labelClassName?: string;
containerClassName?: string; containerClassName?: string;
onChange?: (value: string) => void; onChange?: (value: string) => void;
onSubmit?: () => void; useEditor?: Pick<EditorProps, 'contentType' | 'useTemplating' | 'valueKey'>;
contentType?: string; defaultValue?: string;
useTemplating?: boolean;
useEditor?: boolean;
leftSlot?: ReactNode; leftSlot?: ReactNode;
rightSlot?: ReactNode; rightSlot?: ReactNode;
size?: 'sm' | 'md'; size?: 'sm' | 'md';
@@ -25,13 +28,10 @@ export function Input({
className, className,
containerClassName, containerClassName,
labelClassName, labelClassName,
onSubmit, onChange,
placeholder, placeholder,
useTemplating,
size = 'md', size = 'md',
useEditor, useEditor,
contentType,
onChange,
name, name,
leftSlot, leftSlot,
rightSlot, rightSlot,
@@ -55,7 +55,7 @@ export function Input({
items="center" items="center"
className={classnames( className={classnames(
containerClassName, 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', 'border border-gray-500/10 focus-within:border-blue-400/40',
size === 'md' && 'h-10', size === 'md' && 'h-10',
size === 'sm' && 'h-8', size === 'sm' && 'h-8',
@@ -66,13 +66,15 @@ export function Input({
<Editor <Editor
id={id} id={id}
singleLine singleLine
contentType={contentType ?? 'text/plain'}
useTemplating={useTemplating}
defaultValue={defaultValue} defaultValue={defaultValue}
onChange={onChange}
onSubmit={onSubmit}
placeholder={placeholder} 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 <input
@@ -82,7 +84,7 @@ export function Input({
defaultValue={defaultValue} defaultValue={defaultValue}
className={classnames( className={classnames(
className, 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', leftSlot && '!pl-1',
rightSlot && '!pr-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 { useEffect, useMemo, useState } from 'react';
import { useDeleteAllResponses, useDeleteResponse, useResponses } from '../hooks/useResponses'; import { useDeleteAllResponses, useDeleteResponse, useResponses } from '../hooks/useResponses';
import { Divider } from './Divider';
import { Dropdown } from './Dropdown'; import { Dropdown } from './Dropdown';
import Editor from './Editor/Editor'; import Editor from './Editor/Editor';
import { Icon } from './Icon'; import { Icon } from './Icon';
import { IconButton } from './IconButton'; import { IconButton } from './IconButton';
import { HStack, VStack } from './Stacks'; import type { LayoutPaneProps } from './LayoutPane';
import { WindowDragRegion } from './WindowDragRegion'; import { LayoutPane } from './LayoutPane';
import { HStack } from './Stacks';
interface Props { interface Props extends LayoutPaneProps {
requestId: string; 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 [activeResponseId, setActiveResponseId] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'pretty' | 'raw'>('pretty'); const [viewMode, setViewMode] = useState<'pretty' | 'raw'>('pretty');
const responses = useResponses(requestId); const responses = useResponses(requestId);
@@ -22,7 +23,6 @@ export function ResponsePane({ requestId, error }: Props) {
: responses.data[responses.data.length - 1]; : responses.data[responses.data.length - 1];
const deleteResponse = useDeleteResponse(response); const deleteResponse = useDeleteResponse(response);
const deleteAllResponses = useDeleteAllResponses(response?.requestId); const deleteAllResponses = useDeleteAllResponses(response?.requestId);
error = response?.error ?? error;
useEffect(() => { useEffect(() => {
setActiveResponseId(null); setActiveResponseId(null);
@@ -44,49 +44,29 @@ export function ResponsePane({ requestId, error }: Props) {
}, [response?.body, contentType]); }, [response?.body, contentType]);
return ( return (
<VStack className="w-full"> <LayoutPane className={classnames(className)} {...props}>
<HStack as={WindowDragRegion} items="center" className="pl-1.5 pr-1"> <div className="max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 py-1 px-2">
<Dropdown {/*<HStack as={WindowDragRegion} items="center" className="pl-1.5 pr-1">*/}
items={[ {/*</HStack>*/}
{ {response?.error && (
label: 'Clear Response', <div className="text-white bg-red-500 px-2 py-1 rounded">{response.error}</div>
onSelect: deleteResponse.mutate, )}
disabled: responses.data.length === 0, {response && (
}, <>
{ <div className="mb-2">
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 && (
<>
<HStack <HStack
data-tauri-drag-region
items="center" 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"> <div data-tauri-drag-region className="whitespace-nowrap">
{response.updatedAt.toISOString()}
&nbsp;&bull;&nbsp;
{response.status} {response.status}
{response.statusReason && ` ${response.statusReason}`} {response.statusReason && ` ${response.statusReason}`}
&nbsp;&bull;&nbsp; &nbsp;&bull;&nbsp;
{response.elapsed}ms &nbsp;&bull;&nbsp; {response.elapsed}ms &nbsp;&bull;&nbsp;
{Math.round(response.body.length / 1000)} KB {Math.round(response.body.length / 1000)} KB
</div> </div>
<HStack items="center" className="ml-auto"> <HStack items="center" className="ml-auto">
{contentType.includes('html') && ( {contentType.includes('html') && (
<IconButton <IconButton
@@ -96,26 +76,49 @@ export function ResponsePane({ requestId, error }: Props) {
onClick={() => setViewMode((m) => (m === 'pretty' ? 'raw' : 'pretty'))} 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>
</HStack> </HStack>
{viewMode === 'pretty' && contentForIframe !== null ? ( <Divider />
<iframe </div>
title="Response preview" {viewMode === 'pretty' && contentForIframe !== null ? (
srcDoc={contentForIframe} <iframe
sandbox="allow-scripts allow-same-origin" title="Response preview"
className="h-full w-full rounded-lg" srcDoc={contentForIframe}
/> sandbox="allow-scripts allow-same-origin"
) : response?.body ? ( className="h-full w-full rounded-lg"
<Editor />
valueKey={`${contentType}:${response.body}`} ) : response?.body ? (
defaultValue={response?.body} <Editor
contentType={contentType} valueKey={`${contentType}:${response.body}`}
/> defaultValue={response?.body}
) : null} contentType={contentType}
</> />
)} ) : null}
</VStack> </>
</motion.div> )}
</VStack> </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 type { HttpRequest } from '../lib/models';
import { Button } from './Button'; import { Button } from './Button';
import { Dialog } from './Dialog'; import { Dialog } from './Dialog';
import { DropdownMenuRadio } from './Dropdown';
import { HeaderEditor } from './HeaderEditor'; import { HeaderEditor } from './HeaderEditor';
import { IconButton } from './IconButton'; import { IconButton } from './IconButton';
import { Input } from './Input'; import { Input } from './Input';
@@ -24,10 +25,7 @@ export function Sidebar({ className, activeRequestId, workspaceId, requests, ...
const { toggleTheme } = useTheme(); const { toggleTheme } = useTheme();
const [open, setOpen] = useState<boolean>(false); const [open, setOpen] = useState<boolean>(false);
return ( return (
<div <div className={classnames(className, 'w-52 bg-gray-50 h-full')} {...props}>
className={classnames(className, 'w-52 bg-gray-50/40 h-full border-gray-500/10')}
{...props}
>
<HStack as={WindowDragRegion} items="center" className="pr-1" justify="end"> <HStack as={WindowDragRegion} items="center" className="pr-1" justify="end">
<Dialog wide open={open} onOpenChange={setOpen} title="Edit Headers"> <Dialog wide open={open} onOpenChange={setOpen} title="Edit Headers">
<HeaderEditor /> <HeaderEditor />
@@ -51,7 +49,7 @@ export function Sidebar({ className, activeRequestId, workspaceId, requests, ...
}} }}
/> />
</HStack> </HStack>
<VStack as="ul" className="py-3" space={1}> <VStack as="ul" className="pb-3" space={1}>
{requests.map((r) => ( {requests.map((r) => (
<SidebarItem key={r.id} request={r} active={r.id === activeRequestId} /> <SidebarItem key={r.id} request={r} active={r.id === activeRequestId} />
))} ))}
@@ -66,7 +64,7 @@ function SidebarItem({ request, active }: { request: HttpRequest; active: boolea
<Button <Button
as={Link} as={Link}
to={`/workspaces/${request.workspaceId}/requests/${request.id}`} 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" size="xs"
justify="start" justify="start"
> >

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
import classnames from 'classnames'; import classnames from 'classnames';
import { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
type Props = HTMLAttributes<HTMLDivElement>; type Props = HTMLAttributes<HTMLDivElement>;
export function WindowDragRegion({ className, ...props }: Props) { export function WindowDragRegion({ className, ...props }: Props) {
return ( return (
<div <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="" data-tauri-drag-region=""
{...props} {...props}
/> />

View File

@@ -29,6 +29,18 @@ html, body, #root {
transition: background-color var(--transition-duration), border-color var(--transition-duration); transition: background-color var(--transition-duration), border-color var(--transition-duration);
} }
/*.hide-scrollbar {*/
/* &::-webkit-scrollbar-corner,*/
/* &::-webkit-scrollbar {*/
/* @apply w-[5px] h-[5px];*/
/* background-color: transparent; !* or add it to the track *!*/
/* }*/
/* &::-webkit-scrollbar-thumb {*/
/* @apply bg-gray-100 bg-opacity-20 rounded-full;*/
/* }*/
/*}*/
@layer base { @layer base {
:root, [data-theme="light"] { :root, [data-theme="light"] {
/* Colors */ /* Colors */

View File

@@ -98,7 +98,7 @@ const router = createBrowserRouter([
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MotionConfig transition={{ duration: 0.15 }}> <MotionConfig transition={{ duration: 0.1 }}>
<HelmetProvider> <HelmetProvider>
<RouterProvider router={router} /> <RouterProvider router={router} />
</HelmetProvider> </HelmetProvider>

View File

@@ -20,6 +20,7 @@ module.exports = {
white: 'hsl(var(--color-white) / <alpha-value>)', white: 'hsl(var(--color-white) / <alpha-value>)',
black: 'hsl(var(--color-black) / <alpha-value>)', black: 'hsl(var(--color-black) / <alpha-value>)',
background: 'hsl(var(--color-background) / <alpha-value>)', background: 'hsl(var(--color-background) / <alpha-value>)',
placeholder: 'hsl(var(--color-gray-200) / <alpha-value>)',
gray: color('gray'), gray: color('gray'),
orange: color('orange'), orange: color('orange'),
blue: color('blue'), blue: color('blue'),