2024.5.0 (#39)

This commit is contained in:
Gregory Schier
2024-06-03 14:08:24 -07:00
committed by GitHub
parent 60e469a1c9
commit 4f9a7e9c88
197 changed files with 12283 additions and 3505 deletions

View File

@@ -3,6 +3,7 @@ import { routePaths, useAppRoutes } from '../hooks/useAppRoutes';
import { DefaultLayout } from './DefaultLayout';
import { RedirectToLatestWorkspace } from './RedirectToLatestWorkspace';
import RouteError from './RouteError';
import { Settings } from './Settings/Settings';
import Workspace from './Workspace';
const router = createBrowserRouter([
@@ -36,6 +37,12 @@ const router = createBrowserRouter([
path: '/workspaces/:workspaceId/environments/:environmentId/requests/:requestId',
element: <RedirectLegacyEnvironmentURLs />,
},
{
path: routePaths.workspaceSettings({
workspaceId: ':workspaceId',
}),
element: <Settings />,
},
],
},
]);

View File

@@ -45,11 +45,11 @@ export function BinaryFileEditor({
return (
<VStack space={2}>
<HStack space={2} alignItems="center">
<Button variant="border" color="gray" size="sm" onClick={handleClick}>
<HStack space={2}>
<Button variant="border" color="secondary" size="sm" onClick={handleClick}>
Choose File
</Button>
<div className="text-xs font-mono truncate rtl pr-3 text-gray-800">
<div className="text-sm font-mono truncate rtl pr-3 text-fg">
{/* Special character to insert ltr text in rtl element without making things wonky */}
&#x200E;
{filePath ?? 'Select File'}
@@ -57,22 +57,22 @@ export function BinaryFileEditor({
</HStack>
{filePath != null && mimeType !== contentType && !ignoreContentType.value && (
<Banner className="mt-3 !py-5">
<div className="text-sm mb-4 text-center">
<div className="mb-4 text-center">
<div>Set Content-Type header</div>
<InlineCode>{mimeType}</InlineCode> for current request?
</div>
<HStack space={1.5} justifyContent="center">
<Button size="sm" variant="border" onClick={() => ignoreContentType.set(true)}>
Ignore
</Button>
<Button
variant="solid"
color="gray"
size="xs"
color="primary"
size="sm"
onClick={() => onChangeContentType(mimeType)}
>
Set Header
</Button>
<Button size="xs" variant="border" onClick={() => ignoreContentType.set(true)}>
Ignore
</Button>
</HStack>
</Banner>
)}

View File

@@ -1,6 +1,6 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { useMemo, useCallback, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { getRecentEnvironments } from '../hooks/useRecentEnvironments';
@@ -117,8 +117,8 @@ function CommandPaletteItem({
<button
onClick={onClick}
className={classNames(
'w-full h-xs flex items-center rounded px-1.5 text-gray-600',
active && 'bg-highlightSecondary text-gray-800',
'w-full h-xs flex items-center rounded px-1.5 text-fg-subtle',
active && 'bg-background-highlight-secondary text-fg',
)}
>
{children}

View File

@@ -28,7 +28,7 @@ export const CookieDialog = function ({ cookieJarId }: Props) {
return (
<div className="pb-2">
<table className="w-full text-xs mb-auto min-w-full max-w-full divide-y">
<table className="w-full text-sm mb-auto min-w-full max-w-full divide-y divide-background-highlight">
<thead>
<tr>
<th className="py-2 text-left">Domain</th>
@@ -36,13 +36,13 @@ export const CookieDialog = function ({ cookieJarId }: Props) {
<th className="py-2 pl-4"></th>
</tr>
</thead>
<tbody className="divide-y">
<tbody className="divide-y divide-background-highlight-secondary">
{cookieJar?.cookies.map((c) => (
<tr key={c.domain + c.raw_cookie}>
<td className="py-2 select-text cursor-text font-mono font-semibold max-w-0">
{cookieDomain(c)}
</td>
<td className="py-2 pl-4 select-text cursor-text font-mono text-gray-700 whitespace-nowrap overflow-x-auto max-w-[200px] hide-scrollbars">
<td className="py-2 pl-4 select-text cursor-text font-mono text-fg-subtle whitespace-nowrap overflow-x-auto max-w-[200px] hide-scrollbars">
{c.raw_cookie}
</td>
<td className="max-w-0 w-10">
@@ -53,11 +53,6 @@ export const CookieDialog = function ({ cookieJarId }: Props) {
title="Delete"
className="ml-auto"
onClick={async () => {
console.log(
'DELETE COOKIE',
c,
cookieJar.cookies.filter((c2) => c2 !== c).length,
);
await updateCookieJar.mutateAsync({
...cookieJar,
cookies: cookieJar.cookies.filter((c2) => c2 !== c),

View File

@@ -2,12 +2,26 @@ import { Outlet } from 'react-router-dom';
import { DialogProvider } from './DialogContext';
import { GlobalHooks } from './GlobalHooks';
import { ToastProvider } from './ToastContext';
import classNames from 'classnames';
import { useOsInfo } from '../hooks/useOsInfo';
import { motion } from 'framer-motion';
export function DefaultLayout() {
const osInfo = useOsInfo();
return (
<DialogProvider>
<ToastProvider>
<Outlet />
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.1, delay: 0.1 }}
className={classNames(
'w-full h-full',
osInfo?.osType === 'linux' && 'border border-background-highlight-secondary',
)}
>
<Outlet />
</motion.div>
<GlobalHooks />
</ToastProvider>
</DialogProvider>

View File

@@ -14,7 +14,7 @@ export const DropMarker = memo(
'relative w-full h-0 overflow-visible pointer-events-none',
)}
>
<div className="absolute z-50 left-2 right-2 -bottom-[0.1rem] h-[0.2rem] bg-blue-500/50 rounded-full" />
<div className="absolute z-50 left-2 right-2 -bottom-[0.1rem] h-[0.2rem] bg-fg-primary rounded-full" />
</div>
);
},

View File

@@ -1,5 +1,6 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import React from 'react';
interface Props {
children: ReactNode;
@@ -11,7 +12,8 @@ export function EmptyStateText({ children, className }: Props) {
<div
className={classNames(
className,
'rounded-lg border border-dashed border-highlight h-full py-2 text-gray-400 flex items-center justify-center',
'rounded-lg border border-dashed border-background-highlight',
'h-full py-2 text-fg-subtler flex items-center justify-center italic',
)}
>
{children}

View File

@@ -71,8 +71,8 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
size="sm"
className={classNames(
className,
'text-gray-800 !px-2 truncate',
activeEnvironment == null && 'text-opacity-disabled italic',
'text-fg !px-2 truncate',
activeEnvironment == null && 'text-fg-subtler italic',
)}
{...buttonProps}
>

View File

@@ -68,7 +68,7 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
color="custom"
title="Add sub environment"
icon="plusCircle"
iconClassName="text-gray-500 group-hover:text-gray-700"
iconClassName="text-fg-subtler group-hover:text-fg-subtle"
className="group"
onClick={handleCreateEnvironment}
/>
@@ -97,7 +97,7 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
secondSlot={() =>
activeWorkspace != null && (
<EnvironmentEditor
className="pt-2 border-l border-highlight"
className="pt-2 border-l border-background-highlight-secondary"
environment={selectedEnvironment}
workspace={activeWorkspace}
/>
@@ -175,7 +175,7 @@ const EnvironmentEditor = function ({
<Heading className="w-full flex items-center gap-1">
<div>{environment?.name ?? 'Global Variables'}</div>
<IconButton
iconClassName="text-gray-600"
iconClassName="text-fg-subtler"
size="sm"
icon={valueVisibility.value ? 'eye' : 'eyeClosed'}
title={valueVisibility.value ? 'Hide Values' : 'Reveal Values'}
@@ -244,7 +244,7 @@ function SidebarButton({
size="xs"
className={classNames(
'w-full',
active ? 'text-gray-800 bg-highlightSecondary' : 'text-gray-600 hover:text-gray-700',
active ? 'text-fg bg-background-active' : 'text-fg-subtle hover:text-fg',
)}
justify="start"
onClick={onClick}
@@ -281,7 +281,7 @@ function SidebarButton({
},
},
{
key: 'delete',
key: 'delete-environment',
variant: 'danger',
label: 'Delete',
leftSlot: <Icon icon="trash" size="sm" />,

View File

@@ -90,7 +90,7 @@ export function ExportDataDialog({
/>
</td>
<td
className="py-1 pl-4 text-gray-700 whitespace-nowrap overflow-x-auto hide-scrollbars"
className="py-1 pl-4 text-fg whitespace-nowrap overflow-x-auto hide-scrollbars"
onClick={() => setSelectedWorkspaces((prev) => ({ ...prev, [w.id]: !prev[w.id] }))}
>
{w.name} {w.id === activeWorkspace.id ? '(current workspace)' : ''}
@@ -100,7 +100,7 @@ export function ExportDataDialog({
</tbody>
</table>
<HStack space={2} justifyContent="end">
<Button className="focus" color="gray" onClick={onHide}>
<Button className="focus" variant="border" onClick={onHide}>
Cancel
</Button>
<Button

View File

@@ -30,6 +30,8 @@ export function FormUrlencodedEditor({ body, forceUpdateKey, onChange }: Props)
<PairEditor
valueAutocompleteVariables
nameAutocompleteVariables
namePlaceholder="entry_name"
valuePlaceholder="Value"
pairs={pairs}
onChange={handleChange}
forceUpdateKey={forceUpdateKey}

View File

@@ -1,32 +1,38 @@
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { getCurrent } from '@tauri-apps/api/webviewWindow';
import { useLocation } from 'react-router-dom';
import { useEffect } from 'react';
import { useClipboardText } from '../hooks/useClipboardText';
import { useCommandPalette } from '../hooks/useCommandPalette';
import { cookieJarsQueryKey } from '../hooks/useCookieJars';
import { environmentsQueryKey } from '../hooks/useEnvironments';
import { foldersQueryKey } from '../hooks/useFolders';
import { useGlobalCommands } from '../hooks/useGlobalCommands';
import { grpcConnectionsQueryKey } from '../hooks/useGrpcConnections';
import { grpcEventsQueryKey } from '../hooks/useGrpcEvents';
import { grpcRequestsQueryKey } from '../hooks/useGrpcRequests';
import { useHotKey } from '../hooks/useHotKey';
import { httpRequestsQueryKey } from '../hooks/useHttpRequests';
import { httpResponsesQueryKey } from '../hooks/useHttpResponses';
import { keyValueQueryKey } from '../hooks/useKeyValue';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useNotificationToast } from '../hooks/useNotificationToast';
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { settingsQueryKey } from '../hooks/useSettings';
import { useSyncAppearance } from '../hooks/useSyncAppearance';
import { useSyncWindowTitle } from '../hooks/useSyncWindowTitle';
import { settingsQueryKey, useSettings } from '../hooks/useSettings';
import { useSyncThemeToDocument } from '../hooks/useSyncThemeToDocument';
import { workspacesQueryKey } from '../hooks/useWorkspaces';
import { useZoom } from '../hooks/useZoom';
import type { Model } from '../lib/models';
import { modelsEq } from '../lib/models';
import { setPathname } from '../lib/persistPathname';
import { useNotificationToast } from '../hooks/useNotificationToast';
const DEFAULT_FONT_SIZE = 16;
import { catppuccinMacchiato } from '../lib/theme/themes/catppuccin';
import { githubLight } from '../lib/theme/themes/github';
import { hotdogStandDefault } from '../lib/theme/themes/hotdog-stand';
import { monokaiProDefault } from '../lib/theme/themes/monokai-pro';
import { rosePineDefault } from '../lib/theme/themes/rose-pine';
import { yaakDark } from '../lib/theme/themes/yaak';
import { getThemeCSS } from '../lib/theme/window';
export function GlobalHooks() {
// Include here so they always update, even if no component references them
@@ -34,8 +40,8 @@ export function GlobalHooks() {
useRecentEnvironments();
useRecentRequests();
useSyncAppearance();
useSyncWindowTitle();
// Other useful things
useSyncThemeToDocument();
useGlobalCommands();
useCommandPalette();
useNotificationToast();
@@ -43,12 +49,6 @@ export function GlobalHooks() {
const queryClient = useQueryClient();
const { wasUpdatedExternally } = useRequestUpdateKey(null);
// Listen for location changes and update the pathname
const location = useLocation();
useEffect(() => {
setPathname(location.pathname).catch(console.error);
}, [location.pathname]);
interface ModelPayload {
model: Model;
windowLabel: string;
@@ -63,6 +63,8 @@ export function GlobalHooks() {
? httpResponsesQueryKey(model)
: model.model === 'folder'
? foldersQueryKey(model)
: model.model === 'environment'
? environmentsQueryKey(model)
: model.model === 'grpc_connection'
? grpcConnectionsQueryKey(model)
: model.model === 'grpc_event'
@@ -97,10 +99,8 @@ export function GlobalHooks() {
queryClient.setQueryData<Model[]>(queryKey, (values = []) => {
const index = values.findIndex((v) => modelsEq(v, model)) ?? -1;
if (index >= 0) {
// console.log('UPDATED', payload);
return [...values.slice(0, index), model, ...values.slice(index + 1)];
} else {
// console.log('CREATED', payload);
return pushToFront ? [model, ...(values ?? [])] : [...(values ?? []), model];
}
});
@@ -118,6 +118,8 @@ export function GlobalHooks() {
queryClient.setQueryData(httpResponsesQueryKey(model), removeById(model));
} else if (model.model === 'folder') {
queryClient.setQueryData(foldersQueryKey(model), removeById(model));
} else if (model.model === 'environment') {
queryClient.setQueryData(environmentsQueryKey(model), removeById(model));
} else if (model.model === 'grpc_request') {
queryClient.setQueryData(grpcRequestsQueryKey(model), removeById(model));
} else if (model.model === 'grpc_connection') {
@@ -133,26 +135,42 @@ export function GlobalHooks() {
}
});
useListenToTauriEvent<number>(
'zoom',
({ payload: zoomDelta }) => {
const fontSize = parseFloat(window.getComputedStyle(document.documentElement).fontSize);
const settings = useSettings();
useEffect(() => {
if (settings == null) {
return;
}
let newFontSize;
if (zoomDelta === 0) {
newFontSize = DEFAULT_FONT_SIZE;
} else if (zoomDelta > 0) {
newFontSize = Math.min(fontSize * 1.1, DEFAULT_FONT_SIZE * 5);
} else if (zoomDelta < 0) {
newFontSize = Math.max(fontSize * 0.9, DEFAULT_FONT_SIZE * 0.4);
}
const { interfaceScale, interfaceFontSize, editorFontSize } = settings;
getCurrent().setZoom(interfaceScale).catch(console.error);
document.documentElement.style.setProperty('font-size', `${interfaceFontSize}px`);
document.documentElement.style.setProperty('--editor-font-size', `${editorFontSize}px`);
}, [settings]);
document.documentElement.style.fontSize = `${newFontSize}px`;
},
{
target: { kind: 'WebviewWindow', label: getCurrent().label },
},
);
// Handle Zoom. Note, Mac handles it in app menu, so need to also handle keyboard
// shortcuts for Windows/Linux
const zoom = useZoom();
useHotKey('app.zoom_in', () => zoom.zoomIn);
useListenToTauriEvent('zoom_in', () => zoom.zoomIn);
useHotKey('app.zoom_out', () => zoom.zoomOut);
useListenToTauriEvent('zoom_out', () => zoom.zoomOut);
useHotKey('app.zoom_reset', () => zoom.zoomReset);
useListenToTauriEvent('zoom_reset', () => zoom.zoomReset);
const [, copy] = useClipboardText();
useListenToTauriEvent('generate_theme_css', () => {
const themesCss = [
yaakDark,
monokaiProDefault,
rosePineDefault,
catppuccinMacchiato,
githubLight,
hotdogStandDefault,
]
.map(getThemeCSS)
.join('\n\n');
copy(themesCss);
});
return null;
}

View File

@@ -88,7 +88,7 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi
<Button
key="introspection"
size="xs"
color={error ? 'danger' : 'gray'}
color={error ? 'danger' : 'secondary'}
isLoading={isLoading}
onClick={() => {
dialog.show({
@@ -105,7 +105,7 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi
refetch();
}}
className="ml-auto"
color="secondary"
color="primary"
size="sm"
>
Try Again

View File

@@ -98,9 +98,10 @@ export function GrpcConnectionLayout({ style }: Props) {
<div
style={style}
className={classNames(
'x-theme-responsePane',
'max-h-full h-full grid grid-rows-[minmax(0,1fr)] grid-cols-1',
'bg-gray-50 dark:bg-gray-100 rounded-md border border-highlight',
'shadow shadow-gray-100 dark:shadow-gray-0 relative',
'bg-background rounded-md border border-background-highlight',
'shadow relative',
)}
>
{grpc.go.error ? (

View File

@@ -2,9 +2,11 @@ import classNames from 'classnames';
import { format } from 'date-fns';
import type { CSSProperties } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { useGrpcConnections } from '../hooks/useGrpcConnections';
import { useGrpcEvents } from '../hooks/useGrpcEvents';
import { usePinnedGrpcConnection } from '../hooks/usePinnedGrpcConnection';
import { useStateWithDeps } from '../hooks/useStateWithDeps';
import type { GrpcEvent, GrpcRequest } from '../lib/models';
import { Button } from './core/Button';
import { Icon } from './core/Icon';
import { JsonAttributeTree } from './core/JsonAttributeTree';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
@@ -13,8 +15,6 @@ import { SplitLayout } from './core/SplitLayout';
import { HStack, VStack } from './core/Stacks';
import { EmptyStateText } from './EmptyStateText';
import { RecentConnectionsDropdown } from './RecentConnectionsDropdown';
import { Button } from './core/Button';
import { useStateWithDeps } from '../hooks/useStateWithDeps';
interface Props {
style?: CSSProperties;
@@ -31,11 +31,11 @@ interface Props {
export function GrpcConnectionMessagesPane({ style, methodType, activeRequest }: Props) {
const [activeEventId, setActiveEventId] = useState<string | null>(null);
const connections = useGrpcConnections(activeRequest.id ?? null);
const activeConnection = connections[0] ?? null;
const events = useGrpcEvents(activeConnection?.id ?? null);
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
const [showingLarge, setShowingLarge] = useState<boolean>(false);
const { activeConnection, connections, setPinnedConnectionId } =
usePinnedGrpcConnection(activeRequest);
const events = useGrpcEvents(activeConnection?.id ?? null);
const activeEvent = useMemo(
() => events.find((m) => m.id === activeEventId) ?? null,
@@ -61,19 +61,17 @@ export function GrpcConnectionMessagesPane({ style, methodType, activeRequest }:
firstSlot={() =>
activeConnection && (
<div className="w-full grid grid-rows-[auto_minmax(0,1fr)] items-center">
<HStack className="pl-3 mb-1 font-mono" alignItems="center">
<HStack alignItems="center" space={2}>
<HStack className="pl-3 mb-1 font-mono">
<HStack space={2}>
<span>{events.length} messages</span>
{activeConnection.elapsed === 0 && (
<Icon icon="refresh" size="sm" spin className="text-gray-600" />
<Icon icon="refresh" size="sm" spin className="text-fg-subtler" />
)}
</HStack>
<RecentConnectionsDropdown
connections={connections}
activeConnection={activeConnection}
onPinned={() => {
// todo
}}
onPinnedConnectionId={setPinnedConnectionId}
/>
</HStack>
<div className="overflow-y-auto h-full">
@@ -107,7 +105,7 @@ export function GrpcConnectionMessagesPane({ style, methodType, activeRequest }:
Message {activeEvent.eventType === 'client_message' ? 'Sent' : 'Received'}
</div>
{!showLarge && activeEvent.content.length > 1000 * 1000 ? (
<VStack space={2} className="text-sm italic text-gray-500">
<VStack space={2} className="italic text-fg-subtler">
Message previews larger than 1MB are hidden
<div>
<Button
@@ -119,7 +117,7 @@ export function GrpcConnectionMessagesPane({ style, methodType, activeRequest }:
}, 500);
}}
isLoading={showingLarge}
color="gray"
color="secondary"
variant="border"
size="xs"
>
@@ -138,7 +136,7 @@ export function GrpcConnectionMessagesPane({ style, methodType, activeRequest }:
{activeEvent.content}
</div>
{activeEvent.error && (
<div className="select-text cursor-text text-xs font-mono py-1 text-orange-700">
<div className="select-text cursor-text text-sm font-mono py-1 text-fg-warning">
{activeEvent.error}
</div>
)}
@@ -183,21 +181,21 @@ function EventRow({
className={classNames(
'w-full grid grid-cols-[auto_minmax(0,3fr)_auto] gap-2 items-center text-left',
'px-1.5 py-1 font-mono cursor-default group focus:outline-none rounded',
isActive && '!bg-highlight text-gray-900',
'text-gray-800 hover:text-gray-900',
isActive && '!bg-background-highlight-secondary !text-fg',
'text-fg-subtle hover:text-fg',
)}
>
<Icon
className={
eventType === 'server_message'
? 'text-blue-600'
? 'text-fg-info'
: eventType === 'client_message'
? 'text-violet-600'
? 'text-fg-primary'
: eventType === 'error' || (status != null && status > 0)
? 'text-orange-600'
? 'text-fg-danger'
: eventType === 'connection_end'
? 'text-green-600'
: 'text-gray-700'
? 'text-fg-success'
: 'text-fg-subtle'
}
title={
eventType === 'server_message'
@@ -222,11 +220,11 @@ function EventRow({
: 'info'
}
/>
<div className={classNames('w-full truncate text-2xs')}>
<div className={classNames('w-full truncate text-xs')}>
{content.slice(0, 1000)}
{error && <span className="text-orange-600"> ({error})</span>}
{error && <span className="text-fg-warning"> ({error})</span>}
</div>
<div className={classNames('opacity-50 text-2xs')}>
<div className={classNames('opacity-50 text-xs')}>
{format(createdAt + 'Z', 'HH:mm:ss.SSS')}
</div>
</button>

View File

@@ -199,17 +199,17 @@ export function GrpcConnectionSetupPane({
label: 'Refresh',
type: 'default',
key: 'custom',
leftSlot: <Icon className="text-gray-600" size="sm" icon="refresh" />,
leftSlot: <Icon className="text-fg-subtler" size="sm" icon="refresh" />,
},
]}
>
<Button
size="sm"
variant="border"
rightSlot={<Icon className="text-gray-600" size="sm" icon="chevronDown" />}
rightSlot={<Icon className="text-fg-subtler" size="sm" icon="chevronDown" />}
disabled={isStreaming || services == null}
className={classNames(
'font-mono text-xs min-w-[5rem] !ring-0',
'font-mono text-sm min-w-[5rem] !ring-0',
paneSize < 400 && 'flex-1',
)}
>
@@ -221,14 +221,14 @@ export function GrpcConnectionSetupPane({
{isStreaming && (
<>
<IconButton
className="border border-highlight"
className="border border-background-highlight-secondary"
size="sm"
title="Cancel"
onClick={onCancel}
icon="x"
/>
<IconButton
className="border border-highlight"
className="border border-background-highlight-secondary"
size="sm"
title="Commit"
onClick={onCommit}
@@ -237,7 +237,7 @@ export function GrpcConnectionSetupPane({
</>
)}
<IconButton
className="border border-highlight"
className="border border-background-highlight-secondary"
size="sm"
title={isStreaming ? 'Connect' : 'Send'}
hotkeyAction="grpc_request.send"
@@ -247,7 +247,7 @@ export function GrpcConnectionSetupPane({
</>
) : (
<IconButton
className="border border-highlight"
className="border border-background-highlight-secondary"
size="sm"
title={methodType === 'unary' ? 'Send' : 'Connect'}
hotkeyAction="grpc_request.send"
@@ -275,7 +275,6 @@ export function GrpcConnectionSetupPane({
<GrpcEditor
onChange={handleChangeMessage}
services={services}
className="bg-gray-50"
reflectionError={reflectionError}
reflectionLoading={reflectionLoading}
request={activeRequest}

View File

@@ -133,12 +133,12 @@ export function GrpcEditor({
size="xs"
color={
reflectionLoading
? 'gray'
: reflectionUnavailable
? 'secondary'
: reflectionUnavailable
? 'info'
: reflectionError
? 'danger'
: 'gray'
: 'secondary'
}
isLoading={reflectionLoading}
onClick={() => {

View File

@@ -40,7 +40,6 @@ export function GrpcProtoSelection({ requestId }: Props) {
<HStack space={2} justifyContent="start" className="flex-row-reverse">
<Button
color="primary"
size="sm"
onClick={async () => {
const selected = await open({
title: 'Select Proto Files',
@@ -60,8 +59,7 @@ export function GrpcProtoSelection({ requestId }: Props) {
<Button
isLoading={grpc.reflect.isFetching}
disabled={grpc.reflect.isFetching}
color="gray"
size="sm"
color="secondary"
onClick={() => grpc.reflect.refetch()}
>
Refresh Schema
@@ -103,25 +101,24 @@ export function GrpcProtoSelection({ requestId }: Props) {
)}
{protoFiles.length > 0 && (
<table className="w-full divide-y">
<table className="w-full divide-y divide-background-highlight">
<thead>
<tr>
<th className="text-gray-600">
<span className="font-mono text-sm">*.proto</span> Files
<th className="text-fg-subtler">
<span className="font-mono">*.proto</span> Files
</th>
<th></th>
</tr>
</thead>
<tbody className="divide-y">
<tbody className="divide-y divide-background-highlight">
{protoFiles.map((f, i) => (
<tr key={f + i} className="group">
<td className="pl-1 text-sm font-mono">{f.split('/').pop()}</td>
<td className="pl-1 font-mono">{f.split('/').pop()}</td>
<td className="w-0 py-0.5">
<IconButton
title="Remove file"
size="sm"
icon="trash"
className="ml-auto opacity-30 transition-opacity group-hover:opacity-100"
className="ml-auto opacity-50 transition-opacity group-hover:opacity-100"
onClick={async () => {
await protoFilesKv.set(protoFiles.filter((p) => p !== f));
}}

View File

@@ -0,0 +1,38 @@
import classNames from 'classnames';
import type { HTMLAttributes, ReactNode } from 'react';
import { useIsFullscreen } from '../hooks/useIsFullscreen';
import { useOsInfo } from '../hooks/useOsInfo';
interface HeaderSizeProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
size: 'md' | 'lg';
ignoreStoplights?: boolean;
}
export function HeaderSize({
className,
style,
size,
ignoreStoplights,
...props
}: HeaderSizeProps) {
const platform = useOsInfo();
const fullscreen = useIsFullscreen();
const stoplightsVisible = platform?.osType === 'macos' && !fullscreen && !ignoreStoplights;
return (
<div
data-tauri-drag-region
style={style}
className={classNames(
className,
'pt-[1px] w-full border-b border-background-highlight min-w-0',
stoplightsVisible ? 'pl-20 pr-1' : 'pl-1',
size === 'md' && 'h-[27px]',
size === 'lg' && 'h-[38px]',
)}
>
{/* NOTE: This needs display:grid or else the element shrinks (even though scrollable) */}
<div className="h-full w-full overflow-x-auto hide-scrollbars grid" {...props} />
</div>
);
}

View File

@@ -1,9 +1,9 @@
import { motion } from 'framer-motion';
import React, { useState } from 'react';
import { Button } from './core/Button';
import { useClipboardText } from '../hooks/useClipboardText';
import { useImportCurl } from '../hooks/useImportCurl';
import { Button } from './core/Button';
import { Icon } from './core/Icon';
import { motion } from 'framer-motion';
export function ImportCurlButton() {
const [clipboardText] = useClipboardText();
@@ -23,7 +23,7 @@ export function ImportCurlButton() {
<Button
size="xs"
variant="border"
color="secondary"
color="primary"
leftSlot={<Icon icon="paste" size="sm" />}
isLoading={isLoading}
onClick={() => {

View File

@@ -0,0 +1,15 @@
import type { ReactNode } from 'react';
import { useAppInfo } from '../hooks/useAppInfo';
interface Props {
children: ReactNode;
}
export function IsDev({ children }: Props) {
const appInfo = useAppInfo();
if (!appInfo?.isDev) {
return null;
}
return <>{children}</>;
}

View File

@@ -1,10 +1,10 @@
import { hotkeyActions } from '../hooks/useHotKey';
import { HotKeyList } from './core/HotKeyList';
export const KeyboardShortcutsDialog = () => {
export function KeyboardShortcutsDialog() {
return (
<div className="h-full w-full pb-2">
<HotKeyList hotkeys={hotkeyActions} />
</div>
);
};
}

View File

@@ -2,6 +2,7 @@ import classNames from 'classnames';
import FocusTrap from 'focus-trap-react';
import { motion } from 'framer-motion';
import type { ReactNode } from 'react';
import React from 'react';
import { Portal } from './Portal';
interface Props {
@@ -43,10 +44,16 @@ export function Overlay({
onClick={onClose}
className={classNames(
'absolute inset-0',
variant === 'default' && 'bg-gray-600/30 dark:bg-black/30 backdrop-blur-sm',
variant === 'default' && 'bg-background-backdrop backdrop-blur-sm',
)}
/>
<div className="bg-red-100">{children}</div>
{children}
{/* Show draggable region at the top */}
{/* TODO: Figure out tauri drag region and also make clickable still */}
{variant === 'default' && (
<div data-tauri-drag-region className="absolute top-0 left-0 h-md right-0" />
)}
</motion.div>
</FocusTrap>
)}

View File

@@ -11,12 +11,17 @@ import { HStack } from './core/Stacks';
interface Props {
connections: GrpcConnection[];
activeConnection: GrpcConnection;
onPinned: (r: GrpcConnection) => void;
onPinnedConnectionId: (id: string) => void;
}
export function RecentConnectionsDropdown({ activeConnection, connections, onPinned }: Props) {
export function RecentConnectionsDropdown({
activeConnection,
connections,
onPinnedConnectionId,
}: Props) {
const deleteConnection = useDeleteGrpcConnection(activeConnection?.id ?? null);
const deleteAllConnections = useDeleteGrpcConnections(activeConnection?.requestId);
const latestConnectionId = connections[0]?.id ?? 'n/a';
return (
<Dropdown
@@ -38,19 +43,19 @@ export function RecentConnectionsDropdown({ activeConnection, connections, onPin
...connections.slice(0, 20).map((c) => ({
key: c.id,
label: (
<HStack space={2} alignItems="center">
<HStack space={2}>
{formatDistanceToNowStrict(c.createdAt + 'Z')} ago &bull;{' '}
<span className="font-mono text-xs">{c.elapsed}ms</span>
<span className="font-mono text-sm">{c.elapsed}ms</span>
</HStack>
),
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinned(c),
onSelect: () => onPinnedConnectionId(c.id),
})),
]}
>
<IconButton
title="Show connection history"
icon="chevronDown"
icon={activeConnection?.id === latestConnectionId ? 'chevronDown' : 'pin'}
className="ml-auto"
size="sm"
iconSize="md"

View File

@@ -86,8 +86,8 @@ export function RecentRequestsDropdown({ className }: Pick<ButtonProps, 'classNa
hotkeyAction="request_switcher.toggle"
className={classNames(
className,
'text-gray-800 text-sm truncate pointer-events-auto',
activeRequest === null && 'text-opacity-disabled italic',
'text-fg truncate pointer-events-auto',
activeRequest === null && 'text-fg-subtler italic',
)}
>
{fallbackRequestName(activeRequest)}

View File

@@ -1,26 +1,30 @@
import classNames from 'classnames';
import { useDeleteHttpResponse } from '../hooks/useDeleteHttpResponse';
import { useDeleteHttpResponses } from '../hooks/useDeleteHttpResponses';
import type { HttpResponse } from '../lib/models';
import { Dropdown } from './core/Dropdown';
import { pluralize } from '../lib/pluralize';
import { HStack } from './core/Stacks';
import { StatusTag } from './core/StatusTag';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
import { StatusTag } from './core/StatusTag';
interface Props {
responses: HttpResponse[];
activeResponse: HttpResponse;
onPinnedResponse: (r: HttpResponse) => void;
onPinnedResponseId: (id: string) => void;
className?: string;
}
export const RecentResponsesDropdown = function ResponsePane({
activeResponse,
responses,
onPinnedResponse,
onPinnedResponseId,
className,
}: Props) {
const deleteResponse = useDeleteHttpResponse(activeResponse?.id ?? null);
const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId);
const latestResponseId = responses[0]?.id ?? 'n/a';
return (
<Dropdown
@@ -39,23 +43,24 @@ export const RecentResponsesDropdown = function ResponsePane({
disabled: responses.length === 0,
},
{ type: 'separator', label: 'History' },
...responses.slice(0, 20).map((r) => ({
...responses.slice(0, 20).map((r: HttpResponse) => ({
key: r.id,
label: (
<HStack space={2} alignItems="center">
<StatusTag className="text-xs" response={r} />
<span>&bull;</span> <span className="font-mono text-xs">{r.elapsed}ms</span>
<HStack space={2}>
<StatusTag className="text-sm" response={r} />
<span>&rarr;</span>{' '}
<span className="font-mono text-sm">{r.elapsed >= 0 ? `${r.elapsed}ms` : 'n/a'}</span>
</HStack>
),
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedResponse(r),
onSelect: () => onPinnedResponseId(r.id),
})),
]}
>
<IconButton
title="Show response history"
icon="chevronDown"
className="ml-auto"
icon={activeResponse?.id === latestResponseId ? 'chevronDown' : 'pin'}
className={classNames(className, 'm-0.5')}
size="sm"
iconSize="md"
/>

View File

@@ -1,3 +1,4 @@
import classNames from 'classnames';
import { memo, useMemo } from 'react';
import { usePrompt } from '../hooks/usePrompt';
import { Button } from './core/Button';
@@ -57,7 +58,7 @@ export const RequestMethodDropdown = memo(function RequestMethodDropdown({
return (
<RadioDropdown value={method} items={radioItems} extraItems={extraItems} onChange={onChange}>
<Button size="xs" className={className}>
<Button size="xs" className={classNames(className, 'text-fg-subtle hover:text-fg')}>
{method.toUpperCase()}
</Button>
</RadioDropdown>

View File

@@ -3,10 +3,12 @@ import type { CSSProperties } from 'react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { createGlobalState } from 'react-use';
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders';
import { useImportCurl } from '../hooks/useImportCurl';
import { useIsResponseLoading } from '../hooks/useIsResponseLoading';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useRequests } from '../hooks/useRequests';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders';
import { useSendRequest } from '../hooks/useSendRequest';
import { useUpdateHttpRequest } from '../hooks/useUpdateHttpRequest';
import { tryFormatJson } from '../lib/formatters';
@@ -29,6 +31,7 @@ import { BearerAuth } from './BearerAuth';
import { BinaryFileEditor } from './BinaryFileEditor';
import { CountBadge } from './core/CountBadge';
import { Editor } from './core/Editor';
import type { GenericCompletionOption } from './core/Editor/genericCompletion';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText';
@@ -38,9 +41,6 @@ import { GraphQLEditor } from './GraphQLEditor';
import { HeadersEditor } from './HeadersEditor';
import { UrlBar } from './UrlBar';
import { UrlParametersEditor } from './UrlParameterEditor';
import { useImportCurl } from '../hooks/useImportCurl';
import { useRequests } from '../hooks/useRequests';
import type { GenericCompletionOption } from './core/Editor/genericCompletion';
interface Props {
style: CSSProperties;
@@ -109,11 +109,14 @@ export const RequestPane = memo(function RequestPane({
if (bodyType === BODY_TYPE_NONE) {
newContentType = null;
} else if (
bodyType === BODY_TYPE_FORM_URLENCODED ||
bodyType === BODY_TYPE_FORM_MULTIPART ||
bodyType === BODY_TYPE_JSON ||
bodyType === BODY_TYPE_OTHER ||
bodyType === BODY_TYPE_XML
activeRequest.method.toLowerCase() !== 'put' &&
activeRequest.method.toLowerCase() !== 'patch' &&
activeRequest.method.toLowerCase() !== 'post' &&
(bodyType === BODY_TYPE_FORM_URLENCODED ||
bodyType === BODY_TYPE_FORM_MULTIPART ||
bodyType === BODY_TYPE_JSON ||
bodyType === BODY_TYPE_OTHER ||
bodyType === BODY_TYPE_XML)
) {
patch.method = 'POST';
newContentType = bodyType === BODY_TYPE_OTHER ? 'text/plain' : bodyType;
@@ -181,6 +184,7 @@ export const RequestPane = memo(function RequestPane({
activeRequest.authenticationType,
activeRequest.bodyType,
activeRequest.headers,
activeRequest.method,
activeRequest.urlParameters,
handleContentTypeChange,
updateRequest,
@@ -317,7 +321,6 @@ export const RequestPane = memo(function RequestPane({
useTemplating
autocompleteVariables
placeholder="..."
className="!bg-gray-50"
heightMode={fullHeight ? 'full' : 'auto'}
defaultValue={`${activeRequest.body?.text ?? ''}`}
contentType="application/json"
@@ -330,7 +333,6 @@ export const RequestPane = memo(function RequestPane({
useTemplating
autocompleteVariables
placeholder="..."
className="!bg-gray-50"
heightMode={fullHeight ? 'full' : 'auto'}
defaultValue={`${activeRequest.body?.text ?? ''}`}
contentType="text/xml"
@@ -340,7 +342,6 @@ export const RequestPane = memo(function RequestPane({
<GraphQLEditor
forceUpdateKey={forceUpdateKey}
baseRequest={activeRequest}
className="!bg-gray-50"
defaultValue={`${activeRequest.body?.text ?? ''}`}
onChange={handleBodyTextChange}
/>
@@ -370,7 +371,6 @@ export const RequestPane = memo(function RequestPane({
useTemplating
autocompleteVariables
placeholder="..."
className="!bg-gray-50"
heightMode={fullHeight ? 'full' : 'auto'}
defaultValue={`${activeRequest.body?.text ?? ''}`}
onChange={handleBodyTextChange}

View File

@@ -32,7 +32,7 @@ export function ResizeHandle({
className={classNames(
className,
'group z-10 flex',
// 'bg-blue-100/10', // For debugging
// 'bg-fg-info', // For debugging
vertical ? 'w-full h-3 cursor-row-resize' : 'h-full w-3 cursor-col-resize',
justify === 'center' && 'justify-center',
justify === 'end' && 'justify-end',

View File

@@ -2,8 +2,8 @@ import classNames from 'classnames';
import type { CSSProperties } from 'react';
import { memo, useMemo } from 'react';
import { createGlobalState } from 'react-use';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useResponseViewMode } from '../hooks/useResponseViewMode';
import type { HttpRequest } from '../lib/models';
import { isResponseLoading } from '../lib/models';
@@ -20,9 +20,11 @@ import { TabContent, Tabs } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText';
import { RecentResponsesDropdown } from './RecentResponsesDropdown';
import { ResponseHeaders } from './ResponseHeaders';
import { AudioViewer } from './responseViewers/AudioViewer';
import { CsvViewer } from './responseViewers/CsvViewer';
import { ImageViewer } from './responseViewers/ImageViewer';
import { TextViewer } from './responseViewers/TextViewer';
import { VideoViewer } from './responseViewers/VideoViewer';
import { WebPageViewer } from './responseViewers/WebPageViewer';
interface Props {
@@ -34,7 +36,7 @@ interface Props {
const useActiveTab = createGlobalState<string>('body');
export const ResponsePane = memo(function ResponsePane({ style, className, activeRequest }: Props) {
const { activeResponse, setPinnedResponse, responses } = usePinnedHttpResponse(activeRequest);
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequest);
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const [activeTab, setActiveTab] = useActiveTab();
const contentType = useContentTypeFromHeaders(activeResponse?.headers ?? null);
@@ -73,9 +75,10 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
style={style}
className={classNames(
className,
'x-theme-responsePane',
'max-h-full h-full',
'bg-gray-50 dark:bg-gray-100 rounded-md border border-highlight',
'shadow shadow-gray-100 dark:shadow-gray-0 relative',
'bg-background rounded-md border border-background-highlight',
'relative',
)}
>
{activeResponse == null ? (
@@ -89,41 +92,41 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
) : (
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
<HStack
alignItems="center"
className={classNames(
'text-gray-700 text-sm w-full flex-shrink-0',
'text-fg-subtle w-full flex-shrink-0',
// Remove a bit of space because the tabs have lots too
'-mb-1.5',
)}
>
{activeResponse && (
<HStack alignItems="center" className="w-full">
<div className="whitespace-nowrap px-3">
<HStack space={2}>
<StatusTag showReason response={activeResponse} />
{activeResponse.elapsed > 0 && (
<>
<span>&bull;</span>
<DurationTag
headers={activeResponse.elapsedHeaders}
total={activeResponse.elapsed}
/>
</>
)}
{!!activeResponse.contentLength && (
<>
<span>&bull;</span>
<SizeTag contentLength={activeResponse.contentLength} />
</>
)}
</HStack>
</div>
<HStack
space={2}
className="whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm"
>
<StatusTag showReason response={activeResponse} />
{activeResponse.elapsed > 0 && (
<>
<span>&bull;</span>
<DurationTag
headers={activeResponse.elapsedHeaders}
total={activeResponse.elapsed}
/>
</>
)}
{!!activeResponse.contentLength && (
<>
<span>&bull;</span>
<SizeTag contentLength={activeResponse.contentLength} />
</>
)}
<RecentResponsesDropdown
responses={responses}
activeResponse={activeResponse}
onPinnedResponse={setPinnedResponse}
/>
<div className="ml-auto">
<RecentResponsesDropdown
responses={responses}
activeResponse={activeResponse}
onPinnedResponseId={setPinnedResponseId}
/>
</div>
</HStack>
)}
</HStack>
@@ -138,7 +141,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
onChangeValue={setActiveTab}
label="Response"
tabs={tabs}
className="ml-3 mr-1"
className="ml-3 mr-3 mb-3"
tabListClassName="mt-1.5"
>
<TabContent value="headers">
@@ -146,19 +149,27 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
</TabContent>
<TabContent value="body">
{!activeResponse.contentLength ? (
<EmptyStateText>Empty Body</EmptyStateText>
<div className="pb-2 h-full">
<EmptyStateText>Empty Body</EmptyStateText>
</div>
) : contentType?.startsWith('image') ? (
<ImageViewer className="pb-2" response={activeResponse} />
) : contentType?.startsWith('audio') ? (
<AudioViewer response={activeResponse} />
) : contentType?.startsWith('video') ? (
<VideoViewer response={activeResponse} />
) : activeResponse.contentLength > 2 * 1000 * 1000 ? (
<div className="text-sm italic text-gray-500">
Cannot preview text responses larger than 2MB
</div>
<EmptyStateText>Cannot preview text responses larger than 2MB</EmptyStateText>
) : viewMode === 'pretty' && contentType?.includes('html') ? (
<WebPageViewer response={activeResponse} />
) : contentType?.match(/csv|tab-separated/) ? (
<CsvViewer className="pb-2" response={activeResponse} />
) : (
<TextViewer response={activeResponse} pretty={viewMode === 'pretty'} />
<TextViewer
className="-mr-2" // Pull to the right
response={activeResponse}
pretty={viewMode === 'pretty'}
/>
)}
</TabContent>
</Tabs>

View File

@@ -26,7 +26,7 @@ export default function RouteError() {
>
Go Home
</Button>
<Button color="secondary" onClick={() => window.location.reload()}>
<Button color="info" onClick={() => window.location.reload()}>
Refresh
</Button>
</VStack>

View File

@@ -0,0 +1,64 @@
import { getCurrent } from '@tauri-apps/api/webviewWindow';
import classNames from 'classnames';
import React from 'react';
import { useKeyPressEvent, useLocalStorage } from 'react-use';
import { useOsInfo } from '../../hooks/useOsInfo';
import { capitalize } from '../../lib/capitalize';
import { HStack } from '../core/Stacks';
import { TabContent, Tabs } from '../core/Tabs/Tabs';
import { HeaderSize } from '../HeaderSize';
import { WindowControls } from '../WindowControls';
import { SettingsAppearance } from './SettingsAppearance';
import { SettingsGeneral } from './SettingsGeneral';
enum Tab {
General = 'general',
Appearance = 'appearance',
}
const tabs = [Tab.General, Tab.Appearance];
export const Settings = () => {
const osInfo = useOsInfo();
const [tab, setTab] = useLocalStorage<string>('settings_tab', Tab.General);
// Close settings window on escape
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
useKeyPressEvent('Escape', () => getCurrent().close());
return (
<div className={classNames('grid grid-rows-[auto_minmax(0,1fr)] h-full')}>
<HeaderSize
data-tauri-drag-region
ignoreStoplights
size="md"
className="x-theme-appHeader bg-background text-fg-subtle flex items-center justify-center border-b border-background-highlight text-sm font-semibold"
>
<HStack
space={2}
justifyContent="center"
className="w-full h-full grid grid-cols-[1fr_auto] pointer-events-none"
>
<div className={classNames(osInfo?.osType === 'macos' ? 'text-center' : 'pl-2')}>
Settings
</div>
<WindowControls className="ml-auto" onlyX />
</HStack>
</HeaderSize>
<Tabs
value={tab}
addBorders
label="Settings"
onChangeValue={setTab}
tabs={tabs.map((value) => ({ value, label: capitalize(value) }))}
>
<TabContent value={Tab.General} className="pt-3 overflow-y-auto h-full px-4">
<SettingsGeneral />
</TabContent>
<TabContent value={Tab.Appearance} className="pt-3 overflow-y-auto h-full px-4">
<SettingsAppearance />
</TabContent>
</Tabs>
</div>
);
};

View File

@@ -0,0 +1,206 @@
import React from 'react';
import { useActiveWorkspace } from '../../hooks/useActiveWorkspace';
import { useResolvedAppearance } from '../../hooks/useResolvedAppearance';
import { useResolvedTheme } from '../../hooks/useResolvedTheme';
import { useSettings } from '../../hooks/useSettings';
import { useThemes } from '../../hooks/useThemes';
import { useUpdateSettings } from '../../hooks/useUpdateSettings';
import { trackEvent } from '../../lib/analytics';
import { clamp } from '../../lib/clamp';
import { isThemeDark } from '../../lib/theme/window';
import type { ButtonProps } from '../core/Button';
import { Checkbox } from '../core/Checkbox';
import { Editor } from '../core/Editor';
import type { IconProps } from '../core/Icon';
import { Icon } from '../core/Icon';
import { IconButton } from '../core/IconButton';
import type { SelectProps } from '../core/Select';
import { Select } from '../core/Select';
import { Separator } from '../core/Separator';
import { HStack, VStack } from '../core/Stacks';
const fontSizes = [
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30,
].map((n) => ({ label: `${n}`, value: `${n}` }));
const buttonColors: ButtonProps['color'][] = [
'primary',
'info',
'success',
'notice',
'warning',
'danger',
'secondary',
'default',
];
const icons: IconProps['icon'][] = [
'info',
'box',
'update',
'alert',
'arrowBigRightDash',
'download',
'copy',
'magicWand',
'settings',
'trash',
'sparkles',
'pencil',
'paste',
'search',
'sendHorizontal',
];
export function SettingsAppearance() {
const workspace = useActiveWorkspace();
const settings = useSettings();
const updateSettings = useUpdateSettings();
const appearance = useResolvedAppearance();
const { themes } = useThemes();
const activeTheme = useResolvedTheme();
if (settings == null || workspace == null) {
return null;
}
const lightThemes: SelectProps<string>['options'] = themes
.filter((theme) => !isThemeDark(theme))
.map((theme) => ({
label: theme.name,
value: theme.id,
}));
const darkThemes: SelectProps<string>['options'] = themes
.filter((theme) => isThemeDark(theme))
.map((theme) => ({
label: theme.name,
value: theme.id,
}));
return (
<VStack space={2} className="mb-4">
<Select
size="sm"
name="interfaceFontSize"
label="Font Size"
labelPosition="left"
value={`${settings.interfaceFontSize}`}
options={fontSizes}
onChange={(v) => updateSettings.mutate({ interfaceFontSize: parseInt(v) })}
/>
<Select
size="sm"
name="editorFontSize"
label="Editor Font Size"
labelPosition="left"
value={`${settings.editorFontSize}`}
options={fontSizes}
onChange={(v) => updateSettings.mutate({ editorFontSize: clamp(parseInt(v) || 14, 8, 30) })}
/>
<Checkbox
checked={settings.editorSoftWrap}
title="Wrap Editor Lines"
onChange={(editorSoftWrap) => updateSettings.mutate({ editorSoftWrap })}
/>
<Separator className="my-4" />
<Select
name="appearance"
label="Appearance"
labelPosition="top"
size="sm"
value={settings.appearance}
onChange={(appearance) => {
trackEvent('appearance', 'update', { appearance });
updateSettings.mutateAsync({ appearance });
}}
options={[
{ label: 'Automatic', value: 'system' },
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
]}
/>
<HStack space={2}>
{(settings.appearance === 'system' || settings.appearance === 'light') && (
<Select
hideLabel
leftSlot={<Icon icon="sun" />}
name="lightTheme"
label="Light Theme"
size="sm"
className="flex-1"
value={activeTheme.light.id}
options={lightThemes}
onChange={(themeLight) => {
trackEvent('theme', 'update', { theme: themeLight, appearance: 'light' });
updateSettings.mutateAsync({ ...settings, themeLight });
}}
/>
)}
{(settings.appearance === 'system' || settings.appearance === 'dark') && (
<Select
hideLabel
name="darkTheme"
className="flex-1"
label="Dark Theme"
leftSlot={<Icon icon="moon" />}
size="sm"
value={activeTheme.dark.id}
options={darkThemes}
onChange={(themeDark) => {
trackEvent('theme', 'update', { theme: themeDark, appearance: 'dark' });
updateSettings.mutateAsync({ ...settings, themeDark });
}}
/>
)}
</HStack>
<VStack
space={3}
className="mt-3 w-full bg-background p-3 border border-dashed border-background-highlight rounded overflow-x-auto"
>
<HStack className="text-fg font-bold" space={2}>
Theme Preview{' '}
<Icon icon={appearance === 'dark' ? 'moon' : 'sun'} className="text-fg-subtle" />
</HStack>
<HStack space={1.5} className="w-full">
{buttonColors.map((c, i) => (
<IconButton
key={c}
color={c}
size="2xs"
iconSize="xs"
icon={icons[i % icons.length]!}
iconClassName="text-fg"
title={`${c}`}
/>
))}
{buttonColors.map((c, i) => (
<IconButton
key={c}
color={c}
variant="border"
size="2xs"
iconSize="xs"
icon={icons[i % icons.length]!}
iconClassName="text-fg"
title={`${c}`}
/>
))}
</HStack>
<Editor
defaultValue={[
'let foo = { // Demo code editor',
' foo: ("bar" || "baz" ?? \'qux\'),',
' baz: [1, 10.2, null, false, true],',
'};',
].join('\n')}
heightMode="auto"
contentType="application/javascript"
/>
</VStack>
</VStack>
);
}

View File

@@ -0,0 +1,165 @@
import { invoke } from '@tauri-apps/api/core';
import { open } from '@tauri-apps/plugin-dialog';
import React, { useState } from 'react';
import { useLocalStorage } from 'react-use';
import { useThemes } from '../../hooks/useThemes';
import { capitalize } from '../../lib/capitalize';
import { yaakDark } from '../../lib/theme/themes/yaak';
import { getThemeCSS } from '../../lib/theme/window';
import { Banner } from '../core/Banner';
import { Button } from '../core/Button';
import { Editor } from '../core/Editor';
import type { IconProps } from '../core/Icon';
import { Icon } from '../core/Icon';
import { IconButton } from '../core/IconButton';
import { InlineCode } from '../core/InlineCode';
import { Input } from '../core/Input';
import { Separator } from '../core/Separator';
import { HStack, VStack } from '../core/Stacks';
const buttonColors = [
'primary',
'secondary',
'info',
'success',
'warning',
'danger',
'default',
] as const;
const icons: IconProps['icon'][] = [
'info',
'box',
'update',
'alert',
'arrowBigRightDash',
'download',
'copy',
'magicWand',
'settings',
'trash',
'sparkles',
'pencil',
'paste',
'search',
'sendHorizontal',
];
export function SettingsDesign() {
const themes = useThemes();
const [exportDir, setExportDir] = useLocalStorage<string | null>('theme_export_dir', null);
const [loadingExport, setLoadingExport] = useState<boolean>(false);
const saveThemes = () => {
setLoadingExport(true);
setTimeout(async () => {
const allThemesCSS = themes.themes.map(getThemeCSS).join('\n\n');
const coreThemeCSS = [yaakDark].map(getThemeCSS).join('\n\n');
try {
await invoke('cmd_write_file_dev', {
pathname: exportDir + '/themes-all.css',
contents: allThemesCSS,
});
await invoke('cmd_write_file_dev', {
pathname: exportDir + '/themes-slim.css',
contents: coreThemeCSS,
});
} catch (err) {
console.log('FAILED', err);
}
setLoadingExport(false);
}, 500);
};
return (
<div className="p-2 flex flex-col gap-3">
<VStack space={2}>
<InlineCode>{exportDir}</InlineCode>
<HStack space={2}>
<Button
size="sm"
color="secondary"
variant="border"
onClick={() => {
open({ directory: true }).then(setExportDir);
}}
>
Change Export Dir
</Button>
<Button
disabled={exportDir == null}
isLoading={loadingExport}
size="sm"
color="primary"
variant="border"
onClick={saveThemes}
>
Export CSS
</Button>
</HStack>
</VStack>
<Separator className="my-6" />
<Input
label="Field Label"
name="demo"
placeholder="Placeholder"
size="sm"
rightSlot={<IconButton title="search" size="xs" className="w-8 m-0.5" icon="search" />}
/>
<Editor
defaultValue={[
'// Demo code editor',
'let foo = {',
' foo: ("bar" || "baz" ?? \'qux\'),',
' baz: [1, 10.2, null, false, true],',
'};',
].join('\n')}
heightMode="auto"
contentType="application/javascript"
/>
<div className="flex flex-col gap-1">
<div className="flex flex-wrap gap-1">
{buttonColors.map((c, i) => (
<Button key={c} color={c} size="sm" leftSlot={<Icon size="sm" icon={icons[i]!} />}>
{capitalize(c).slice(0, 4)}
</Button>
))}
</div>
<div className="flex flex-wrap gap-1">
{buttonColors.map((c, i) => (
<Button
key={c}
color={c}
variant="border"
size="sm"
leftSlot={<Icon size="sm" icon={icons[i]!} />}
>
{capitalize(c).slice(0, 4)}
</Button>
))}
</div>
<div className="flex gap-1">
{icons.map((v, i) => (
<IconButton
color={buttonColors[i % buttonColors.length]}
title={v}
variant="border"
size="sm"
key={v}
icon={v}
/>
))}
</div>
</div>
<div className="flex flex-col gap-1">
<Banner color="primary">Primary banner</Banner>
<Banner color="secondary">Secondary banner</Banner>
<Banner color="danger">Danger banner</Banner>
<Banner color="warning">Warning banner</Banner>
<Banner color="success">Success banner</Banner>
</div>
</div>
);
}

View File

@@ -0,0 +1,99 @@
import React from 'react';
import { useActiveWorkspace } from '../../hooks/useActiveWorkspace';
import { useAppInfo } from '../../hooks/useAppInfo';
import { useCheckForUpdates } from '../../hooks/useCheckForUpdates';
import { useSettings } from '../../hooks/useSettings';
import { useUpdateSettings } from '../../hooks/useUpdateSettings';
import { useUpdateWorkspace } from '../../hooks/useUpdateWorkspace';
import { Checkbox } from '../core/Checkbox';
import { Heading } from '../core/Heading';
import { IconButton } from '../core/IconButton';
import { KeyValueRow, KeyValueRows } from '../core/KeyValueRow';
import { PlainInput } from '../core/PlainInput';
import { Select } from '../core/Select';
import { Separator } from '../core/Separator';
import { VStack } from '../core/Stacks';
export function SettingsGeneral() {
const workspace = useActiveWorkspace();
const updateWorkspace = useUpdateWorkspace(workspace?.id ?? null);
const settings = useSettings();
const updateSettings = useUpdateSettings();
const appInfo = useAppInfo();
const checkForUpdates = useCheckForUpdates();
if (settings == null || workspace == null) {
return null;
}
return (
<VStack space={2} className="mb-4">
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-1">
<Select
name="updateChannel"
label="Update Channel"
labelPosition="left"
size="sm"
value={settings.updateChannel}
onChange={(updateChannel) => updateSettings.mutate({ updateChannel })}
options={[
{ label: 'Release', value: 'stable' },
{ label: 'Early Bird (Beta)', value: 'beta' },
]}
/>
<IconButton
variant="border"
size="sm"
title="Check for updates"
icon="refresh"
spin={checkForUpdates.isPending}
onClick={() => checkForUpdates.mutateAsync()}
/>
</div>
<Separator className="my-4" />
<Heading size={2}>
Workspace{' '}
<div className="inline-block ml-1 bg-background-highlight px-2 py-0.5 rounded text-fg text-shrink">
{workspace.name}
</div>
</Heading>
<VStack className="mt-1 w-full" space={3}>
<PlainInput
size="sm"
name="requestTimeout"
label="Request Timeout (ms)"
placeholder="0"
labelPosition="left"
defaultValue={`${workspace.settingRequestTimeout}`}
validate={(value) => parseInt(value) >= 0}
onChange={(v) => updateWorkspace.mutate({ settingRequestTimeout: parseInt(v) || 0 })}
type="number"
/>
<Checkbox
checked={workspace.settingValidateCertificates}
title="Validate TLS Certificates"
onChange={(settingValidateCertificates) =>
updateWorkspace.mutate({ settingValidateCertificates })
}
/>
<Checkbox
checked={workspace.settingFollowRedirects}
title="Follow Redirects"
onChange={(settingFollowRedirects) => updateWorkspace.mutate({ settingFollowRedirects })}
/>
</VStack>
<Separator className="my-4" />
<Heading size={2}>App Info</Heading>
<KeyValueRows>
<KeyValueRow label="Version" value={appInfo?.version} />
<KeyValueRow label="Data Directory" value={appInfo?.appDataDir} />
<KeyValueRow label="Logs Directory" value={appInfo?.appLogDir} />
</KeyValueRows>
</VStack>
);
}

View File

@@ -1,142 +0,0 @@
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useAppInfo } from '../hooks/useAppInfo';
import { useCheckForUpdates } from '../hooks/useCheckForUpdates';
import { useSettings } from '../hooks/useSettings';
import { useUpdateSettings } from '../hooks/useUpdateSettings';
import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace';
import { trackEvent } from '../lib/analytics';
import { Checkbox } from './core/Checkbox';
import { Heading } from './core/Heading';
import { IconButton } from './core/IconButton';
import { Input } from './core/Input';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
import { Select } from './core/Select';
import { Separator } from './core/Separator';
import { VStack } from './core/Stacks';
export const SettingsDialog = () => {
const workspace = useActiveWorkspace();
const updateWorkspace = useUpdateWorkspace(workspace?.id ?? null);
const settings = useSettings();
const updateSettings = useUpdateSettings();
const appInfo = useAppInfo();
const checkForUpdates = useCheckForUpdates();
if (settings == null || workspace == null) {
return null;
}
return (
<VStack space={2} className="mb-4">
<Select
name="appearance"
label="Appearance"
labelPosition="left"
size="sm"
value={settings.appearance}
onChange={async (appearance) => {
await updateSettings.mutateAsync({ ...settings, appearance });
trackEvent('setting', 'update', { appearance });
}}
options={[
{
label: 'System',
value: 'system',
},
{
label: 'Light',
value: 'light',
},
{
label: 'Dark',
value: 'dark',
},
]}
/>
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-1">
<Select
name="updateChannel"
label="Update Channel"
labelPosition="left"
size="sm"
value={settings.updateChannel}
onChange={async (updateChannel) => {
trackEvent('setting', 'update', { update_channel: updateChannel });
await updateSettings.mutateAsync({ ...settings, updateChannel });
}}
options={[
{
label: 'Release',
value: 'stable',
},
{
label: 'Early Bird (Beta)',
value: 'beta',
},
]}
/>
<IconButton
variant="border"
size="sm"
title="Check for updates"
icon="refresh"
spin={checkForUpdates.isPending}
onClick={() => checkForUpdates.mutateAsync()}
/>
</div>
<Separator className="my-4" />
<Heading size={2}>
Workspace{' '}
<div className="inline-block ml-1 bg-gray-500 dark:bg-gray-300 px-2 py-0.5 text-sm rounded text-white dark:text-gray-900">
{workspace.name}
</div>
</Heading>
<VStack className="mt-1 w-full" space={3}>
<Input
size="sm"
name="requestTimeout"
label="Request Timeout (ms)"
placeholder="0"
labelPosition="left"
defaultValue={`${workspace.settingRequestTimeout}`}
validate={(value) => parseInt(value) >= 0}
onChange={(v) => updateWorkspace.mutateAsync({ settingRequestTimeout: parseInt(v) || 0 })}
/>
<Checkbox
checked={workspace.settingValidateCertificates}
title="Validate TLS Certificates"
onChange={async (settingValidateCertificates) => {
trackEvent('workspace', 'update', {
validate_certificates: JSON.stringify(settingValidateCertificates),
});
await updateWorkspace.mutateAsync({ settingValidateCertificates });
}}
/>
<Checkbox
checked={workspace.settingFollowRedirects}
title="Follow Redirects"
onChange={async (settingFollowRedirects) => {
trackEvent('workspace', 'update', {
follow_redirects: JSON.stringify(settingFollowRedirects),
});
await updateWorkspace.mutateAsync({ settingFollowRedirects });
}}
/>
</VStack>
<Separator className="my-4" />
<Heading size={2}>App Info</Heading>
<KeyValueRows>
<KeyValueRow label="Version" value={appInfo.data?.version} />
<KeyValueRow label="Data Directory" value={appInfo.data?.appDataDir} />
<KeyValueRow label="Logs Directory" value={appInfo.data?.appLogDir} />
</KeyValueRows>
</VStack>
);
};

View File

@@ -1,6 +1,9 @@
import { invoke } from '@tauri-apps/api/core';
import { open } from '@tauri-apps/plugin-shell';
import { useRef } from 'react';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { useAppInfo } from '../hooks/useAppInfo';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCheckForUpdates } from '../hooks/useCheckForUpdates';
import { useExportData } from '../hooks/useExportData';
import { useImportData } from '../hooks/useImportData';
@@ -11,7 +14,6 @@ import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { useDialog } from './DialogContext';
import { KeyboardShortcutsDialog } from './KeyboardShortcutsDialog';
import { SettingsDialog } from './SettingsDialog';
export function SettingsDropdown() {
const importData = useImportData();
@@ -20,13 +22,15 @@ export function SettingsDropdown() {
const dropdownRef = useRef<DropdownRef>(null);
const dialog = useDialog();
const checkForUpdates = useCheckForUpdates();
const routes = useAppRoutes();
const workspaceId = useActiveWorkspaceId();
const showSettings = () => {
dialog.show({
id: 'settings',
size: 'md',
title: 'Settings',
render: () => <SettingsDialog />,
const showSettings = async () => {
if (!workspaceId) return;
await invoke('cmd_new_nested_window', {
url: routes.paths.workspaceSettings({ workspaceId }),
label: 'settings',
title: 'Yaak Settings',
});
};
@@ -52,7 +56,7 @@ export function SettingsDropdown() {
dialog.show({
id: 'hotkey',
title: 'Keyboard Shortcuts',
size: 'sm',
size: 'dynamic',
render: () => <KeyboardShortcutsDialog />,
});
},
@@ -69,7 +73,7 @@ export function SettingsDropdown() {
leftSlot: <Icon icon="folderOutput" />,
onSelect: () => exportData.mutate(),
},
{ type: 'separator', label: `Yaak v${appInfo.data?.version}` },
{ type: 'separator', label: `Yaak v${appInfo?.version}` },
{
key: 'update-check',
label: 'Check for Updates',
@@ -88,7 +92,7 @@ export function SettingsDropdown() {
label: 'Changelog',
leftSlot: <Icon icon="cake" />,
rightSlot: <Icon icon="externalLink" />,
onSelect: () => open(`https://yaak.app/changelog/${appInfo.data?.version}`),
onSelect: () => open(`https://yaak.app/changelog/${appInfo?.version}`),
},
]}
>

View File

@@ -9,6 +9,7 @@ import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCopyAsCurl } from '../hooks/useCopyAsCurl';
import { useCreateDropdownItems } from '../hooks/useCreateDropdownItems';
import { useDeleteFolder } from '../hooks/useDeleteFolder';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
@@ -40,7 +41,6 @@ import { InlineCode } from './core/InlineCode';
import { VStack } from './core/Stacks';
import { StatusTag } from './core/StatusTag';
import { DropMarker } from './DropMarker';
import { useCopyAsCurl } from '../hooks/useCopyAsCurl';
interface Props {
className?: string;
@@ -133,27 +133,31 @@ export function Sidebar({ className }: Props) {
) {
selectedRequest = node.item;
}
const childItems = [...requests, ...folders].filter((f) =>
node.item.model === 'workspace' ? f.folderId == null : f.folderId === node.item.id,
);
childItems.sort((a, b) => a.sortPriority - b.sortPriority);
// Recurse to children
const isCollapsed = collapsed.value?.[node.item.id];
const depth = node.depth + 1;
childItems.sort((a, b) => a.sortPriority - b.sortPriority);
for (const item of childItems) {
treeParentMap[item.id] = node;
// Add to children
node.children.push(next({ item, children: [], depth }));
if (item.model !== 'folder') {
// Add to selectable requests
if (item.model !== 'folder' && !isCollapsed) {
selectableRequests.push({ id: item.id, index: selectableRequestIndex++, tree: node });
}
}
return node;
};
const tree = next({ item: activeWorkspace, children: [], depth: 0 });
return { tree, treeParentMap, selectableRequests, selectedRequest };
}, [activeWorkspace, selectedId, requests, folders]);
}, [activeWorkspace, selectedId, requests, folders, collapsed.value]);
const deleteSelectedRequest = useDeleteRequest(selectedRequest);
@@ -455,6 +459,7 @@ export function Sidebar({ className }: Props) {
/>
<SidebarItems
treeParentMap={treeParentMap}
activeId={activeRequest?.id ?? null}
selectedId={selectedId}
selectedTree={selectedTree}
isCollapsed={isCollapsed}
@@ -476,6 +481,7 @@ interface SidebarItemsProps {
tree: TreeNode;
focused: boolean;
draggingId: string | null;
activeId: string | null;
selectedId: string | null;
selectedTree: TreeNode | null;
treeParentMap: Record<string, TreeNode>;
@@ -491,6 +497,7 @@ interface SidebarItemsProps {
function SidebarItems({
tree,
focused,
activeId,
selectedId,
selectedTree,
draggingId,
@@ -510,60 +517,68 @@ function SidebarItems({
aria-orientation="vertical"
dir="ltr"
className={classNames(
tree.depth > 0 && 'border-l border-highlight',
tree.depth > 0 && 'border-l border-background-highlight-secondary',
tree.depth === 0 && 'ml-0',
tree.depth >= 1 && 'ml-[1.2em]',
tree.depth >= 1 && 'ml-[1.2rem]',
)}
>
{tree.children.map((child, i) => (
<Fragment key={child.item.id}>
{hoveredIndex === i && hoveredTree?.item.id === tree.item.id && <DropMarker />}
<DraggableSidebarItem
draggable
selected={selectedId === child.item.id}
itemId={child.item.id}
itemName={child.item.name}
itemFallbackName={
child.item.model === 'http_request' || child.item.model === 'grpc_request'
? fallbackRequestName(child.item)
: 'New Folder'
}
itemModel={child.item.model}
itemPrefix={
(child.item.model === 'http_request' || child.item.model === 'grpc_request') && (
<HttpMethodTag request={child.item} />
)
}
onMove={handleMove}
onEnd={handleEnd}
onSelect={onSelect}
onDragStart={handleDragStart}
useProminentStyles={focused}
isCollapsed={isCollapsed}
child={child}
>
{child.item.model === 'folder' &&
!isCollapsed(child.item.id) &&
draggingId !== child.item.id && (
<SidebarItems
treeParentMap={treeParentMap}
tree={child}
isCollapsed={isCollapsed}
draggingId={draggingId}
hoveredTree={hoveredTree}
hoveredIndex={hoveredIndex}
focused={focused}
selectedId={selectedId}
selectedTree={selectedTree}
onSelect={onSelect}
handleMove={handleMove}
handleEnd={handleEnd}
handleDragStart={handleDragStart}
/>
)}
</DraggableSidebarItem>
</Fragment>
))}
{tree.children.map((child, i) => {
const selected = selectedId === child.item.id;
const active = activeId === child.item.id;
return (
<Fragment key={child.item.id}>
{hoveredIndex === i && hoveredTree?.item.id === tree.item.id && <DropMarker />}
<DraggableSidebarItem
draggable
selected={selected}
itemId={child.item.id}
itemName={child.item.name}
itemFallbackName={
child.item.model === 'http_request' || child.item.model === 'grpc_request'
? fallbackRequestName(child.item)
: 'New Folder'
}
itemModel={child.item.model}
itemPrefix={
(child.item.model === 'http_request' || child.item.model === 'grpc_request') && (
<HttpMethodTag
request={child.item}
className={classNames(!(active || selected) && 'text-fg-subtler')}
/>
)
}
onMove={handleMove}
onEnd={handleEnd}
onSelect={onSelect}
onDragStart={handleDragStart}
useProminentStyles={focused}
isCollapsed={isCollapsed}
child={child}
>
{child.item.model === 'folder' &&
!isCollapsed(child.item.id) &&
draggingId !== child.item.id && (
<SidebarItems
treeParentMap={treeParentMap}
tree={child}
isCollapsed={isCollapsed}
draggingId={draggingId}
hoveredTree={hoveredTree}
hoveredIndex={hoveredIndex}
focused={focused}
activeId={activeId}
selectedId={selectedId}
selectedTree={selectedTree}
onSelect={onSelect}
handleMove={handleMove}
handleEnd={handleEnd}
handleDragStart={handleDragStart}
/>
)}
</DraggableSidebarItem>
</Fragment>
);
})}
{hoveredIndex === tree.children.length && hoveredTree?.item.id === tree.item.id && (
<DropMarker />
)}
@@ -608,7 +623,7 @@ const SidebarItem = forwardRef(function SidebarItem(
const deleteRequest = useDeleteRequest(activeRequest ?? null);
const duplicateHttpRequest = useDuplicateHttpRequest({ id: itemId, navigateAfter: true });
const duplicateGrpcRequest = useDuplicateGrpcRequest({ id: itemId, navigateAfter: true });
const [, copyAsCurl] = useCopyAsCurl(itemId);
const copyAsCurl = useCopyAsCurl(itemId);
const sendRequest = useSendRequest(itemId);
const sendManyRequests = useSendManyRequests();
const latestHttpResponse = useLatestHttpResponse(itemId);
@@ -753,11 +768,14 @@ const SidebarItem = forwardRef(function SidebarItem(
const name = await prompt({
id: 'rename-request',
title: 'Rename Request',
description: (
<>
Enter a new name for <InlineCode>{itemName}</InlineCode>
</>
),
description:
itemName === '' ? (
'Enter a new name'
) : (
<>
Enter a new name for <InlineCode>{itemName}</InlineCode>
</>
),
name: 'name',
label: 'Name',
placeholder: 'New Name',
@@ -802,12 +820,12 @@ const SidebarItem = forwardRef(function SidebarItem(
data-active={isActive}
data-selected={selected}
className={classNames(
'w-full flex gap-1.5 items-center text-sm h-xs px-1.5 rounded-md transition-colors',
'w-full flex gap-1.5 items-center h-xs px-1.5 rounded-md',
editing && 'ring-1 focus-within:ring-focus',
isActive && 'bg-highlightSecondary text-gray-800',
isActive && 'bg-background-highlight-secondary text-fg',
!isActive &&
'text-gray-600 group-hover/item:text-gray-800 active:bg-highlightSecondary',
selected && useProminentStyles && '!bg-violet-400/20',
'text-fg-subtle group-hover/item:text-fg active:bg-background-highlight-secondary',
selected && useProminentStyles && '!bg-background-active',
)}
>
{itemModel === 'folder' && (
@@ -815,7 +833,8 @@ const SidebarItem = forwardRef(function SidebarItem(
size="sm"
icon="chevronRight"
className={classNames(
'transition-transform opacity-50',
'text-fg-subtler',
'transition-transform',
!isCollapsed(itemId) && 'transform rotate-90',
)}
/>
@@ -837,15 +856,15 @@ const SidebarItem = forwardRef(function SidebarItem(
{latestGrpcConnection ? (
<div className="ml-auto">
{latestGrpcConnection.elapsed === 0 && (
<Icon spin size="sm" icon="update" className="text-gray-400" />
<Icon spin size="sm" icon="update" className="text-fg-subtler" />
)}
</div>
) : latestHttpResponse ? (
<div className="ml-auto">
{isResponseLoading(latestHttpResponse) ? (
<Icon spin size="sm" icon="update" className="text-gray-400" />
<Icon spin size="sm" icon="refresh" className="text-fg-subtler" />
) : (
<StatusTag className="text-2xs dark:opacity-80" response={latestHttpResponse} />
<StatusTag className="text-xs" response={latestHttpResponse} />
)}
</div>
) : null}

View File

@@ -19,7 +19,7 @@ export function SidebarActions() {
);
return (
<HStack className="h-full" alignItems="center">
<HStack className="h-full">
<IconButton
onClick={async () => {
trackEvent('sidebar', 'toggle');

View File

@@ -1,3 +1,4 @@
import classNames from 'classnames';
import type { EditorView } from 'codemirror';
import type { FormEvent, ReactNode } from 'react';
import { memo, useRef, useState } from 'react';
@@ -58,7 +59,7 @@ export const UrlBar = memo(function UrlBar({
};
return (
<form onSubmit={handleSubmit} className={className}>
<form onSubmit={handleSubmit} className={classNames('x-theme-urlBar', className)}>
<Input
autocompleteVariables
ref={inputRef}
@@ -75,7 +76,7 @@ export const UrlBar = memo(function UrlBar({
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onPaste={onPaste}
containerClassName="shadow shadow-gray-100 dark:shadow-gray-50"
containerClassName="bg-background border border-background-highlight"
onChange={onUrlChange}
defaultValue={url}
placeholder={placeholder}

View File

@@ -12,6 +12,8 @@ export function UrlParametersEditor({ urlParameters, forceUpdateKey, onChange }:
<PairEditor
valueAutocompleteVariables
nameAutocompleteVariables
namePlaceholder="param_name"
valuePlaceholder="Value"
pairs={urlParameters}
onChange={onChange}
forceUpdateKey={forceUpdateKey}

View File

@@ -0,0 +1,74 @@
import { getCurrent } from '@tauri-apps/api/webviewWindow';
import classNames from 'classnames';
import React, { useState } from 'react';
import { useOsInfo } from '../hooks/useOsInfo';
import { Button } from './core/Button';
import { HStack } from './core/Stacks';
interface Props {
className?: string;
onlyX?: boolean;
}
export function WindowControls({ className, onlyX }: Props) {
const [maximized, setMaximized] = useState<boolean>(false);
const osInfo = useOsInfo();
const shouldShow = osInfo?.osType === 'linux' || osInfo?.osType === 'windows';
if (!shouldShow) {
return null;
}
return (
<HStack className={classNames(className, 'ml-4 h-full')}>
{!onlyX && (
<>
<Button
className="!h-full px-4 text-fg-subtle hocus:text-fg hocus:bg-background-highlight-secondary rounded-none"
color="custom"
onClick={() => getCurrent().minimize()}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill="currentColor" d="M14 8v1H3V8z" />
</svg>
</Button>
<Button
className="!h-full px-4 text-fg-subtle hocus:text-fg hocus:bg-background-highlight rounded-none"
color="custom"
onClick={async () => {
const w = getCurrent();
await w.toggleMaximize();
setMaximized(await w.isMaximized());
}}
>
{maximized ? (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<g fill="currentColor">
<path d="M3 5v9h9V5zm8 8H4V6h7z" />
<path fillRule="evenodd" d="M5 5h1V4h7v7h-1v1h2V3H5z" clipRule="evenodd" />
</g>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill="currentColor" d="M3 3v10h10V3zm9 9H4V4h8z" />
</svg>
)}
</Button>
</>
)}
<Button
color="custom"
className="!h-full px-4 text-fg-subtle rounded-none hocus:bg-fg-danger hocus:text-fg"
onClick={() => getCurrent().close()}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path
fill="currentColor"
fillRule="evenodd"
d="m7.116 8l-4.558 4.558l.884.884L8 8.884l4.558 4.558l.884-.884L8.884 8l4.558-4.558l-.884-.884L8 7.116L3.442 2.558l-.884.884z"
clipRule="evenodd"
/>
</svg>
</Button>
</HStack>
);
}

View File

@@ -1,11 +1,6 @@
import classNames from 'classnames';
import { motion } from 'framer-motion';
import type {
CSSProperties,
HTMLAttributes,
MouseEvent as ReactMouseEvent,
ReactNode,
} from 'react';
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useWindowSize } from 'react-use';
import { useActiveRequest } from '../hooks/useActiveRequest';
@@ -13,11 +8,10 @@ import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { useFloatingSidebarHidden } from '../hooks/useFloatingSidebarHidden';
import { useImportData } from '../hooks/useImportData';
import { useIsFullscreen } from '../hooks/useIsFullscreen';
import { useOsInfo } from '../hooks/useOsInfo';
import { useShouldFloatSidebar } from '../hooks/useShouldFloatSidebar';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useSidebarWidth } from '../hooks/useSidebarWidth';
import { useSyncWorkspaceRequestTitle } from '../hooks/useSyncWorkspaceRequestTitle';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
@@ -27,6 +21,7 @@ import { FeedbackLink } from './core/Link';
import { HStack } from './core/Stacks';
import { CreateDropdown } from './CreateDropdown';
import { GrpcConnectionLayout } from './GrpcConnectionLayout';
import { HeaderSize } from './HeaderSize';
import { HttpRequestLayout } from './HttpRequestLayout';
import { Overlay } from './Overlay';
import { ResizeHandle } from './ResizeHandle';
@@ -40,6 +35,7 @@ const body = { gridArea: 'body' };
const drag = { gridArea: 'drag' };
export default function Workspace() {
useSyncWorkspaceRequestTitle();
const workspaces = useWorkspaces();
const activeWorkspace = useActiveWorkspace();
const activeWorkspaceId = useActiveWorkspaceId();
@@ -130,7 +126,7 @@ export default function Workspace() {
'grid w-full h-full',
// Animate sidebar width changes but only when not resizing
// because it's too slow to animate on mouse move
!isResizing && 'transition-all',
!isResizing && 'transition-grid',
)}
>
{floating ? (
@@ -143,11 +139,12 @@ export default function Workspace() {
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
className={classNames(
'absolute top-0 left-0 bottom-0 bg-gray-100 border-r border-highlight w-[14rem]',
'x-theme-sidebar',
'absolute top-0 left-0 bottom-0 bg-background border-r border-background-highlight w-[14rem]',
'grid grid-rows-[auto_1fr]',
)}
>
<HeaderSize className="border-transparent">
<HeaderSize size="lg" className="border-transparent">
<SidebarActions />
</HeaderSize>
<Sidebar />
@@ -155,8 +152,11 @@ export default function Workspace() {
</Overlay>
) : (
<>
<div style={side} className={classNames('overflow-hidden bg-gray-100')}>
<Sidebar className="border-r border-highlight" />
<div
style={side}
className={classNames('x-theme-sidebar', 'overflow-hidden bg-background')}
>
<Sidebar className="border-r border-background-highlight" />
</div>
<ResizeHandle
className="-translate-x-3"
@@ -168,14 +168,19 @@ export default function Workspace() {
/>
</>
)}
<HeaderSize data-tauri-drag-region style={head}>
<HeaderSize
data-tauri-drag-region
size="lg"
className="x-theme-appHeader bg-background"
style={head}
>
<WorkspaceHeader className="pointer-events-none" />
</HeaderSize>
{activeWorkspace == null ? (
<div className="m-auto">
<Banner color="warning" className="max-w-[30rem]">
The active workspace{' '}
<InlineCode className="text-orange-800">{activeWorkspaceId}</InlineCode> was not found.
<InlineCode className="text-fg-warning">{activeWorkspaceId}</InlineCode> was not found.
Select a workspace from the header menu or report this bug to <FeedbackLink />
</Banner>
</div>
@@ -203,26 +208,3 @@ export default function Workspace() {
</div>
);
}
interface HeaderSizeProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
}
function HeaderSize({ className, style, ...props }: HeaderSizeProps) {
const platform = useOsInfo();
const fullscreen = useIsFullscreen();
const stoplightsVisible = platform?.osType === 'macos' && !fullscreen;
return (
<div
style={style}
className={classNames(
className,
'h-md pt-[1px] w-full border-b min-w-0',
stoplightsVisible ? 'pl-20 pr-1' : 'pl-1',
)}
>
{/* NOTE: This needs display:grid or else the element shrinks (even though scrollable) */}
<div className="h-full w-full overflow-x-auto hide-scrollbars grid" {...props} />
</div>
);
}

View File

@@ -52,15 +52,10 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
),
render: ({ hide }) => {
return (
<HStack
space={2}
justifyContent="start"
alignItems="center"
className="mt-4 mb-6 flex-row-reverse"
>
<HStack space={2} justifyContent="start" className="mt-4 mb-6 flex-row-reverse">
<Button
className="focus"
color="gray"
color="primary"
onClick={async () => {
hide();
const environmentId = (await getRecentEnvironments(w.id))[0];
@@ -76,7 +71,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
</Button>
<Button
className="focus"
color="gray"
color="secondary"
rightSlot={<Icon icon="externalLink" />}
onClick={async () => {
hide();
@@ -169,7 +164,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
size="sm"
className={classNames(
className,
'text-gray-800 !px-2 truncate',
'text-fg !px-2 truncate',
activeWorkspace === null && 'italic opacity-disabled',
)}
{...buttonProps}

View File

@@ -1,95 +1,39 @@
import { getCurrent } from '@tauri-apps/api/webviewWindow';
import classNames from 'classnames';
import React, { memo, useState } from 'react';
import { useOsInfo } from '../hooks/useOsInfo';
import React, { memo } from 'react';
import { CookieDropdown } from './CookieDropdown';
import { Button } from './core/Button';
import { Icon } from './core/Icon';
import { HStack } from './core/Stacks';
import { EnvironmentActionsDropdown } from './EnvironmentActionsDropdown';
import { ImportCurlButton } from './ImportCurlButton';
import { RecentRequestsDropdown } from './RecentRequestsDropdown';
import { SettingsDropdown } from './SettingsDropdown';
import { SidebarActions } from './SidebarActions';
import { WindowControls } from './WindowControls';
import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
import { ImportCurlButton } from './ImportCurlButton';
interface Props {
className?: string;
}
export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Props) {
const osInfo = useOsInfo();
const [maximized, setMaximized] = useState<boolean>(false);
return (
<HStack
space={2}
justifyContent="center"
alignItems="center"
className={classNames(className, 'w-full h-full')}
>
<HStack space={0.5} className="flex-1 pointer-events-none" alignItems="center">
<HStack space={2} justifyContent="center" className={classNames(className, 'w-full h-full')}>
<HStack space={0.5} className="flex-1 pointer-events-none">
<SidebarActions />
<CookieDropdown />
<HStack alignItems="center">
<HStack>
<WorkspaceActionsDropdown />
<Icon icon="chevronRight" className="text-gray-900 text-opacity-disabled" />
<Icon icon="chevronRight" className="text-fg-subtle" />
<EnvironmentActionsDropdown className="w-auto pointer-events-auto" />
</HStack>
</HStack>
<div className="pointer-events-none">
<RecentRequestsDropdown />
</div>
<div className="flex-1 flex gap-1 items-center h-full justify-end pointer-events-none">
<div className="flex-1 flex gap-1 items-center h-full justify-end pointer-events-none pr-0.5">
<ImportCurlButton />
<SettingsDropdown />
{(osInfo?.osType === 'linux' || osInfo?.osType === 'windows') && (
<HStack className="ml-4" alignItems="center">
<Button
className="px-4 !text-gray-600 rounded-none"
onClick={() => getCurrent().minimize()}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill="currentColor" d="M14 8v1H3V8z" />
</svg>
</Button>
<Button
className="px-4 !text-gray-600 rounded-none"
onClick={async () => {
const w = getCurrent();
await w.toggleMaximize();
setMaximized(await w.isMaximized());
}}
>
{maximized ? (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<g fill="currentColor">
<path d="M3 5v9h9V5zm8 8H4V6h7z" />
<path fillRule="evenodd" d="M5 5h1V4h7v7h-1v1h2V3H5z" clipRule="evenodd" />
</g>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill="currentColor" d="M3 3v10h10V3zm9 9H4V4h8z" />
</svg>
)}
</Button>
<Button
color="custom"
className="px-4 text-gray-600 rounded-none hocus:bg-red-200 hocus:text-gray-800"
onClick={() => getCurrent().close()}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path
fill="currentColor"
fillRule="evenodd"
d="m7.116 8l-4.558 4.558l.884.884L8 8.884l4.558 4.558l.884-.884L8.884 8l4.558-4.558l-.884-.884L8 7.116L3.442 2.558l-.884.884z"
clipRule="evenodd"
/>
</svg>
</Button>
</HStack>
)}
<WindowControls />
</div>
</HStack>
);

View File

@@ -4,19 +4,18 @@ import type { ReactNode } from 'react';
interface Props {
children: ReactNode;
className?: string;
color?: 'danger' | 'warning' | 'success' | 'gray';
color?: 'primary' | 'secondary' | 'success' | 'notice' | 'warning' | 'danger';
}
export function Banner({ children, className, color = 'gray' }: Props) {
export function Banner({ children, className, color = 'secondary' }: Props) {
return (
<div>
<div
className={classNames(
className,
`x-theme-banner--${color}`,
'border border-dashed italic px-3 py-2 rounded select-auto cursor-text',
color === 'gray' && 'border-gray-500/60 bg-gray-300/10 text-gray-800',
color === 'warning' && 'border-orange-500/60 bg-orange-300/10 text-orange-800',
color === 'danger' && 'border-red-500/60 bg-red-300/10 text-red-800',
color === 'success' && 'border-violet-500/60 bg-violet-300/10 text-violet-800',
'border-background-highlight bg-background-highlight-secondary text-fg',
)}
>
{children}

View File

@@ -7,10 +7,19 @@ import { Icon } from './Icon';
export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, 'color'> & {
innerClassName?: string;
color?: 'custom' | 'default' | 'gray' | 'primary' | 'secondary' | 'warning' | 'danger';
color?:
| 'custom'
| 'default'
| 'secondary'
| 'primary'
| 'info'
| 'success'
| 'notice'
| 'warning'
| 'danger';
variant?: 'border' | 'solid';
isLoading?: boolean;
size?: 'xs' | 'sm' | 'md';
size?: '2xs' | 'xs' | 'sm' | 'md';
justify?: 'start' | 'center';
type?: 'button' | 'submit';
forDropdown?: boolean;
@@ -48,57 +57,44 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
const classes = classNames(
className,
'x-theme-button',
`x-theme-button--${variant}`,
`x-theme-button--${variant}--${color}`,
'text-fg',
'border', // They all have borders to ensure the same width
'max-w-full min-w-0', // Help with truncation
'hocus:opacity-100', // Force opacity for certain hover effects
'whitespace-nowrap outline-none',
'flex-shrink-0 flex items-center',
'focus-visible-or-class:ring rounded-md',
'focus-visible-or-class:ring',
disabled ? 'pointer-events-none opacity-disabled' : 'pointer-events-auto',
justify === 'start' && 'justify-start',
justify === 'center' && 'justify-center',
size === 'md' && 'h-md px-3',
size === 'sm' && 'h-sm px-2.5 text-sm',
size === 'xs' && 'h-xs px-2 text-sm',
size === 'md' && 'h-md px-3 rounded-md',
size === 'sm' && 'h-sm px-2.5 rounded-md',
size === 'xs' && 'h-xs px-2 text-sm rounded-md',
size === '2xs' && 'h-5 px-1 text-xs rounded',
// Solids
variant === 'solid' && 'border-transparent',
variant === 'solid' && color === 'custom' && 'ring-blue-400',
variant === 'solid' &&
color !== 'custom' &&
color !== 'default' &&
'bg-background enabled:hocus:bg-background-highlight ring-background-highlight-secondary',
variant === 'solid' &&
color === 'default' &&
'text-gray-700 enabled:hocus:bg-gray-700/10 enabled:hocus:text-gray-800 ring-blue-400',
variant === 'solid' &&
color === 'gray' &&
'text-gray-800 bg-gray-200/70 enabled:hocus:bg-gray-200 ring-blue-400',
variant === 'solid' &&
color === 'primary' &&
'bg-blue-400 text-white ring-blue-700 enabled:hocus:bg-blue-500',
variant === 'solid' &&
color === 'secondary' &&
'bg-violet-400 text-white ring-violet-700 enabled:hocus:bg-violet-500',
variant === 'solid' &&
color === 'warning' &&
'bg-orange-400 text-white ring-orange-700 enabled:hocus:bg-orange-500',
variant === 'solid' &&
color === 'danger' &&
'bg-red-400 text-white ring-red-700 enabled:hocus:bg-red-500',
'enabled:hocus:bg-background-highlight ring-fg-info',
// Borders
variant === 'border' && 'border',
variant === 'border' &&
color !== 'custom' &&
color !== 'default' &&
'border-fg-subtler text-fg-subtle enabled:hocus:border-fg-subtle enabled:hocus:bg-background-highlight enabled:hocus:text-fg ring-fg-subtler',
variant === 'border' &&
color === 'default' &&
'border-highlight text-gray-700 enabled:hocus:border-focus enabled:hocus:text-gray-800 ring-blue-500/50',
variant === 'border' &&
color === 'gray' &&
'border-gray-500/70 text-gray-700 enabled:hocus:bg-gray-500/20 enabled:hocus:text-gray-800 ring-blue-500/50',
variant === 'border' &&
color === 'primary' &&
'border-blue-500/70 text-blue-700 enabled:hocus:border-blue-500 ring-blue-500/50',
variant === 'border' &&
color === 'secondary' &&
'border-violet-500/70 text-violet-700 enabled:hocus:border-violet-500 ring-violet-500/50',
variant === 'border' &&
color === 'warning' &&
'border-orange-500/70 text-orange-700 enabled:hocus:border-orange-500 ring-orange-500/50',
variant === 'border' &&
color === 'danger' &&
'border-red-500/70 text-red-700 enabled:hocus:border-red-500 ring-red-500/50',
'border-background-highlight enabled:hocus:border-fg-subtler enabled:hocus:bg-background-highlight-secondary',
);
const buttonRef = useRef<HTMLButtonElement>(null);
@@ -124,7 +120,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
{isLoading ? (
<Icon icon="refresh" size={size} className="animate-spin mr-1" />
) : leftSlot ? (
<div className="mr-1">{leftSlot}</div>
<div className="mr-2">{leftSlot}</div>
) : null}
<div
className={classNames(

View File

@@ -27,15 +27,14 @@ export function Checkbox({
<HStack
as="label"
space={2}
alignItems="center"
className={classNames(className, 'text-gray-900 text-sm', disabled && 'opacity-disabled')}
className={classNames(className, 'text-fg', disabled && 'opacity-disabled')}
>
<div className={classNames(inputWrapperClassName, 'relative flex')}>
<div className={classNames(inputWrapperClassName, 'x-theme-input', 'relative flex')}>
<input
aria-hidden
className={classNames(
'appearance-none w-4 h-4 flex-shrink-0 border border-highlight',
'rounded hocus:border-focus hocus:bg-focus/[5%] outline-none ring-0',
'appearance-none w-4 h-4 flex-shrink-0 border border-background-highlight',
'rounded hocus:border-border-focus hocus:bg-focus/[5%] outline-none ring-0',
)}
type="checkbox"
disabled={disabled}

View File

@@ -12,7 +12,7 @@ export function CountBadge({ count, className }: Props) {
aria-hidden
className={classNames(
className,
'opacity-70 border border-highlight text-4xs rounded mb-0.5 px-1 ml-1 h-4 font-mono',
'opacity-70 border border-background-highlight-secondary text-4xs rounded mb-0.5 px-1 ml-1 h-4 font-mono',
)}
>
{count}

View File

@@ -50,7 +50,7 @@ export function Dialog({
return (
<Overlay open={open} onClose={onClose} portalName="dialog">
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="x-theme-dialog absolute inset-0 flex items-center justify-center pointer-events-none">
<div
role="dialog"
aria-labelledby={titleId}
@@ -63,9 +63,9 @@ export function Dialog({
className={classNames(
className,
'grid grid-rows-[auto_auto_minmax(0,1fr)]',
'relative bg-gray-50 pointer-events-auto',
'relative bg-background pointer-events-auto',
'rounded-lg',
'dark:border border-highlight shadow shadow-black/10',
'border border-background-highlight-secondary shadow-lg shadow-[rgba(0,0,0,0.1)]',
'max-w-[calc(100vw-5rem)] max-h-[calc(100vh-6rem)]',
size === 'sm' && 'w-[25rem] max-h-[80vh]',
size === 'md' && 'w-[45rem] max-h-[80vh]',
@@ -83,7 +83,7 @@ export function Dialog({
)}
{description ? (
<p className="px-6 text-gray-700" id={descriptionId}>
<p className="px-6 text-fg-subtle" id={descriptionId}>
{description}
</p>
) : (
@@ -94,7 +94,7 @@ export function Dialog({
className={classNames(
'h-full w-full grid grid-cols-[minmax(0,1fr)]',
!noPadding && 'px-6 py-2',
!noScroll && 'overflow-y-auto',
!noScroll && 'overflow-y-auto overflow-x-hidden',
)}
>
{children}

View File

@@ -23,14 +23,14 @@ import React, {
import { useKey, useWindowSize } from 'react-use';
import type { HotkeyAction } from '../../hooks/useHotKey';
import { useHotKey } from '../../hooks/useHotKey';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
import { getNodeText } from '../../lib/getNodeText';
import { Overlay } from '../Overlay';
import { Button } from './Button';
import { HotKey } from './HotKey';
import { Icon } from './Icon';
import { Separator } from './Separator';
import { HStack, VStack } from './Stacks';
import { Icon } from './Icon';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
export type DropdownItemSeparator = {
type: 'separator';
@@ -59,6 +59,7 @@ export interface DropdownProps {
items: DropdownItem[];
onOpen?: () => void;
onClose?: () => void;
fullWidth?: boolean;
hotKeyAction?: HotkeyAction;
}
@@ -73,7 +74,7 @@ export interface DropdownRef {
}
export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown(
{ children, items, onOpen, onClose, hotKeyAction }: DropdownProps,
{ children, items, onOpen, onClose, hotKeyAction, fullWidth }: DropdownProps,
ref,
) {
const [isOpen, _setIsOpen] = useState<boolean>(false);
@@ -153,6 +154,7 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
<Menu
ref={menuRef}
showTriangle
fullWidth={fullWidth}
defaultSelectedIndex={defaultSelectedIndex}
items={items}
triggerShape={triggerRect ?? null}
@@ -203,6 +205,7 @@ interface MenuProps {
triggerShape: Pick<DOMRect, 'top' | 'bottom' | 'left' | 'right'> | null;
onClose: () => void;
showTriangle?: boolean;
fullWidth?: boolean;
isOpen: boolean;
}
@@ -211,6 +214,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
className,
isOpen,
items,
fullWidth,
onClose,
triggerShape,
defaultSelectedIndex,
@@ -224,7 +228,6 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
);
const [menuStyles, setMenuStyles] = useState<CSSProperties>({});
const [filter, setFilter] = useState<string>('');
const [containerWidth, setContainerWidth] = useState<number | null>(null);
// Calculate the max height so we can scroll
const initMenu = useCallback((el: HTMLDivElement | null) => {
@@ -349,11 +352,6 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
[handleClose, handleNext, handlePrev, handleSelect, items, selectedIndex],
);
const initContainerRef = (n: HTMLDivElement | null) => {
if (n == null) return null;
setContainerWidth(n.offsetWidth);
};
const { containerStyles, triangleStyles } = useMemo<{
containerStyles: CSSProperties;
triangleStyles: CSSProperties | null;
@@ -365,22 +363,23 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
const heightAbove = triggerShape.top;
const heightBelow = docRect.height - triggerShape.bottom;
const hSpaceRemaining = docRect.width - triggerShape.left;
const top = triggerShape?.bottom + 5;
const top = triggerShape.bottom + 5;
const onRight = hSpaceRemaining < 200;
const upsideDown = heightAbove > heightBelow && heightBelow < 200;
const triggerWidth = triggerShape.right - triggerShape.left;
const containerStyles = {
top: !upsideDown ? top : undefined,
bottom: upsideDown ? docRect.height - top : undefined,
right: onRight ? docRect.width - triggerShape?.right : undefined,
left: !onRight ? triggerShape?.left : undefined,
width: containerWidth ?? 'auto',
right: onRight ? docRect.width - triggerShape.right : undefined,
left: !onRight ? triggerShape.left : undefined,
minWidth: fullWidth ? triggerWidth : undefined,
};
const size = { top: '-0.2rem', width: '0.4rem', height: '0.4rem' };
const triangleStyles = onRight
? { right: width / 2, marginRight: '-0.2rem', ...size }
: { left: width / 2, marginLeft: '-0.2rem', ...size };
return { containerStyles, triangleStyles };
}, [triggerShape, containerWidth]);
}, [fullWidth, triggerShape]);
const filteredItems = useMemo(
() => items.filter((i) => getNodeText(i.label).toLowerCase().includes(filter.toLowerCase())),
@@ -413,7 +412,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
)}
{isOpen && (
<Overlay open variant="transparent" portalName="dropdown" zIndex={50}>
<div>
<div className="x-theme-menu">
<div tabIndex={-1} aria-hidden className="fixed inset-0 z-30" onClick={handleClose} />
<motion.div
tabIndex={0}
@@ -423,7 +422,6 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
role="menu"
aria-orientation="vertical"
dir="ltr"
ref={initContainerRef}
style={containerStyles}
className={classNames(className, 'outline-none my-1 pointer-events-auto fixed z-50')}
>
@@ -431,7 +429,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
<span
aria-hidden
style={triangleStyles}
className="bg-gray-50 absolute rotate-45 border-gray-200 border-t border-l"
className="bg-background absolute rotate-45 border-background-highlight-secondary border-t border-l"
/>
)}
{containerStyles && (
@@ -440,22 +438,21 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
style={menuStyles}
className={classNames(
className,
'h-auto bg-gray-50 rounded-md shadow-lg dark:shadow-gray-0 py-1.5 border',
'border-gray-200 overflow-auto mb-1 mx-0.5',
'h-auto bg-background rounded-md shadow-lg py-1.5 border',
'border-background-highlight-secondary overflow-auto mb-1 mx-0.5',
)}
>
{filter && (
<HStack
space={2}
alignItems="center"
className="pb-0.5 px-1.5 mb-2 text-xs border border-highlight mx-2 rounded font-mono h-2xs"
className="pb-0.5 px-1.5 mb-2 text-sm border border-background-highlight-secondary mx-2 rounded font-mono h-xs"
>
<Icon icon="search" size="xs" className="text-gray-700" />
<div className="text-gray-800">{filter}</div>
<Icon icon="search" size="xs" className="text-fg-subtle" />
<div className="text-fg">{filter}</div>
</HStack>
)}
{filteredItems.length === 0 && (
<span className="text-gray-500 text-sm text-center px-2 py-1">No matches</span>
<span className="text-fg-subtler text-center px-2 py-1">No matches</span>
)}
{filteredItems.map((item, i) => {
if (item.type === 'separator') {
@@ -529,17 +526,21 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
onFocus={handleFocus}
onClick={handleClick}
justify="start"
leftSlot={item.leftSlot && <div className="pr-2 flex justify-start">{item.leftSlot}</div>}
leftSlot={
item.leftSlot && <div className="pr-2 flex justify-start opacity-70">{item.leftSlot}</div>
}
rightSlot={rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>}
innerClassName="!text-left"
color="custom"
className={classNames(
className,
'h-xs', // More compact
'min-w-[8rem] outline-none px-2 mx-1.5 flex text-sm text-gray-700 whitespace-nowrap',
'focus:bg-highlight focus:text-gray-800 rounded',
item.variant === 'danger' && 'text-red-600',
item.variant === 'notify' && 'text-pink-600',
'min-w-[8rem] outline-none px-2 mx-1.5 flex whitespace-nowrap',
'focus:bg-background-highlight focus:text-fg rounded',
item.variant === 'default' && 'text-fg-subtle',
item.variant === 'danger' && 'text-fg-danger',
item.variant === 'notify' && 'text-fg-primary',
)}
innerClassName="!text-left"
{...props}
>
<div

View File

@@ -5,7 +5,10 @@ interface Props {
export function DurationTag({ total, headers }: Props) {
return (
<span title={`HEADER: ${formatMillis(headers)}\nTOTAL: ${formatMillis(total)}`}>
<span
className="font-mono"
title={`HEADER: ${formatMillis(headers)}\nTOTAL: ${formatMillis(total)}`}
>
{formatMillis(total)}
</span>
);

View File

@@ -5,7 +5,9 @@
@apply w-full block text-base;
.cm-cursor {
@apply border-gray-800 !important;
@apply border-fg !important;
/* Widen the cursor */
@apply border-l-[2px];
}
&.cm-focused {
@@ -17,7 +19,7 @@
}
.cm-line {
@apply text-gray-800 pl-1 pr-1.5;
@apply text-fg pl-1 pr-1.5;
}
.cm-placeholder {
@@ -47,27 +49,21 @@
/* Style gutters */
.cm-gutters {
@apply border-0 text-gray-500/50;
@apply border-0 text-fg-subtler bg-transparent;
.cm-gutterElement {
@apply cursor-default;
}
}
.placeholder-widget {
@apply text-xs text-violet-700 dark:text-violet-700 px-1 mx-[0.5px] rounded cursor-default dark:shadow;
.placeholder {
/* Colors */
@apply bg-background text-fg-subtle border-background-highlight-secondary;
@apply hover:border-background-highlight hover:text-fg hover:bg-background-highlight-secondary;
/* NOTE: Background and border are translucent so we can see text selection through it */
@apply bg-violet-500/20 border border-violet-500/20 border-opacity-40;
/* Bring above on hover */
@apply hover:z-10 relative;
@apply border px-1 mx-[0.5px] rounded cursor-default dark:shadow;
-webkit-text-security: none;
&.placeholder-widget-error {
@apply text-red-700 dark:text-red-800 bg-red-300/30 border-red-300/80 border-opacity-40 hover:border-red-300 hover:bg-red-300/40;
}
}
.hyperlink-widget {
@@ -108,8 +104,7 @@
}
.cm-scroller {
@apply font-mono text-[0.75rem];
@apply font-mono text-editor;
/*
* Round corners or they'll stick out of the editor bounds of editor is rounded.
* Could potentially be pushed up from the editor like we do with bg color but this
@@ -137,7 +132,7 @@
.cm-editor .fold-gutter-icon::after {
@apply block w-1.5 h-1.5 border-transparent -rotate-45
border-l border-b border-l-[currentColor] border-b-[currentColor] content-[''];
border-l border-b border-l-[currentColor] border-b-[currentColor] content-[''];
}
.cm-editor .fold-gutter-icon[data-open] {
@@ -149,12 +144,12 @@
}
.cm-editor .fold-gutter-icon:hover {
@apply text-gray-900 bg-gray-300/50;
@apply text-fg bg-background-highlight;
}
.cm-editor .cm-foldPlaceholder {
@apply px-2 border border-gray-400/50 bg-gray-300/50;
@apply hover:text-gray-800 hover:border-gray-400;
@apply px-2 border border-fg-subtler bg-background-highlight;
@apply hover:text-fg hover:border-fg-subtle text-fg;
@apply cursor-default !important;
}
@@ -164,11 +159,13 @@
.cm-wrapper:not(.cm-readonly) .cm-editor {
&.cm-focused .cm-activeLineGutter {
@apply text-gray-600;
@apply text-fg-subtle;
}
}
.cm-wrapper.cm-readonly .cm-editor {
.cm-cursor {
@apply border-l-2 border-gray-800;
@apply border-fg-danger !important;
}
}
@@ -187,18 +184,18 @@
}
.cm-tooltip.cm-tooltip-hover {
@apply shadow-lg bg-gray-100 rounded text-gray-700 border border-gray-500 z-50 pointer-events-auto text-xs;
@apply shadow-lg bg-background rounded text-fg-subtle border border-fg-subtler z-50 pointer-events-auto text-sm;
@apply px-2 py-1;
a {
@apply text-gray-800;
@apply text-fg;
&:hover {
@apply underline;
}
&::after {
@apply text-gray-800 bg-gray-800 h-3 w-3 ml-1;
@apply text-fg bg-fg-secondary h-3 w-3 ml-1;
content: '';
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' fill='black' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5z'/%3E%3Cpath fill-rule='evenodd' d='M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0v-5z'/%3E%3C/svg%3E");
-webkit-mask-size: contain;
@@ -210,7 +207,7 @@
/* NOTE: Extra selector required to override default styles */
.cm-tooltip.cm-tooltip-autocomplete,
.cm-tooltip.cm-completionInfo {
@apply shadow-lg bg-gray-100 rounded text-gray-700 border border-gray-300 z-50 pointer-events-auto text-xs;
@apply shadow-lg bg-background rounded text-fg-subtle border border-background-highlight z-50 pointer-events-auto text-sm;
.cm-completionIcon {
@apply italic font-mono;
@@ -269,7 +266,7 @@
}
&.cm-completionInfo-right {
@apply ml-1 -mt-0.5 text-sm;
@apply ml-1 -mt-0.5 font-sans;
}
&.cm-completionInfo-right-narrow {
@@ -281,24 +278,26 @@
}
&.cm-tooltip-autocomplete {
@apply font-mono;
& > ul {
@apply p-1 max-h-[40vh];
}
& > ul > li {
@apply cursor-default px-2 rounded-sm text-gray-600 h-7 flex items-center;
@apply cursor-default px-2 py-1.5 rounded-sm text-fg-subtle flex items-center;
}
& > ul > li[aria-selected] {
@apply bg-highlight text-gray-900;
@apply bg-background-highlight-secondary text-fg;
}
.cm-completionIcon {
@apply text-xs flex items-center pb-0.5 flex-shrink-0;
@apply text-sm flex items-center pb-0.5 flex-shrink-0;
}
.cm-completionLabel {
@apply text-gray-700;
@apply text-fg-subtle;
}
.cm-completionDetail {
@@ -308,7 +307,7 @@
}
.cm-editor .cm-panels {
@apply bg-gray-100 backdrop-blur-sm p-1 mb-1 text-gray-800 z-20 rounded-md;
@apply bg-background-highlight-secondary backdrop-blur-sm p-1 mb-1 text-fg z-20 rounded-md;
input,
button {
@@ -316,19 +315,21 @@
}
button {
@apply appearance-none bg-none bg-gray-200 hocus:bg-gray-300 hocus:text-gray-950 border-0 text-gray-800 cursor-default;
@apply border-fg-subtler bg-background-highlight text-fg hover:border-fg-info;
@apply appearance-none bg-none cursor-default;
}
button[name='close'] {
@apply text-gray-600 hocus:text-gray-900 px-2 -mr-1.5 !important;
@apply text-fg-subtle hocus:text-fg px-2 -mr-1.5 !important;
}
input {
@apply bg-gray-50 border border-gray-500/50 focus:border-focus outline-none;
@apply bg-background border-background-highlight focus:border-border-focus;
@apply border outline-none cursor-text;
}
label {
@apply focus-within:text-gray-950;
@apply focus-within:text-fg;
}
/* Hide the "All" button */

View File

@@ -17,6 +17,7 @@ import {
} from 'react';
import { useActiveEnvironment } from '../../../hooks/useActiveEnvironment';
import { useActiveWorkspace } from '../../../hooks/useActiveWorkspace';
import { useSettings } from '../../../hooks/useSettings';
import { IconButton } from '../IconButton';
import { HStack } from '../Stacks';
import './Editor.css';
@@ -85,11 +86,16 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
}: EditorProps,
ref,
) {
const s = useSettings();
const e = useActiveEnvironment();
const w = useActiveWorkspace();
const environment = autocompleteVariables ? e : null;
const workspace = autocompleteVariables ? w : null;
if (s && wrapLines === undefined) {
wrapLines = s.editorSoftWrap;
}
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
useImperativeHandle(ref, () => cm.current?.view);
@@ -189,7 +195,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
placeholderCompartment.current.of(
placeholderExt(placeholderElFromText(placeholder ?? '')),
),
wrapLinesCompartment.current.of([]),
wrapLinesCompartment.current.of(wrapLines ? [EditorView.lineWrapping] : []),
...getExtensions({
container,
readOnly,
@@ -206,7 +212,6 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
view = new EditorView({ state, parent: container });
cm.current = { view, languageCompartment };
syncGutterBg({ parent: container, bgClassList });
if (autoFocus) {
view.focus();
}
@@ -270,7 +275,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
ref={initEditorRef}
className={classNames(
className,
'cm-wrapper text-base bg-gray-50',
'cm-wrapper text-base',
type === 'password' && 'cm-obscure-text',
heightMode === 'auto' ? 'cm-auto-height' : 'cm-full-height',
singleLine ? 'cm-singleline' : 'cm-multiline',
@@ -284,12 +289,11 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
}
return (
<div className="group relative h-full w-full">
<div className="group relative h-full w-full x-theme-editor bg-background">
{cmContainer}
{decoratedActions && (
<HStack
space={1}
alignItems="center"
justifyContent="end"
className={classNames(
'absolute bottom-2 left-0 right-0',
@@ -327,7 +331,25 @@ function getExtensions({
undefined;
return [
// NOTE: These *must* be anonymous functions so the references update properly
...baseExtensions, // Must be first
tooltips({ parent }),
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
...(singleLine ? [singleLineExt()] : []),
...(!singleLine ? [multiLineExtensions] : []),
...(readOnly
? [EditorState.readOnly.of(true), EditorView.contentAttributes.of({ tabindex: '-1' })]
: []),
// ------------------------ //
// Things that must be last //
// ------------------------ //
EditorView.updateListener.of((update) => {
if (onChange && update.docChanged) {
onChange.current?.(update.state.doc.toString());
}
}),
EditorView.domEventHandlers({
focus: () => {
onFocus.current?.();
@@ -342,38 +364,9 @@ function getExtensions({
onPaste.current?.(e.clipboardData?.getData('text/plain') ?? '');
},
}),
tooltips({ parent }),
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
...(singleLine ? [singleLineExt()] : []),
...(!singleLine ? [multiLineExtensions] : []),
...(readOnly
? [EditorState.readOnly.of(true), EditorView.contentAttributes.of({ tabindex: '-1' })]
: []),
// Handle onChange
EditorView.updateListener.of((update) => {
if (onChange && update.docChanged) {
onChange.current?.(update.state.doc.toString());
}
}),
...baseExtensions,
];
}
const syncGutterBg = ({
parent,
bgClassList,
}: {
parent: HTMLDivElement;
bgClassList: string[];
}) => {
const gutterEl = parent.querySelector<HTMLDivElement>('.cm-gutters');
if (gutterEl) {
gutterEl?.classList.add(...bgClassList);
}
};
const placeholderElFromText = (text: string) => {
const el = document.createElement('div');
el.innerHTML = text.replace('\n', '<br/>');

View File

@@ -39,51 +39,29 @@ import { text } from './text/extension';
import { twig } from './twig/extension';
import { url } from './url/extension';
export const myHighlightStyle = HighlightStyle.define([
export const syntaxHighlightStyle = HighlightStyle.define([
{
tag: [t.documentMeta, t.blockComment, t.lineComment, t.docComment, t.comment],
color: 'hsl(var(--color-gray-600))',
color: 'var(--fg-subtler)',
fontStyle: 'italic',
},
{
tag: [t.paren],
color: 'hsl(var(--color-gray-900))',
tag: [t.paren, t.bracket, t.brace],
color: 'var(--fg)',
},
{
tag: [t.name, t.tagName, t.angleBracket, t.docString, t.number],
color: 'hsl(var(--color-blue-600))',
tag: [t.link, t.name, t.tagName, t.angleBracket, t.docString, t.number],
color: 'var(--fg-info)',
},
{ tag: [t.variableName], color: 'hsl(var(--color-green-600))' },
{ tag: [t.bool], color: 'hsl(var(--color-pink-600))' },
{ tag: [t.attributeName, t.propertyName], color: 'hsl(var(--color-violet-600))' },
{ tag: [t.attributeValue], color: 'hsl(var(--color-orange-600))' },
{ tag: [t.string], color: 'hsl(var(--color-yellow-600))' },
{ tag: [t.keyword, t.meta, t.operator], color: 'hsl(var(--color-red-600))' },
{ tag: [t.variableName], color: 'var(--fg-success)' },
{ tag: [t.bool], color: 'var(--fg-warning)' },
{ tag: [t.attributeName, t.propertyName], color: 'var(--fg-primary)' },
{ tag: [t.attributeValue], color: 'var(--fg-warning)' },
{ tag: [t.string], color: 'var(--fg-notice)' },
{ tag: [t.atom, t.meta, t.operator, t.bool, t.null, t.keyword], color: 'var(--fg-danger)' },
]);
const myTheme = EditorView.theme({}, { dark: true });
// export const defaultHighlightStyle = HighlightStyle.define([
// { tag: t.meta, color: '#404740' },
// { tag: t.link, textDecoration: 'underline' },
// { tag: t.heading, textDecoration: 'underline', fontWeight: 'bold' },
// { tag: t.emphasis, fontStyle: 'italic' },
// { tag: t.strong, fontWeight: 'bold' },
// { tag: t.strikethrough, textDecoration: 'line-through' },
// { tag: t.keyword, color: '#708' },
// { tag: [t.atom, t.bool, t.url, t.contentSeparator, t.labelName], color: '#219' },
// { tag: [t.literal, t.inserted], color: '#164' },
// { tag: [t.string, t.deleted], color: '#a11' },
// { tag: [t.regexp, t.escape, t.special(t.string)], color: '#e40' },
// { tag: t.definition(t.variableName), color: '#00f' },
// { tag: t.local(t.variableName), color: '#30a' },
// { tag: [t.typeName, t.namespace], color: '#085' },
// { tag: t.className, color: '#167' },
// { tag: [t.special(t.variableName), t.macroName], color: '#256' },
// { tag: t.definition(t.propertyName), color: '#00c' },
// { tag: t.comment, color: '#940' },
// { tag: t.invalid, color: '#f00' },
// ]);
const syntaxTheme = EditorView.theme({}, { dark: true });
const syntaxExtensions: Record<string, LanguageSupport> = {
'application/graphql': graphqlLanguageSupport(),
@@ -123,14 +101,15 @@ export const baseExtensions = [
dropCursor(),
drawSelection(),
autocompletion({
tooltipClass: () => 'x-theme-menu',
closeOnBlur: true, // Set to `false` for debugging in devtools without closing it
compareCompletions: (a, b) => {
// Don't sort completions at all, only on boost
return (a.boost ?? 0) - (b.boost ?? 0);
},
}),
syntaxHighlighting(myHighlightStyle),
myTheme,
syntaxHighlighting(syntaxHighlightStyle),
syntaxTheme,
EditorState.allowMultipleSelections.of(true),
];

View File

@@ -20,7 +20,11 @@ export interface GenericCompletionConfig {
/**
* Complete options, always matching until the start of the line
*/
export function genericCompletion({ options, minMatch = 1 }: GenericCompletionConfig) {
export function genericCompletion(config?: GenericCompletionConfig) {
if (config == null) return [];
const { minMatch = 1, options } = config;
return function completions(context: CompletionContext) {
const toMatch = context.matchBefore(/.*/);

View File

@@ -1,13 +1,13 @@
import type { LanguageSupport } from '@codemirror/language';
import { LRLanguage } from '@codemirror/language';
import { parseMixed } from '@lezer/common';
import type { Environment, Workspace } from '../../../../lib/models';
import type { GenericCompletionConfig } from '../genericCompletion';
import { genericCompletion } from '../genericCompletion';
import { placeholders } from './placeholder';
import { textLanguageName } from '../text/extension';
import { twigCompletion } from './completion';
import { placeholders } from './placeholder';
import { parser as twigParser } from './twig';
import type { Environment, Workspace } from '../../../../lib/models';
export function twig(
base: LanguageSupport,
@@ -15,25 +15,19 @@ export function twig(
workspace: Workspace | null,
autocomplete?: GenericCompletionConfig,
) {
const variables =
[...(workspace?.variables ?? []), ...(environment?.variables ?? [])].filter((v) => v.enabled) ??
[];
const completions = twigCompletion({ options: variables });
const language = mixLanguage(base);
const completion = language.data.of({ autocomplete: completions });
const completionBase = base.language.data.of({ autocomplete: completions });
const additionalCompletion = autocomplete
? [base.language.data.of({ autocomplete: genericCompletion(autocomplete) })]
: [];
const allVariables = [...(workspace?.variables ?? []), ...(environment?.variables ?? [])];
const variables = allVariables.filter((v) => v.enabled) ?? [];
const completions = twigCompletion({ options: variables });
return [
language,
completion,
completionBase,
base.support,
placeholders(variables),
...additionalCompletion,
language.data.of({ autocomplete: completions }),
base.language.data.of({ autocomplete: completions }),
language.data.of({ autocomplete: genericCompletion(autocomplete) }),
base.language.data.of({ autocomplete: genericCompletion(autocomplete) }),
];
}

View File

@@ -11,8 +11,8 @@ class PlaceholderWidget extends WidgetType {
}
toDOM() {
const elt = document.createElement('span');
elt.className = `placeholder-widget ${
!this.isExistingVariable ? 'placeholder-widget-error' : ''
elt.className = `x-theme-placeholder placeholder ${
this.isExistingVariable ? 'x-theme-placeholder--primary' : 'x-theme-placeholder--danger'
}`;
elt.title = !this.isExistingVariable ? 'Variable not found in active environment' : '';
elt.textContent = this.name;

View File

@@ -9,8 +9,8 @@ export function FormattedError({ children }: Props) {
return (
<pre
className={classNames(
'w-full text-sm select-auto cursor-text bg-gray-100 p-3 rounded',
'whitespace-pre-wrap border border-red-500 border-dashed overflow-x-auto',
'w-full select-auto cursor-text bg-background-highlight-secondary p-3 rounded',
'whitespace-pre-wrap border border-fg-danger border-dashed overflow-x-auto',
)}
>
{children}

View File

@@ -11,7 +11,7 @@ export function Heading({ className, size = 1, ...props }: Props) {
<Component
className={classNames(
className,
'font-semibold text-gray-900',
'font-semibold text-fg',
size === 1 && 'text-2xl',
size === 2 && 'text-xl',
size === 3 && 'text-lg',

View File

@@ -22,7 +22,7 @@ export function HotKey({ action, className, variant }: Props) {
className={classNames(
className,
variant === 'with-bg' && 'rounded border',
'text-gray-1000 text-opacity-disabled',
'text-fg-subtler',
)}
>
{labelParts.map((char, index) => (

View File

@@ -7,5 +7,5 @@ interface Props {
export function HotKeyLabel({ action }: Props) {
const label = useHotKeyLabel(action);
return <span>{label}</span>;
return <span className="text-fg-subtle whitespace-nowrap">{label}</span>;
}

View File

@@ -11,7 +11,7 @@ interface Props {
export const HotKeyList = ({ hotkeys, bottomSlot }: Props) => {
return (
<div className="h-full flex items-center justify-center text-gray-700 text-sm">
<div className="h-full flex items-center justify-center">
<VStack space={2}>
{hotkeys.map((hotkey) => (
<HStack key={hotkey} className="grid grid-cols-2">

View File

@@ -27,7 +27,7 @@ export function HttpMethodTag({ request, className }: Props) {
const m = method.toLowerCase();
return (
<span className={classNames(className, 'text-2xs font-mono opacity-50')}>
<span className={classNames(className, 'text-xs font-mono text-fg-subtle')}>
{methodMap[m] ?? m.slice(0, 3).toUpperCase()}
</span>
);

View File

@@ -1,5 +1,5 @@
import * as lucide from 'lucide-react';
import classNames from 'classnames';
import * as lucide from 'lucide-react';
import type { HTMLAttributes } from 'react';
import { memo } from 'react';
@@ -42,8 +42,10 @@ const icons = {
magicWand: lucide.Wand2Icon,
minus: lucide.MinusIcon,
moreVertical: lucide.MoreVerticalIcon,
moon: lucide.MoonIcon,
paste: lucide.ClipboardPasteIcon,
pencil: lucide.PencilIcon,
pin: lucide.PinIcon,
plug: lucide.Plug,
plus: lucide.PlusIcon,
plusCircle: lucide.PlusCircleIcon,
@@ -54,6 +56,7 @@ const icons = {
settings2: lucide.Settings2Icon,
settings: lucide.SettingsIcon,
sparkles: lucide.SparklesIcon,
sun: lucide.SunIcon,
trash: lucide.TrashIcon,
update: lucide.RefreshCcwIcon,
upload: lucide.UploadIcon,
@@ -65,7 +68,7 @@ const icons = {
export interface IconProps {
icon: keyof typeof icons;
className?: string;
size?: 'xs' | 'sm' | 'md' | 'lg';
size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg';
spin?: boolean;
title?: string;
}
@@ -82,6 +85,7 @@ export const Icon = memo(function Icon({ icon, spin, size = 'md', className, tit
size === 'md' && 'h-4 w-4',
size === 'sm' && 'h-3.5 w-3.5',
size === 'xs' && 'h-3 w-3',
size === '2xs' && 'h-2.5 w-2.5',
spin && 'animate-spin',
)}
/>

View File

@@ -52,11 +52,12 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButt
size={size}
className={classNames(
className,
'relative flex-shrink-0 text-gray-700 hover:text-gray-1000',
'group/button relative flex-shrink-0 text-fg-subtle',
'!px-0',
size === 'md' && 'w-9',
size === 'sm' && 'w-8',
size === 'xs' && 'w-6',
size === '2xs' && 'w-5',
)}
{...props}
>
@@ -71,6 +72,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButt
spin={spin}
className={classNames(
iconClassName,
'group-hover/button:text-fg',
props.disabled && 'opacity-70',
confirmed && 'text-green-600',
)}

View File

@@ -6,8 +6,8 @@ export function InlineCode({ className, ...props }: HTMLAttributes<HTMLSpanEleme
<code
className={classNames(
className,
'font-mono text-xs bg-highlight border-0 border-gray-200/30',
'px-1.5 py-0.5 rounded text-gray-800 shadow-inner',
'font-mono text-shrink bg-background-highlight-secondary border border-background-highlight',
'px-1.5 py-0.5 rounded text-fg shadow-inner',
)}
{...props}
/>

View File

@@ -135,7 +135,7 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
htmlFor={id}
className={classNames(
labelClassName,
'text-sm text-gray-900 whitespace-nowrap',
'text-fg-subtle whitespace-nowrap',
hideLabel && 'sr-only',
)}
>
@@ -145,10 +145,11 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
alignItems="stretch"
className={classNames(
containerClassName,
'relative w-full rounded-md text-gray-900',
'x-theme-input',
'relative w-full rounded-md text-fg',
'border',
focused ? 'border-focus' : 'border-highlight',
!isValid && '!border-invalid',
focused ? 'border-border-focus' : 'border-background-highlight',
!isValid && '!border-fg-danger',
size === 'md' && 'min-h-md',
size === 'sm' && 'min-h-sm',
size === 'xs' && 'min-h-xs',
@@ -156,7 +157,6 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
>
{leftSlot}
<HStack
alignItems="center"
className={classNames(
'w-full min-w-0',
leftSlot && 'pl-0.5 -ml-2',
@@ -186,7 +186,7 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
title={obscured ? `Show ${label}` : `Obscure ${label}`}
size="xs"
className="mr-0.5 group/obscure !h-auto my-0.5"
iconClassName="text-gray-500 group-hover/obscure:text-gray-800"
iconClassName="text-fg-subtle group-hover/obscure:text-fg"
iconSize="sm"
icon={obscured ? 'eye' : 'eyeClosed'}
onClick={() => setObscured((o) => !o)}

View File

@@ -41,7 +41,7 @@ export const JsonAttributeTree = ({ depth = 0, attrKey, attrValue, attrKeyJsonPa
: null,
isExpandable: Object.keys(attrValue).length > 0,
label: isExpanded ? `{${Object.keys(attrValue).length || ' '}}` : `{⋯}`,
labelClassName: 'text-gray-600',
labelClassName: 'text-fg-subtler',
};
} else if (jsonType === '[object Array]') {
return {
@@ -59,7 +59,7 @@ export const JsonAttributeTree = ({ depth = 0, attrKey, attrValue, attrKeyJsonPa
: null,
isExpandable: attrValue.length > 0,
label: isExpanded ? `[${attrValue.length || ' '}]` : `[⋯]`,
labelClassName: 'text-gray-600',
labelClassName: 'text-subtler',
};
} else {
return {
@@ -67,22 +67,20 @@ export const JsonAttributeTree = ({ depth = 0, attrKey, attrValue, attrKeyJsonPa
isExpandable: false,
label: jsonType === '[object String]' ? `"${attrValue}"` : `${attrValue}`,
labelClassName: classNames(
jsonType === '[object Boolean]' && 'text-pink-600',
jsonType === '[object Number]' && 'text-blue-600',
jsonType === '[object String]' && 'text-yellow-600',
jsonType === '[object Null]' && 'text-red-600',
jsonType === '[object Boolean]' && 'text-fg-primary',
jsonType === '[object Number]' && 'text-fg-info',
jsonType === '[object String]' && 'text-fg-notice',
jsonType === '[object Null]' && 'text-fg-danger',
),
};
}
}, [attrValue, attrKeyJsonPath, isExpanded, depth]);
const labelEl = (
<span className={classNames(labelClassName, 'select-text group-hover:text-gray-800')}>
{label}
</span>
<span className={classNames(labelClassName, 'select-text group-hover:text-fg')}>{label}</span>
);
return (
<div className={classNames(/*depth === 0 && '-ml-4',*/ 'font-mono text-2xs')}>
<div className={classNames(/*depth === 0 && '-ml-4',*/ 'font-mono text-xs')}>
<div className="flex items-center">
{isExpandable ? (
<button className="group relative flex items-center pl-4 w-full" onClick={toggleExpanded}>
@@ -91,18 +89,18 @@ export const JsonAttributeTree = ({ depth = 0, attrKey, attrValue, attrKeyJsonPa
icon="chevronRight"
className={classNames(
'left-0 absolute transition-transform flex items-center',
'text-gray-600 group-hover:text-gray-900',
'text-fg-subtler group-hover:text-fg-subtle',
isExpanded ? 'rotate-90' : '',
)}
/>
<span className="text-violet-600 group-hover:text-violet-700 mr-1.5 whitespace-nowrap">
<span className="text-fg-primary group-hover:text-fg-primary mr-1.5 whitespace-nowrap">
{attrKey === undefined ? '$' : attrKey}:
</span>
{labelEl}
</button>
) : (
<>
<span className="text-violet-600 mr-1.5 pl-4 whitespace-nowrap select-text">
<span className="text-fg-primary mr-1.5 pl-4 whitespace-nowrap select-text">
{attrKey}:
</span>
{labelEl}

View File

@@ -30,7 +30,7 @@ export function KeyValueRow({ label, value, labelClassName }: Props) {
return (
<>
<td
className={classNames('py-0.5 pr-2 text-gray-700 select-text cursor-text', labelClassName)}
className={classNames('py-0.5 pr-2 text-fg-subtle select-text cursor-text', labelClassName)}
>
{label}
</td>

View File

@@ -388,8 +388,8 @@ function PairEditorRow({
{pairContainer.pair.isFile ? (
<Button
size="xs"
color="gray"
className="font-mono text-xs"
color="secondary"
className="font-mono text-2xs rtl"
onClick={async (e) => {
e.preventDefault();
const selected = await open({
@@ -403,7 +403,9 @@ function PairEditorRow({
handleChangeValueFile(selected.path);
}}
>
{getFileName(pairContainer.pair.value) || 'Select File'}
{/* Special character to insert ltr text in rtl element without making things wonky */}
&#x200E;
{pairContainer.pair.value || 'Select File'}
</Button>
) : (
<Input
@@ -494,9 +496,3 @@ const newPairContainer = (initialPair?: Pair): PairContainer => {
const pair = initialPair ?? { name: '', value: '', enabled: true, isFile: false };
return { id, pair };
};
const getFileName = (path: string | null | undefined): string => {
if (typeof path !== 'string') return '';
const parts = path.split(/[\\/]/);
return parts[parts.length - 1] ?? '';
};

View File

@@ -0,0 +1,152 @@
import classNames from 'classnames';
import { forwardRef, useCallback, useMemo, useRef, useState } from 'react';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
import { IconButton } from './IconButton';
import type { InputProps } from './Input';
import { HStack } from './Stacks';
export type PlainInputProps = Omit<InputProps, 'wrapLines' | 'onKeyDown' | 'type'> & {
type: 'text' | 'password' | 'number';
step?: number;
};
export const PlainInput = forwardRef<HTMLInputElement, PlainInputProps>(function Input(
{
className,
containerClassName,
defaultValue,
forceUpdateKey,
hideLabel,
label,
labelClassName,
labelPosition = 'top',
leftSlot,
name,
onBlur,
onChange,
onFocus,
onPaste,
placeholder,
require,
rightSlot,
size = 'md',
type = 'text',
validate,
...props
}: PlainInputProps,
ref,
) {
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
const [currentValue, setCurrentValue] = useState(defaultValue ?? '');
const [focused, setFocused] = useState(false);
const handleFocus = useCallback(() => {
setFocused(true);
onFocus?.();
}, [onFocus]);
const handleBlur = useCallback(() => {
setFocused(false);
onBlur?.();
}, [onBlur]);
const id = `input-${name}`;
const inputClassName = classNames(
className,
'!bg-transparent min-w-0 h-auto w-full focus:outline-none placeholder:text-placeholder',
'px-1.5 text-xs font-mono',
);
const isValid = useMemo(() => {
if (require && !validateRequire(currentValue)) return false;
if (validate && !validate(currentValue)) return false;
return true;
}, [currentValue, validate, require]);
const handleChange = useCallback(
(value: string) => {
setCurrentValue(value);
onChange?.(value);
},
[onChange],
);
const wrapperRef = useRef<HTMLDivElement>(null);
return (
<div
ref={wrapperRef}
className={classNames(
'w-full',
'pointer-events-auto', // Just in case we're placing in disabled parent
labelPosition === 'left' && 'flex items-center gap-2',
labelPosition === 'top' && 'flex-row gap-0.5',
)}
>
<label
htmlFor={id}
className={classNames(
labelClassName,
'text-fg-subtle whitespace-nowrap',
hideLabel && 'sr-only',
)}
>
{label}
</label>
<HStack
alignItems="stretch"
className={classNames(
containerClassName,
'x-theme-input',
'relative w-full rounded-md text-fg',
'border',
focused ? 'border-border-focus' : 'border-background-highlight',
!isValid && '!border-fg-danger',
size === 'md' && 'min-h-md',
size === 'sm' && 'min-h-sm',
size === 'xs' && 'min-h-xs',
)}
>
{leftSlot}
<HStack
className={classNames(
'w-full min-w-0',
leftSlot && 'pl-0.5 -ml-2',
rightSlot && 'pr-0.5 -mr-2',
)}
>
<input
ref={ref}
key={forceUpdateKey}
id={id}
type={type === 'password' && !obscured ? 'text' : type}
defaultValue={defaultValue}
placeholder={placeholder}
onChange={(e) => handleChange(e.target.value)}
onPaste={(e) => onPaste?.(e.clipboardData.getData('Text'))}
className={inputClassName}
onFocus={handleFocus}
onBlur={handleBlur}
{...props}
/>
</HStack>
{type === 'password' && (
<IconButton
title={obscured ? `Show ${label}` : `Obscure ${label}`}
size="xs"
className="mr-0.5 group/obscure !h-auto my-0.5"
iconClassName="text-fg-subtle group-hover/obscure:text-fg"
iconSize="sm"
icon={obscured ? 'eye' : 'eyeClosed'}
onClick={() => setObscured((o) => !o)}
/>
)}
{rightSlot}
</HStack>
</div>
);
});
function validateRequire(v: string) {
return v.length > 0;
}

View File

@@ -1,3 +1,4 @@
import type { ReactNode } from 'react';
import { useMemo } from 'react';
import type { DropdownItem, DropdownItemSeparator, DropdownProps } from './Dropdown';
import { Dropdown } from './Dropdown';
@@ -9,6 +10,7 @@ export type RadioDropdownItem<T = string | null> =
label: string;
shortLabel?: string;
value: T;
rightSlot?: ReactNode;
}
| DropdownItemSeparator;
@@ -37,9 +39,10 @@ export function RadioDropdown<T = string | null>({
key: item.label,
label: item.label,
shortLabel: item.shortLabel,
rightSlot: item.rightSlot,
onSelect: () => onChange(item.value),
leftSlot: <Icon icon={value === item.value ? 'check' : 'empty'} />,
};
} as DropdownProps['items'][0];
}
}),
...((extraItems ? [{ type: 'separator' }, ...extraItems] : []) as DropdownItem[]),
@@ -47,5 +50,9 @@ export function RadioDropdown<T = string | null>({
[items, extraItems, value, onChange],
);
return <Dropdown items={dropdownItems}>{children}</Dropdown>;
return (
<Dropdown fullWidth items={dropdownItems}>
{children}
</Dropdown>
);
}

View File

@@ -1,15 +1,24 @@
import classNames from 'classnames';
import type { CSSProperties, ReactNode } from 'react';
import { useState } from 'react';
import { useOsInfo } from '../../hooks/useOsInfo';
import type { ButtonProps } from './Button';
import { Button } from './Button';
import type { RadioDropdownItem } from './RadioDropdown';
import { RadioDropdown } from './RadioDropdown';
import { HStack } from './Stacks';
interface Props<T extends string> {
export interface SelectProps<T extends string> {
name: string;
label: string;
labelPosition?: 'top' | 'left';
labelClassName?: string;
hideLabel?: boolean;
value: T;
options: { label: string; value: T }[];
leftSlot?: ReactNode;
options: RadioDropdownItem<T>[];
onChange: (value: T) => void;
size?: 'xs' | 'sm' | 'md' | 'lg';
size?: ButtonProps['size'];
className?: string;
}
@@ -21,18 +30,22 @@ export function Select<T extends string>({
label,
value,
options,
leftSlot,
onChange,
className,
size = 'md',
}: Props<T>) {
}: SelectProps<T>) {
const osInfo = useOsInfo();
const [focused, setFocused] = useState<boolean>(false);
const id = `input-${name}`;
return (
<div
className={classNames(
className,
'x-theme-input',
'w-full',
'pointer-events-auto', // Just in case we're placing in disabled parent
labelPosition === 'left' && 'flex items-center gap-2',
labelPosition === 'left' && 'grid grid-cols-[auto_1fr] items-center gap-2',
labelPosition === 'top' && 'flex-row gap-0.5',
)}
>
@@ -40,38 +53,69 @@ export function Select<T extends string>({
htmlFor={id}
className={classNames(
labelClassName,
'text-sm text-gray-900 whitespace-nowrap',
'text-fg-subtle whitespace-nowrap',
hideLabel && 'sr-only',
)}
>
{label}
</label>
<select
value={value}
style={selectBackgroundStyles}
onChange={(e) => onChange(e.target.value as T)}
className={classNames(
'font-mono text-xs border w-full outline-none bg-transparent pl-2 pr-7',
'border-highlight focus:border-focus',
size === 'xs' && 'h-xs',
size === 'sm' && 'h-sm',
size === 'md' && 'h-md',
size === 'lg' && 'h-lg',
)}
>
{options.map(({ label, value }) => (
<option key={label} value={value}>
{label}
</option>
))}
</select>
{osInfo?.osType === 'macos' ? (
<HStack
space={2}
className={classNames(
'w-full rounded-md text-fg text-sm font-mono',
'pl-2',
'border',
focused ? 'border-border-focus' : 'border-background-highlight',
size === 'xs' && 'h-xs',
size === 'sm' && 'h-sm',
size === 'md' && 'h-md',
)}
>
{leftSlot && <div>{leftSlot}</div>}
<select
value={value}
style={selectBackgroundStyles}
onChange={(e) => onChange(e.target.value as T)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
className={classNames('pr-7 w-full outline-none bg-transparent')}
>
{options.map((o) => {
if (o.type === 'separator') return null;
return (
<option key={o.label} value={o.value}>
{o.label}
</option>
);
})}
</select>
</HStack>
) : (
// Use custom "select" component until Tauri can be configured to have select menus not always appear in
// light mode
<RadioDropdown value={value} onChange={onChange} items={options}>
<Button
className="w-full text-sm font-mono"
justify="start"
variant="border"
size={size}
leftSlot={leftSlot}
forDropdown
>
{options.find((o) => o.type !== 'separator' && o.value === value)?.label ?? '--'}
</Button>
</RadioDropdown>
)}
</div>
);
}
const selectBackgroundStyles = {
const selectBackgroundStyles: CSSProperties = {
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`,
backgroundPosition: 'right 0.3rem center',
backgroundRepeat: 'no-repeat',
backgroundSize: '1.5em 1.5em',
appearance: 'none',
printColorAdjust: 'exact',
};

View File

@@ -8,19 +8,13 @@ interface Props {
children?: ReactNode;
}
export function Separator({
className,
variant = 'primary',
orientation = 'horizontal',
children,
}: Props) {
export function Separator({ className, orientation = 'horizontal', children }: Props) {
return (
<div role="separator" className={classNames(className, 'flex items-center')}>
{children && <div className="text-xs text-gray-500 mr-2 whitespace-nowrap">{children}</div>}
{children && <div className="text-sm text-fg-subtler mr-2 whitespace-nowrap">{children}</div>}
<div
className={classNames(
variant === 'primary' && 'bg-highlight',
variant === 'secondary' && 'bg-highlightSecondary',
'bg-background-highlight',
orientation === 'horizontal' && 'w-full h-[1px]',
orientation === 'vertical' && 'h-full w-[1px]',
)}

View File

@@ -21,7 +21,7 @@ export function SizeTag({ contentLength }: Props) {
}
return (
<span title={`${contentLength} bytes`}>
<span className="font-mono" title={`${contentLength} bytes`}>
{Math.round(num * 10) / 10} {unit}
</span>
);

View File

@@ -19,7 +19,7 @@ interface HStackProps extends BaseStackProps {
}
export const HStack = forwardRef(function HStack(
{ className, space, children, ...props }: HStackProps,
{ className, space, children, alignItems = 'center', ...props }: HStackProps,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ref: ForwardedRef<any>,
) {
@@ -27,6 +27,7 @@ export const HStack = forwardRef(function HStack(
<BaseStack
ref={ref}
className={classNames(className, 'flex-row', space != null && gapClasses[space])}
alignItems={alignItems}
{...props}
>
{children}

View File

@@ -10,17 +10,18 @@ interface Props {
export function StatusTag({ response, className, showReason }: Props) {
const { status } = response;
const label = status < 100 ? 'ERR' : status;
const category = `${status}`[0];
return (
<span
className={classNames(
className,
'font-mono',
status >= 0 && status < 100 && 'text-red-600',
status >= 100 && status < 200 && 'text-green-600',
status >= 200 && status < 300 && 'text-green-600',
status >= 300 && status < 400 && 'text-pink-600',
status >= 400 && status < 500 && 'text-orange-600',
status >= 500 && 'text-red-600',
category === '0' && 'text-fg-danger',
category === '1' && 'text-fg-info',
category === '2' && 'text-fg-success',
category === '3' && 'text-fg-primary',
category === '4' && 'text-fg-warning',
category === '5' && 'text-fg-danger',
)}
>
{label} {showReason && response.statusReason && response.statusReason}

View File

@@ -1,7 +1,6 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { memo, useCallback, useEffect, useRef } from 'react';
import { Button } from '../Button';
import { Icon } from '../Icon';
import type { RadioDropdownProps } from '../RadioDropdown';
import { RadioDropdown } from '../RadioDropdown';
@@ -25,6 +24,7 @@ interface Props {
tabListClassName?: string;
className?: string;
children: ReactNode;
addBorders?: boolean;
}
export function Tabs({
@@ -35,6 +35,7 @@ export function Tabs({
tabs,
className,
tabListClassName,
addBorders,
}: Props) {
const ref = useRef<HTMLDivElement | null>(null);
@@ -73,17 +74,21 @@ export function Tabs({
aria-label={label}
className={classNames(
tabListClassName,
addBorders && '!-ml-1 h-md mt-2',
'flex items-center overflow-x-auto overflow-y-visible hide-scrollbars mt-1 mb-2',
// Give space for button focus states within overflow boundary.
'-ml-5 pl-3 pr-1 py-1',
)}
>
<HStack space={2} className="flex-shrink-0">
<HStack space={2} className="h-full flex-shrink-0">
{tabs.map((t) => {
const isActive = t.value === value;
const btnClassName = classNames(
isActive ? 'text-gray-800' : 'text-gray-600 hover:text-gray-700',
'h-full flex items-center rounded',
'!px-2 ml-[1px]',
addBorders && 'border',
isActive ? 'text-fg' : 'text-fg-subtle hover:text-fg',
isActive && addBorders ? 'border-background-highlight' : 'border-transparent',
);
if ('options' in t) {
@@ -97,39 +102,32 @@ export function Tabs({
value={t.options.value}
onChange={t.options.onChange}
>
<Button
<button
color="custom"
size="sm"
onClick={isActive ? undefined : () => handleTabChange(t.value)}
className={btnClassName}
rightSlot={
<Icon
size="sm"
icon="chevronDown"
className={classNames(
'-mr-1.5 mt-0.5',
isActive ? 'opacity-100' : 'opacity-20',
)}
/>
}
>
{option && 'shortLabel' in option
? option.shortLabel
: option?.label ?? 'Unknown'}
</Button>
<Icon
size="sm"
icon="chevronDown"
className={classNames('ml-1', isActive ? 'text-fg-subtle' : 'opacity-50')}
/>
</button>
</RadioDropdown>
);
} else {
return (
<Button
<button
key={t.value}
color="custom"
size="sm"
onClick={() => handleTabChange(t.value)}
className={btnClassName}
>
{t.label}
</Button>
</button>
);
}
})}

View File

@@ -3,9 +3,9 @@ import { motion } from 'framer-motion';
import type { ReactNode } from 'react';
import React from 'react';
import { useKey } from 'react-use';
import { IconButton } from './IconButton';
import type { IconProps } from './Icon';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
export interface ToastProps {
children: ReactNode;
@@ -52,14 +52,15 @@ export function Toast({
transition={{ duration: 0.2 }}
className={classNames(
className,
'x-theme-toast',
'pointer-events-auto',
'relative bg-gray-50 dark:bg-gray-100 pointer-events-auto',
'relative bg-background pointer-events-auto',
'rounded-lg',
'border border-highlightSecondary dark:border-highlight shadow-xl',
'border border-background-highlight shadow-lg',
'max-w-[calc(100vw-5rem)] max-h-[calc(100vh-6rem)]',
'w-[22rem] max-h-[80vh]',
'm-2 grid grid-cols-[1fr_auto]',
'text-gray-700',
'text-fg',
)}
>
<div className="px-3 py-2 flex items-center gap-2">
@@ -67,10 +68,10 @@ export function Toast({
<Icon
icon={ICONS[variant]}
className={classNames(
variant === 'success' && 'text-green-500',
variant === 'warning' && 'text-orange-500',
variant === 'error' && 'text-red-500',
variant === 'copied' && 'text-violet-500',
variant === 'success' && 'text-fg-success',
variant === 'warning' && 'text-fg-warning',
variant === 'error' && 'text-fg-danger',
variant === 'copied' && 'text-fg-primary',
)}
/>
)}
@@ -82,7 +83,7 @@ export function Toast({
<IconButton
color="custom"
className="opacity-50"
className="opacity-60"
title="Dismiss"
icon="x"
onClick={onClose}
@@ -91,7 +92,7 @@ export function Toast({
{timeout != null && (
<div className="w-full absolute bottom-0 left-0 right-0">
<motion.div
className="bg-highlight h-0.5"
className="bg-background-highlight h-0.5"
initial={{ width: '100%' }}
animate={{ width: '0%', opacity: 0.2 }}
transition={{ duration: timeout / 1000, ease: 'linear' }}

View File

@@ -1,17 +0,0 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
interface Props {
className?: string;
children?: ReactNode;
}
export function WindowDragRegion({ className, ...props }: Props) {
return (
<div
data-tauri-drag-region
className={classNames(className, 'w-full flex-shrink-0')}
{...props}
/>
);
}

View File

@@ -0,0 +1,18 @@
import { convertFileSrc } from '@tauri-apps/api/core';
import React from 'react';
import type { HttpResponse } from '../../lib/models';
interface Props {
response: HttpResponse;
}
export function AudioViewer({ response }: Props) {
if (response.bodyPath === null) {
return <div>Empty response body</div>;
}
const src = convertFileSrc(response.bodyPath);
// eslint-disable-next-line jsx-a11y/media-has-caption
return <audio className="w-full" controls src={src}></audio>;
}

View File

@@ -20,13 +20,9 @@ export function ImageViewer({ response, className }: Props) {
if (!show) {
return (
<>
<div className="text-sm italic text-gray-500">
<div className="italic text-fg-subtler">
Response body is too large to preview.{' '}
<button
className="cursor-pointer underline hover:text-gray-800"
color="gray"
onClick={() => setShow(true)}
>
<button className="cursor-pointer underline hover:text-fg" onClick={() => setShow(true)}>
Show anyway
</button>
</div>

View File

@@ -1,11 +1,11 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { useCallback, useMemo } from 'react';
import { createGlobalState } from 'react-use';
import { useContentTypeFromHeaders } from '../../hooks/useContentTypeFromHeaders';
import { useDebouncedState } from '../../hooks/useDebouncedState';
import { useDebouncedValue } from '../../hooks/useDebouncedValue';
import { useFilterResponse } from '../../hooks/useFilterResponse';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import { useToggle } from '../../hooks/useToggle';
import { tryFormatJson, tryFormatXml } from '../../lib/formatters';
import type { HttpResponse } from '../../lib/models';
import { Editor } from '../core/Editor';
@@ -18,27 +18,45 @@ const extraExtensions = [hyperlink];
interface Props {
response: HttpResponse;
pretty: boolean;
className?: string;
}
export function TextViewer({ response, pretty }: Props) {
const [isSearching, toggleIsSearching] = useToggle();
const [filterText, setDebouncedFilterText, setFilterText] = useDebouncedState<string>('', 400);
const useFilterText = createGlobalState<Record<string, string | null>>({});
export function TextViewer({ response, pretty, className }: Props) {
const [filterTextMap, setFilterTextMap] = useFilterText();
const filterText = filterTextMap[response.id] ?? null;
const debouncedFilterText = useDebouncedValue(filterText, 300);
const setFilterText = useCallback(
(v: string | null) => {
setFilterTextMap((m) => ({ ...m, [response.id]: v }));
},
[setFilterTextMap, response],
);
const contentType = useContentTypeFromHeaders(response.headers);
const rawBody = useResponseBodyText(response) ?? '';
const isSearching = filterText != null;
const formattedBody =
pretty && contentType?.includes('json')
? tryFormatJson(rawBody)
: pretty && contentType?.includes('xml')
? tryFormatXml(rawBody)
: rawBody;
const filteredResponse = useFilterResponse({ filter: filterText, responseId: response.id });
const body = filteredResponse ?? formattedBody;
const clearSearch = useCallback(() => {
toggleIsSearching();
setFilterText('');
}, [setFilterText, toggleIsSearching]);
const filteredResponse = useFilterResponse({
filter: debouncedFilterText ?? '',
responseId: response.id,
});
const body = isSearching && filterText?.length > 0 ? filteredResponse : formattedBody;
const toggleSearch = useCallback(() => {
if (isSearching) {
setFilterText(null);
} else {
setFilterText('');
}
}, [isSearching, setFilterText]);
const isJson = contentType?.includes('json');
const isXml = contentType?.includes('xml') || contentType?.includes('html');
@@ -53,16 +71,17 @@ export function TextViewer({ response, pretty }: Props) {
result.push(
<div key="input" className="w-full !opacity-100">
<Input
key={response.id}
hideLabel
autoFocus
containerClassName="bg-gray-100 dark:bg-gray-50"
containerClassName="bg-background"
size="sm"
placeholder={isJson ? 'JSONPath expression' : 'XPath expression'}
label="Filter expression"
name="filter"
defaultValue={filterText}
onKeyDown={(e) => e.key === 'Escape' && clearSearch()}
onChange={setDebouncedFilterText}
onKeyDown={(e) => e.key === 'Escape' && toggleSearch()}
onChange={setFilterText}
/>
</div>,
);
@@ -74,18 +93,21 @@ export function TextViewer({ response, pretty }: Props) {
size="sm"
icon={isSearching ? 'x' : 'filter'}
title={isSearching ? 'Close filter' : 'Filter response'}
onClick={clearSearch}
className={classNames(isSearching && '!opacity-100')}
onClick={toggleSearch}
className={classNames(
'bg-background border !border-background-highlight',
isSearching && '!opacity-100',
)}
/>,
);
return result;
}, [canFilter, clearSearch, filterText, isJson, isSearching, setDebouncedFilterText]);
}, [canFilter, filterText, isJson, isSearching, setFilterText, toggleSearch]);
return (
<Editor
readOnly
className="bg-gray-50 dark:!bg-gray-100"
className={className}
forceUpdateKey={body}
defaultValue={body}
contentType={contentType}

View File

@@ -0,0 +1,18 @@
import { convertFileSrc } from '@tauri-apps/api/core';
import React from 'react';
import type { HttpResponse } from '../../lib/models';
interface Props {
response: HttpResponse;
}
export function VideoViewer({ response }: Props) {
if (response.bodyPath === null) {
return <div>Empty response body</div>;
}
const src = convertFileSrc(response.bodyPath);
// eslint-disable-next-line jsx-a11y/media-has-caption
return <video className="w-full" controls src={src}></video>;
}

View File

@@ -24,7 +24,7 @@ export function WebPageViewer({ response }: Props) {
title="Response preview"
srcDoc={contentForIframe}
sandbox="allow-scripts allow-same-origin"
className="h-full w-full rounded border border-highlightSecondary"
className="h-full w-full rounded border border-background-highlight-secondary"
/>
</div>
);

View File

@@ -32,10 +32,10 @@ export function Confirm({ onHide, onResult, confirmText, variant = 'confirm' }:
return (
<HStack space={2} justifyContent="start" className="mt-2 mb-4 flex-row-reverse">
<Button className="focus" color={colors[variant]} onClick={handleSuccess}>
<Button color={colors[variant]} onClick={handleSuccess}>
{confirmText ?? confirmButtonTexts[variant]}
</Button>
<Button className="focus" color="gray" onClick={handleHide}>
<Button onClick={handleHide} variant="border">
Cancel
</Button>
</HStack>

View File

@@ -52,10 +52,10 @@ export function Prompt({
onChange={setValue}
/>
<HStack space={2} justifyContent="end">
<Button className="focus" color="gray" onClick={onHide}>
<Button onClick={onHide} variant="border">
Cancel
</Button>
<Button type="submit" className="focus" color="primary">
<Button type="submit" color="primary">
{confirmLabel}
</Button>
</HStack>

View File

@@ -16,5 +16,5 @@ export function useAppInfo() {
const metadata = await invoke('cmd_metadata');
return metadata as AppInfo;
},
});
}).data;
}

View File

@@ -1,9 +1,9 @@
import { useNavigate } from 'react-router-dom';
import { QUERY_ENVIRONMENT_ID } from './useActiveEnvironmentId';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useActiveRequestId } from './useActiveRequestId';
import type { Environment } from '../lib/models';
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import type { Environment } from '../lib/models';
import { QUERY_ENVIRONMENT_ID } from './useActiveEnvironmentId';
import { useActiveRequestId } from './useActiveRequestId';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
export type RouteParamsWorkspace = {
workspaceId: string;
@@ -18,6 +18,9 @@ export const routePaths = {
workspaces() {
return '/workspaces';
},
workspaceSettings({ workspaceId } = { workspaceId: ':workspaceId' } as RouteParamsWorkspace) {
return `/workspaces/${workspaceId}/settings`;
},
workspace(
{ workspaceId, environmentId } = {
workspaceId: ':workspaceId',

View File

@@ -1,5 +1,6 @@
import { readText, writeText } from '@tauri-apps/plugin-clipboard-manager';
import { useCallback, useEffect } from 'react';
import { useToast } from '../components/ToastContext';
import { useWindowFocus } from './useWindowFocus';
import { createGlobalState } from 'react-use';
@@ -8,6 +9,7 @@ const useClipboardTextState = createGlobalState<string>('');
export function useClipboardText() {
const [value, setValue] = useClipboardTextState();
const focused = useWindowFocus();
const toast = useToast();
useEffect(() => {
readText().then(setValue);
@@ -16,9 +18,14 @@ export function useClipboardText() {
const setText = useCallback(
(text: string) => {
writeText(text).catch(console.error);
toast.show({
id: 'copied',
variant: 'copied',
message: 'Copied to clipboard',
});
setValue(text);
},
[setValue],
[setValue, toast],
);
return [value, setText] as const;

View File

@@ -8,7 +8,7 @@ export function useCommandPalette() {
const appInfo = useAppInfo();
useHotKey('command_palette.toggle', () => {
// Disabled in production for now
if (!appInfo.data?.isDev) {
if (!appInfo?.isDev) {
return;
}

View File

@@ -1,23 +1,11 @@
import { invoke } from '@tauri-apps/api/core';
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { useState } from 'react';
import { useToast } from '../components/ToastContext';
import { useClipboardText } from './useClipboardText';
export function useCopyAsCurl(requestId: string) {
const [checked, setChecked] = useState<boolean>(false);
const toast = useToast();
return [
checked,
async () => {
const cmd: string = await invoke('cmd_request_to_curl', { requestId });
await writeText(cmd);
setChecked(true);
setTimeout(() => setChecked(false), 800);
toast.show({
variant: 'copied',
message: 'Curl copied to clipboard',
});
return cmd;
},
] as const;
const [, copy] = useClipboardText();
return async () => {
const cmd: string = await invoke('cmd_request_to_curl', { requestId });
copy(cmd);
return cmd;
};
}

View File

@@ -18,6 +18,6 @@ export function useFilterResponse({
return (await invoke('cmd_filter_response', { responseId, filter })) as string | null;
},
}).data ?? null
}).data ?? ''
);
}

View File

@@ -20,7 +20,10 @@ export type HotkeyAction =
| 'sidebar.focus'
| 'sidebar.toggle'
| 'urlBar.focus'
| 'command_palette.toggle';
| 'command_palette.toggle'
| 'app.zoom_in'
| 'app.zoom_out'
| 'app.zoom_reset';
const hotkeys: Record<HotkeyAction, string[]> = {
'environmentEditor.toggle': ['CmdCtrl+Shift+e'],
@@ -37,6 +40,9 @@ const hotkeys: Record<HotkeyAction, string[]> = {
'sidebar.toggle': ['CmdCtrl+b'],
'urlBar.focus': ['CmdCtrl+l'],
'command_palette.toggle': ['CmdCtrl+k'],
'app.zoom_in': ['CmdCtrl+='],
'app.zoom_out': ['CmdCtrl+-'],
'app.zoom_reset': ['CmdCtrl+0'],
};
const hotkeyLabels: Record<HotkeyAction, string> = {
@@ -54,6 +60,9 @@ const hotkeyLabels: Record<HotkeyAction, string> = {
'sidebar.toggle': 'Toggle Sidebar',
'urlBar.focus': 'Focus URL',
'command_palette.toggle': 'Toggle Command Palette',
'app.zoom_in': 'Zoom In',
'app.zoom_out': 'Zoom Out',
'app.zoom_reset': 'Zoom to Actual Size',
};
export const hotkeyActions: HotkeyAction[] = Object.keys(hotkeys) as (keyof typeof hotkeys)[];
@@ -114,6 +123,7 @@ export function useHotKey(
e.preventDefault();
e.stopPropagation();
callbackRef.current(e);
currentKeys.current.clear();
}
}
}

View File

@@ -1,26 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { getCurrent } from '@tauri-apps/api/webviewWindow';
import { useEffect, useState } from 'react';
import { useWindowSize } from 'react-use';
import { useDebouncedValue } from './useDebouncedValue';
export function useIsFullscreen() {
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
const windowSize = useWindowSize();
const debouncedWindowWidth = useDebouncedValue(windowSize.width);
useEffect(() => {
(async function () {
// Fullscreen state isn't updated right after resize event on Mac (needs to wait for animation) so
// we'll poll for 10 seconds to see if it changes. Hopefully Tauri eventually adds a way to listen
// for this.
for (let i = 0; i < 100; i++) {
await new Promise((resolve) => setTimeout(resolve, 100));
const newIsFullscreen = await getCurrent().isFullscreen();
if (newIsFullscreen !== isFullscreen) {
setIsFullscreen(newIsFullscreen);
break;
}
}
})();
}, [windowSize, isFullscreen]);
// NOTE: Fullscreen state isn't updated right after resize event on Mac (needs to wait for animation) so
// we'll wait for a bit using the debounced window size. Hopefully Tauri eventually adds a way to listen
// for fullscreen change events.
return isFullscreen;
return (
useQuery({
queryKey: ['is_fullscreen', debouncedWindowWidth],
queryFn: async () => {
return getCurrent().isFullscreen();
},
}).data ?? false
);
}

View File

@@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { buildKeyValueKey, getKeyValue, setKeyValue } from '../lib/keyValueStore';
const DEFAULT_NAMESPACE = 'app';
const DEFAULT_NAMESPACE = 'global';
export function keyValueQueryKey({
namespace = DEFAULT_NAMESPACE,
@@ -20,7 +20,7 @@ export function useKeyValue<T extends Object | null>({
key,
fallback,
}: {
namespace?: 'app' | 'no_sync' | 'global';
namespace?: 'global' | 'no_sync';
key: string | string[];
fallback: T;
}) {
@@ -51,7 +51,8 @@ export function useKeyValue<T extends Object | null>({
await mutate.mutateAsync(value);
}
},
[fallback, key, mutate, namespace],
// eslint-disable-next-line react-hooks/exhaustive-deps
[fallback, key, namespace],
);
const reset = useCallback(async () => mutate.mutateAsync(fallback), [mutate, fallback]);

View File

@@ -1,8 +1,8 @@
import { invoke } from '@tauri-apps/api/core';
import { open } from '@tauri-apps/plugin-shell';
import { Button } from '../components/core/Button';
import { useToast } from '../components/ToastContext';
import { useListenToTauriEvent } from './useListenToTauriEvent';
import { Button } from '../components/core/Button';
import { open } from '@tauri-apps/plugin-shell';
import { invoke } from '@tauri-apps/api/core';
export function useNotificationToast() {
const toast = useToast();
@@ -31,7 +31,7 @@ export function useNotificationToast() {
actionLabel && actionUrl ? (
<Button
size="xs"
color="gray"
color="secondary"
className="mr-auto min-w-[5rem]"
onClick={() => {
toast.hide(payload.id);

View File

@@ -0,0 +1,19 @@
import type { GrpcConnection, GrpcRequest } from '../lib/models';
import { useGrpcConnections } from './useGrpcConnections';
import { useKeyValue } from './useKeyValue';
import { useLatestGrpcConnection } from './useLatestGrpcConnection';
export function usePinnedGrpcConnection(activeRequest: GrpcRequest) {
const latestConnection = useLatestGrpcConnection(activeRequest.id);
const { set: setPinnedConnectionId, value: pinnedConnectionId } = useKeyValue<string | null>({
// Key on latest connection instead of activeRequest because connections change out of band of active request
key: ['pinned_grpc_connection_id', latestConnection?.id ?? 'n/a'],
fallback: null,
namespace: 'global',
});
const connections = useGrpcConnections(activeRequest.id);
const activeConnection: GrpcConnection | null =
connections.find((r) => r.id === pinnedConnectionId) ?? latestConnection;
return { activeConnection, setPinnedConnectionId, pinnedConnectionId, connections } as const;
}

View File

@@ -1,28 +1,19 @@
import { useCallback, useEffect } from 'react';
import { createGlobalState } from 'react-use';
import type { HttpRequest, HttpResponse } from '../lib/models';
import { useHttpResponses } from './useHttpResponses';
import { useKeyValue } from './useKeyValue';
import { useLatestHttpResponse } from './useLatestHttpResponse';
const usePinnedResponseIdState = createGlobalState<string | null>(null);
export function usePinnedHttpResponse(activeRequest: HttpRequest) {
const [pinnedResponseId, setPinnedResponseId] = usePinnedResponseIdState();
const latestResponse = useLatestHttpResponse(activeRequest.id);
const { set: setPinnedResponseId, value: pinnedResponseId } = useKeyValue<string | null>({
// Key on latest response instead of activeRequest because responses change out of band of active request
key: ['pinned_http_response_id', latestResponse?.id ?? 'n/a'],
fallback: null,
namespace: 'global',
});
const responses = useHttpResponses(activeRequest.id);
const activeResponse: HttpResponse | null = pinnedResponseId
? responses.find((r) => r.id === pinnedResponseId) ?? null
: latestResponse ?? null;
const activeResponse: HttpResponse | null =
responses.find((r) => r.id === pinnedResponseId) ?? latestResponse;
// Unset pinned response when a new one comes in
useEffect(() => setPinnedResponseId(null), [responses.length, setPinnedResponseId]);
const setPinnedResponse = useCallback(
(r: HttpResponse) => {
setPinnedResponseId(r.id);
},
[setPinnedResponseId],
);
return { activeResponse, setPinnedResponse, pinnedResponseId, responses } as const;
return { activeResponse, setPinnedResponseId, pinnedResponseId, responses } as const;
}

Some files were not shown because too many files have changed in this diff Show More