mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-21 08:21:19 +02:00
Styled it up a bit
This commit is contained in:
@@ -1,19 +1,24 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { m } from 'framer-motion';
|
||||||
import type { CSSProperties, FormEvent } from 'react';
|
import type { CSSProperties, FormEvent } from 'react';
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useAlert } from '../hooks/useAlert';
|
import { useAlert } from '../hooks/useAlert';
|
||||||
|
import type { GrpcMessage } from '../hooks/useGrpc';
|
||||||
import { useGrpc } from '../hooks/useGrpc';
|
import { useGrpc } from '../hooks/useGrpc';
|
||||||
import { useKeyValue } from '../hooks/useKeyValue';
|
import { useKeyValue } from '../hooks/useKeyValue';
|
||||||
|
import { tryFormatJson } from '../lib/formatters';
|
||||||
import { Banner } from './core/Banner';
|
import { Banner } from './core/Banner';
|
||||||
import { Editor } from './core/Editor';
|
import { Editor } from './core/Editor';
|
||||||
import { HotKeyList } from './core/HotKeyList';
|
import { HotKeyList } from './core/HotKeyList';
|
||||||
import { Icon } from './core/Icon';
|
import { Icon } from './core/Icon';
|
||||||
|
import { JsonAttributeTree } from './core/JsonAttributeTree';
|
||||||
import { Select } from './core/Select';
|
import { Select } from './core/Select';
|
||||||
|
import { Separator } from './core/Separator';
|
||||||
import { SplitLayout } from './core/SplitLayout';
|
import { SplitLayout } from './core/SplitLayout';
|
||||||
import { HStack, VStack } from './core/Stacks';
|
import { HStack, VStack } from './core/Stacks';
|
||||||
import { GrpcEditor } from './GrpcEditor';
|
import { GrpcEditor } from './GrpcEditor';
|
||||||
import { UrlBar } from './UrlBar';
|
import { UrlBar } from './UrlBar';
|
||||||
import { format } from 'date-fns';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
style: CSSProperties;
|
style: CSSProperties;
|
||||||
@@ -37,6 +42,7 @@ export function GrpcConnectionLayout({ style }: Props) {
|
|||||||
key: 'grpc_message',
|
key: 'grpc_message',
|
||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
});
|
});
|
||||||
|
const [activeMessage, setActiveMessage] = useState<GrpcMessage | null>(null);
|
||||||
const [resp, setResp] = useState<string>('');
|
const [resp, setResp] = useState<string>('');
|
||||||
const grpc = useGrpc(url.value ?? null);
|
const grpc = useGrpc(url.value ?? null);
|
||||||
|
|
||||||
@@ -180,7 +186,7 @@ export function GrpcConnectionLayout({ style }: Props) {
|
|||||||
className={classNames(
|
className={classNames(
|
||||||
'max-h-full h-full grid grid-rows-[minmax(0,1fr)] grid-cols-1',
|
'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',
|
'bg-gray-50 dark:bg-gray-100 rounded-md border border-highlight',
|
||||||
'shadow shadow-gray-100 dark:shadow-gray-0 relative py-1',
|
'shadow shadow-gray-100 dark:shadow-gray-0 relative pt-1',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{grpc.unary.error ? (
|
{grpc.unary.error ? (
|
||||||
@@ -188,15 +194,57 @@ export function GrpcConnectionLayout({ style }: Props) {
|
|||||||
{grpc.unary.error}
|
{grpc.unary.error}
|
||||||
</Banner>
|
</Banner>
|
||||||
) : grpc.messages.length > 0 ? (
|
) : grpc.messages.length > 0 ? (
|
||||||
<VStack className="h-full overflow-y-auto">
|
<div className="grid grid-rows-[minmax(0,1fr)_auto] overflow-hidden">
|
||||||
{[...grpc.messages].reverse().map((m, i) => (
|
<div className="overflow-y-auto">
|
||||||
<HStack key={m.time.getTime()} space={3} className="px-2 py-1 font-mono text-xs">
|
{...grpc.messages.map((m) => (
|
||||||
<Icon icon="arrowDownToDot" />
|
<HStack
|
||||||
<div>{format(m.time, 'HH:mm:ss')}</div>
|
key={m.time.getTime()}
|
||||||
<div>{m.message}</div>
|
space={2}
|
||||||
</HStack>
|
onClick={() => {
|
||||||
))}
|
if (m === activeMessage) setActiveMessage(null);
|
||||||
</VStack>
|
else setActiveMessage(m);
|
||||||
|
}}
|
||||||
|
alignItems="center"
|
||||||
|
className={classNames(
|
||||||
|
'px-2 py-1 font-mono text-xs opacity-70',
|
||||||
|
m === activeMessage && 'bg-highlight !opacity-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={m.isServer ? 'text-blue-600' : 'text-green-600'}
|
||||||
|
icon={m.isServer ? 'arrowBigDownDash' : 'arrowBigUpDash'}
|
||||||
|
/>
|
||||||
|
<div className="w-full truncate">{m.message}</div>
|
||||||
|
<div>{format(m.time, 'HH:mm:ss')}</div>
|
||||||
|
</HStack>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'transition-all',
|
||||||
|
activeMessage ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-[100%]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="pb-2 px-2">
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
<div className="pl-2">
|
||||||
|
<JsonAttributeTree
|
||||||
|
depth={0}
|
||||||
|
attrValue={JSON.parse(activeMessage?.message ?? '{}')}
|
||||||
|
attrKey={''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/*<Editor*/}
|
||||||
|
{/* className="bg-gray-50 dark:bg-gray-100 max-h-30"*/}
|
||||||
|
{/* contentType="application/json"*/}
|
||||||
|
{/* heightMode="auto"*/}
|
||||||
|
{/* defaultValue={tryFormatJson(activeMessage?.message ?? '')}*/}
|
||||||
|
{/* forceUpdateKey={activeMessage?.time.getTime()}*/}
|
||||||
|
{/* readOnly*/}
|
||||||
|
{/*/>*/}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : resp ? (
|
) : resp ? (
|
||||||
<Editor
|
<Editor
|
||||||
className="bg-gray-50 dark:bg-gray-100"
|
className="bg-gray-50 dark:bg-gray-100"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Banner } from './core/Banner';
|
|||||||
import { CountBadge } from './core/CountBadge';
|
import { CountBadge } from './core/CountBadge';
|
||||||
import { DurationTag } from './core/DurationTag';
|
import { DurationTag } from './core/DurationTag';
|
||||||
import { HotKeyList } from './core/HotKeyList';
|
import { HotKeyList } from './core/HotKeyList';
|
||||||
|
import { JsonAttributeTree } from './core/JsonAttributeTree';
|
||||||
import { SizeTag } from './core/SizeTag';
|
import { SizeTag } from './core/SizeTag';
|
||||||
import { HStack } from './core/Stacks';
|
import { HStack } from './core/Stacks';
|
||||||
import { StatusTag } from './core/StatusTag';
|
import { StatusTag } from './core/StatusTag';
|
||||||
@@ -23,6 +24,7 @@ import { RecentResponsesDropdown } from './RecentResponsesDropdown';
|
|||||||
import { ResponseHeaders } from './ResponseHeaders';
|
import { ResponseHeaders } from './ResponseHeaders';
|
||||||
import { CsvViewer } from './responseViewers/CsvViewer';
|
import { CsvViewer } from './responseViewers/CsvViewer';
|
||||||
import { ImageViewer } from './responseViewers/ImageViewer';
|
import { ImageViewer } from './responseViewers/ImageViewer';
|
||||||
|
import { JsonViewer } from './responseViewers/JsonViewer';
|
||||||
import { TextViewer } from './responseViewers/TextViewer';
|
import { TextViewer } from './responseViewers/TextViewer';
|
||||||
import { WebPageViewer } from './responseViewers/WebPageViewer';
|
import { WebPageViewer } from './responseViewers/WebPageViewer';
|
||||||
|
|
||||||
@@ -179,6 +181,8 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
|
|||||||
) : contentType?.match(/csv|tab-separated/) ? (
|
) : contentType?.match(/csv|tab-separated/) ? (
|
||||||
<CsvViewer className="pb-2" response={activeResponse} />
|
<CsvViewer className="pb-2" response={activeResponse} />
|
||||||
) : (
|
) : (
|
||||||
|
// ) : contentType?.startsWith('application/json') ? (
|
||||||
|
// <JsonViewer response={activeResponse} />
|
||||||
<TextViewer response={activeResponse} pretty={viewMode === 'pretty'} />
|
<TextViewer response={activeResponse} pretty={viewMode === 'pretty'} />
|
||||||
)}
|
)}
|
||||||
</TabContent>
|
</TabContent>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export interface EditorProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
heightMode?: 'auto' | 'full';
|
heightMode?: 'auto' | 'full';
|
||||||
contentType?: string | null;
|
contentType?: string | null;
|
||||||
forceUpdateKey?: string;
|
forceUpdateKey?: string | number;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
autoSelect?: boolean;
|
autoSelect?: boolean;
|
||||||
defaultValue?: string | null;
|
defaultValue?: string | null;
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ const icons = {
|
|||||||
arrowUpFromDot: lucide.ArrowUpFromDotIcon,
|
arrowUpFromDot: lucide.ArrowUpFromDotIcon,
|
||||||
arrowDownToDot: lucide.ArrowDownToDotIcon,
|
arrowDownToDot: lucide.ArrowDownToDotIcon,
|
||||||
arrowUpDown: lucide.ArrowUpDownIcon,
|
arrowUpDown: lucide.ArrowUpDownIcon,
|
||||||
|
arrowDown: lucide.ArrowDownIcon,
|
||||||
|
arrowUp: lucide.ArrowUpIcon,
|
||||||
|
arrowBigDownDash: lucide.ArrowBigDownDashIcon,
|
||||||
|
arrowBigUpDash: lucide.ArrowBigUpDashIcon,
|
||||||
x: lucide.XIcon,
|
x: lucide.XIcon,
|
||||||
|
|
||||||
empty: (props: HTMLAttributes<HTMLSpanElement>) => <span {...props} />,
|
empty: (props: HTMLAttributes<HTMLSpanElement>) => <span {...props} />,
|
||||||
@@ -51,7 +55,7 @@ const icons = {
|
|||||||
export interface IconProps {
|
export interface IconProps {
|
||||||
icon: keyof typeof icons;
|
icon: keyof typeof icons;
|
||||||
className?: string;
|
className?: string;
|
||||||
size?: 'xs' | 'sm' | 'md';
|
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||||
spin?: boolean;
|
spin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +65,8 @@ export const Icon = memo(function Icon({ icon, spin, size = 'md', className }: I
|
|||||||
<Component
|
<Component
|
||||||
className={classNames(
|
className={classNames(
|
||||||
className,
|
className,
|
||||||
'text-inherit',
|
'text-inherit flex-shrink-0',
|
||||||
|
size === 'lg' && 'h-5 w-5',
|
||||||
size === 'md' && 'h-4 w-4',
|
size === 'md' && 'h-4 w-4',
|
||||||
size === 'sm' && 'h-3.5 w-3.5',
|
size === 'sm' && 'h-3.5 w-3.5',
|
||||||
size === 'xs' && 'h-3 w-3',
|
size === 'xs' && 'h-3 w-3',
|
||||||
|
|||||||
109
src-web/components/core/JsonAttributeTree.tsx
Normal file
109
src-web/components/core/JsonAttributeTree.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import classNames from 'classnames';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Icon } from './Icon';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
depth?: number;
|
||||||
|
attrValue: any;
|
||||||
|
attrKey?: string | number;
|
||||||
|
attrKeyJsonPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const JsonAttributeTree = ({ depth = 0, attrKey, attrValue, attrKeyJsonPath }: Props) => {
|
||||||
|
attrKeyJsonPath = attrKeyJsonPath ?? `${attrKey}`;
|
||||||
|
|
||||||
|
const [isExpanded, setIsExpanded] = useState(depth === 0);
|
||||||
|
const toggleExpanded = () => setIsExpanded((v) => !v);
|
||||||
|
|
||||||
|
const { isExpandable, children, label, labelClassName } = useMemo<{
|
||||||
|
isExpandable: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
label?: string;
|
||||||
|
labelClassName?: string;
|
||||||
|
}>(() => {
|
||||||
|
const jsonType = Object.prototype.toString.call(attrValue);
|
||||||
|
if (jsonType === '[object Object]') {
|
||||||
|
return {
|
||||||
|
children: isExpanded
|
||||||
|
? Object.keys(attrValue)
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.flatMap((k) => (
|
||||||
|
<JsonAttributeTree
|
||||||
|
depth={depth + 1}
|
||||||
|
attrValue={attrValue[k]}
|
||||||
|
attrKey={k}
|
||||||
|
attrKeyJsonPath={joinObjectKey(attrKeyJsonPath, k)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: null,
|
||||||
|
isExpandable: true,
|
||||||
|
label: isExpanded ? undefined : `{⋯}`,
|
||||||
|
labelClassName: 'text-gray-500',
|
||||||
|
};
|
||||||
|
} else if (jsonType === '[object Array]') {
|
||||||
|
return {
|
||||||
|
children: isExpanded
|
||||||
|
? attrValue.flatMap((v: any, i: number) => (
|
||||||
|
<JsonAttributeTree
|
||||||
|
depth={depth + 1}
|
||||||
|
attrValue={v}
|
||||||
|
attrKey={i}
|
||||||
|
attrKeyJsonPath={joinArrayKey(attrKeyJsonPath, i)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: null,
|
||||||
|
isExpandable: true,
|
||||||
|
label: isExpanded ? undefined : `[⋯]`,
|
||||||
|
labelClassName: 'text-gray-500',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
children: null,
|
||||||
|
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',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [attrValue, attrKeyJsonPath, isExpanded, depth]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classNames(depth === 0 && '-ml-4', 'font-mono text-xs')}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{depth === 0 ? null : isExpandable ? (
|
||||||
|
<button className="relative flex items-center pl-4" onClick={toggleExpanded}>
|
||||||
|
<Icon
|
||||||
|
className={classNames(
|
||||||
|
'left-0 absolute transition-transform text-gray-500 flex gap-1 items-center',
|
||||||
|
isExpanded ? 'rotate-90' : '',
|
||||||
|
)}
|
||||||
|
size="xs"
|
||||||
|
icon="chevronRight"
|
||||||
|
/>
|
||||||
|
<span className="text-violet-600 mr-1.5 whitespace-nowrap">{attrKey}:</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-violet-600 mr-1.5 pl-4 whitespace-nowrap">{attrKey}:</span>
|
||||||
|
)}
|
||||||
|
<span className={classNames(labelClassName, 'select-text')}>{label}</span>
|
||||||
|
</div>
|
||||||
|
{children && <div className="ml-4 whitespace-nowrap">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function joinObjectKey(baseKey: string | undefined, key: string): string {
|
||||||
|
const quotedKey = key.match(/^[a-z0-9_]+$/i) ? key : `\`${key}\``;
|
||||||
|
|
||||||
|
if (baseKey == null) return quotedKey;
|
||||||
|
else return `${baseKey}.${quotedKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinArrayKey(baseKey: string | undefined, index: number): string {
|
||||||
|
return `${baseKey ?? ''}[${index}]`;
|
||||||
|
}
|
||||||
25
src-web/components/responseViewers/JsonViewer.tsx
Normal file
25
src-web/components/responseViewers/JsonViewer.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import classNames from 'classnames';
|
||||||
|
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
|
||||||
|
import type { HttpResponse } from '../../lib/models';
|
||||||
|
import { JsonAttributeTree } from '../core/JsonAttributeTree';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
response: HttpResponse;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JsonViewer({ response, className }: Props) {
|
||||||
|
const rawBody = useResponseBodyText(response) ?? '';
|
||||||
|
let parsed = {};
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(rawBody);
|
||||||
|
} catch (e) {
|
||||||
|
// foo
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classNames(className, 'overflow-x-auto h-full')}>
|
||||||
|
<JsonAttributeTree attrValue={parsed} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,18 +8,33 @@ interface ReflectResponseService {
|
|||||||
methods: { name: string; schema: string; serverStreaming: boolean; clientStreaming: boolean }[];
|
methods: { name: string; schema: string; serverStreaming: boolean; clientStreaming: boolean }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Message {
|
export interface GrpcMessage {
|
||||||
message: string;
|
message: string;
|
||||||
time: Date;
|
time: Date;
|
||||||
|
isServer: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGrpc(url: string | null) {
|
export function useGrpc(url: string | null) {
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<GrpcMessage[]>([]);
|
||||||
useListenToTauriEvent<string>(
|
useListenToTauriEvent<string>(
|
||||||
'grpc_message',
|
'grpc_message',
|
||||||
(event) => {
|
(event) => {
|
||||||
console.log('GOT MESSAGE', event);
|
console.log('GOT MESSAGE', event);
|
||||||
setMessages((prev) => [...prev, { message: event.payload, time: new Date() }]);
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
message: JSON.stringify({
|
||||||
|
dummy: 'Yo, this is a dummy message',
|
||||||
|
another: 'property',
|
||||||
|
list: [1, 2, 3, 4, 5],
|
||||||
|
null: null,
|
||||||
|
bool: true,
|
||||||
|
}),
|
||||||
|
time: new Date(),
|
||||||
|
isServer: false,
|
||||||
|
},
|
||||||
|
{ message: event.payload, time: new Date(), isServer: true },
|
||||||
|
]);
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user