diff --git a/src-web/components/GrpcConnectionLayout.tsx b/src-web/components/GrpcConnectionLayout.tsx index eb7785ed..a6e9118b 100644 --- a/src-web/components/GrpcConnectionLayout.tsx +++ b/src-web/components/GrpcConnectionLayout.tsx @@ -1,19 +1,24 @@ import classNames from 'classnames'; +import { format } from 'date-fns'; +import { m } from 'framer-motion'; import type { CSSProperties, FormEvent } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useAlert } from '../hooks/useAlert'; +import type { GrpcMessage } from '../hooks/useGrpc'; import { useGrpc } from '../hooks/useGrpc'; import { useKeyValue } from '../hooks/useKeyValue'; +import { tryFormatJson } from '../lib/formatters'; import { Banner } from './core/Banner'; import { Editor } from './core/Editor'; import { HotKeyList } from './core/HotKeyList'; import { Icon } from './core/Icon'; +import { JsonAttributeTree } from './core/JsonAttributeTree'; import { Select } from './core/Select'; +import { Separator } from './core/Separator'; import { SplitLayout } from './core/SplitLayout'; import { HStack, VStack } from './core/Stacks'; import { GrpcEditor } from './GrpcEditor'; import { UrlBar } from './UrlBar'; -import { format } from 'date-fns'; interface Props { style: CSSProperties; @@ -37,6 +42,7 @@ export function GrpcConnectionLayout({ style }: Props) { key: 'grpc_message', defaultValue: '', }); + const [activeMessage, setActiveMessage] = useState(null); const [resp, setResp] = useState(''); const grpc = useGrpc(url.value ?? null); @@ -180,7 +186,7 @@ export function GrpcConnectionLayout({ style }: Props) { className={classNames( '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 py-1', + 'shadow shadow-gray-100 dark:shadow-gray-0 relative pt-1', )} > {grpc.unary.error ? ( @@ -188,15 +194,57 @@ export function GrpcConnectionLayout({ style }: Props) { {grpc.unary.error} ) : grpc.messages.length > 0 ? ( - - {[...grpc.messages].reverse().map((m, i) => ( - - -
{format(m.time, 'HH:mm:ss')}
-
{m.message}
-
- ))} -
+
+
+ {...grpc.messages.map((m) => ( + { + if (m === activeMessage) setActiveMessage(null); + else setActiveMessage(m); + }} + alignItems="center" + className={classNames( + 'px-2 py-1 font-mono text-xs opacity-70', + m === activeMessage && 'bg-highlight !opacity-100', + )} + > + +
{m.message}
+
{format(m.time, 'HH:mm:ss')}
+
+ ))} +
+
+
+ +
+
+ +
+ {/**/} +
+
) : resp ? ( ) : ( + // ) : contentType?.startsWith('application/json') ? ( + // )} diff --git a/src-web/components/core/Editor/Editor.tsx b/src-web/components/core/Editor/Editor.tsx index f3867c53..7f13640c 100644 --- a/src-web/components/core/Editor/Editor.tsx +++ b/src-web/components/core/Editor/Editor.tsx @@ -38,7 +38,7 @@ export interface EditorProps { className?: string; heightMode?: 'auto' | 'full'; contentType?: string | null; - forceUpdateKey?: string; + forceUpdateKey?: string | number; autoFocus?: boolean; autoSelect?: boolean; defaultValue?: string | null; diff --git a/src-web/components/core/Icon.tsx b/src-web/components/core/Icon.tsx index adbcff3a..f5bda426 100644 --- a/src-web/components/core/Icon.tsx +++ b/src-web/components/core/Icon.tsx @@ -43,6 +43,10 @@ const icons = { arrowUpFromDot: lucide.ArrowUpFromDotIcon, arrowDownToDot: lucide.ArrowDownToDotIcon, arrowUpDown: lucide.ArrowUpDownIcon, + arrowDown: lucide.ArrowDownIcon, + arrowUp: lucide.ArrowUpIcon, + arrowBigDownDash: lucide.ArrowBigDownDashIcon, + arrowBigUpDash: lucide.ArrowBigUpDashIcon, x: lucide.XIcon, empty: (props: HTMLAttributes) => , @@ -51,7 +55,7 @@ const icons = { export interface IconProps { icon: keyof typeof icons; className?: string; - size?: 'xs' | 'sm' | 'md'; + size?: 'xs' | 'sm' | 'md' | 'lg'; spin?: boolean; } @@ -61,7 +65,8 @@ export const Icon = memo(function Icon({ icon, spin, size = 'md', className }: I { + 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) => ( + + )) + : null, + isExpandable: true, + label: isExpanded ? undefined : `{⋯}`, + labelClassName: 'text-gray-500', + }; + } else if (jsonType === '[object Array]') { + return { + children: isExpanded + ? attrValue.flatMap((v: any, i: number) => ( + + )) + : 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 ( +
+
+ {depth === 0 ? null : isExpandable ? ( + + ) : ( + {attrKey}: + )} + {label} +
+ {children &&
{children}
} +
+ ); +}; + +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}]`; +} diff --git a/src-web/components/responseViewers/JsonViewer.tsx b/src-web/components/responseViewers/JsonViewer.tsx new file mode 100644 index 00000000..62949b3a --- /dev/null +++ b/src-web/components/responseViewers/JsonViewer.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/src-web/hooks/useGrpc.ts b/src-web/hooks/useGrpc.ts index 7b3939de..14567b07 100644 --- a/src-web/hooks/useGrpc.ts +++ b/src-web/hooks/useGrpc.ts @@ -8,18 +8,33 @@ interface ReflectResponseService { methods: { name: string; schema: string; serverStreaming: boolean; clientStreaming: boolean }[]; } -interface Message { +export interface GrpcMessage { message: string; time: Date; + isServer: boolean; } export function useGrpc(url: string | null) { - const [messages, setMessages] = useState([]); + const [messages, setMessages] = useState([]); useListenToTauriEvent( 'grpc_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 }, + ]); }, [], );