diff --git a/src-tauri/grpc/src/lib.rs b/src-tauri/grpc/src/lib.rs index bb271fbd..ada331cc 100644 --- a/src-tauri/grpc/src/lib.rs +++ b/src-tauri/grpc/src/lib.rs @@ -29,10 +29,11 @@ pub struct MethodDefinition { pub server_streaming: bool, } -pub async fn reflect(uri: &Uri) -> Vec { - let (pool, _) = fill_pool(uri).await; +pub async fn reflect(uri: &Uri) -> Result, String> { + let (pool, _) = fill_pool(uri).await?; - pool.services() + Ok(pool + .services() .map(|s| { let mut def = ServiceDefinition { name: s.full_name().to_string(), @@ -53,5 +54,5 @@ pub async fn reflect(uri: &Uri) -> Vec { } def }) - .collect::>() + .collect::>()) } diff --git a/src-tauri/grpc/src/manager.rs b/src-tauri/grpc/src/manager.rs index c8ad4f1f..057908c7 100644 --- a/src-tauri/grpc/src/manager.rs +++ b/src-tauri/grpc/src/manager.rs @@ -173,7 +173,7 @@ impl GrpcManager { message: &str, ) -> Result> { self.connect(id, uri) - .await + .await? .server_streaming(service, method, message) .await } @@ -187,7 +187,7 @@ impl GrpcManager { stream: ReceiverStream, ) -> Result { self.connect(id, uri) - .await + .await? .client_streaming(service, method, stream) .await } @@ -201,15 +201,15 @@ impl GrpcManager { stream: ReceiverStream, ) -> Result> { self.connect(id, uri) - .await + .await? .streaming(service, method, stream) .await } - pub async fn connect(&mut self, id: &str, uri: Uri) -> GrpcConnection { - let (pool, conn) = fill_pool(&uri).await; + pub async fn connect(&mut self, id: &str, uri: Uri) -> Result { + let (pool, conn) = fill_pool(&uri).await?; let connection = GrpcConnection { pool, conn, uri }; self.connections.insert(id.to_string(), connection.clone()); - connection + Ok(connection) } } diff --git a/src-tauri/grpc/src/proto.rs b/src-tauri/grpc/src/proto.rs index 9a12df25..c25f3480 100644 --- a/src-tauri/grpc/src/proto.rs +++ b/src-tauri/grpc/src/proto.rs @@ -5,6 +5,7 @@ use anyhow::anyhow; use hyper::client::HttpConnector; use hyper::Client; use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder}; +use log::warn; use prost::Message; use prost_reflect::{DescriptorPool, MethodDescriptor}; use prost_types::FileDescriptorProto; @@ -12,6 +13,7 @@ use tokio_stream::StreamExt; use tonic::body::BoxBody; use tonic::codegen::http::uri::PathAndQuery; use tonic::transport::Uri; +use tonic::Code::Unimplemented; use tonic::Request; use tonic_reflection::pb::server_reflection_client::ServerReflectionClient; use tonic_reflection::pb::server_reflection_request::MessageRequest; @@ -20,10 +22,13 @@ use tonic_reflection::pb::ServerReflectionRequest; pub async fn fill_pool( uri: &Uri, -) -> ( - DescriptorPool, - Client, BoxBody>, -) { +) -> Result< + ( + DescriptorPool, + Client, BoxBody>, + ), + String, +> { let mut pool = DescriptorPool::new(); let connector = HttpsConnectorBuilder::new().with_native_roots(); let connector = connector.https_or_http().enable_http2().wrap_connector({ @@ -37,34 +42,33 @@ pub async fn fill_pool( .build(connector); let mut client = ServerReflectionClient::with_origin(transport.clone(), uri.clone()); - let services = list_services(&mut client).await; - for service in services { + for service in list_services(&mut client).await? { if service == "grpc.reflection.v1alpha.ServerReflection" { continue; } file_descriptor_set_from_service_name(&service, &mut pool, &mut client).await; } - (pool, transport) + Ok((pool, transport)) } async fn list_services( reflect_client: &mut ServerReflectionClient, BoxBody>>, -) -> Vec { +) -> Result, String> { let response = - send_reflection_request(reflect_client, MessageRequest::ListServices("".into())).await; + send_reflection_request(reflect_client, MessageRequest::ListServices("".into())).await?; let list_services_response = match response { MessageResponse::ListServicesResponse(resp) => resp, _ => panic!("Expected a ListServicesResponse variant"), }; - list_services_response + Ok(list_services_response .service .iter() .map(|s| s.name.clone()) - .collect::>() + .collect::>()) } async fn file_descriptor_set_from_service_name( @@ -72,11 +76,21 @@ async fn file_descriptor_set_from_service_name( pool: &mut DescriptorPool, client: &mut ServerReflectionClient, BoxBody>>, ) { - let response = send_reflection_request( + let response = match send_reflection_request( client, MessageRequest::FileContainingSymbol(service_name.into()), ) - .await; + .await + { + Ok(resp) => resp, + Err(e) => { + warn!( + "Error fetching file descriptor for service {}: {}", + service_name, e + ); + return; + } + }; let file_descriptor_response = match response { MessageResponse::FileDescriptorResponse(resp) => resp, @@ -109,8 +123,14 @@ async fn file_descriptor_set_by_filename( let response = send_reflection_request(client, MessageRequest::FileByFilename(filename.into())).await; let file_descriptor_response = match response { - MessageResponse::FileDescriptorResponse(resp) => resp, - _ => panic!("Expected a FileDescriptorResponse variant"), + Ok(MessageResponse::FileDescriptorResponse(resp)) => resp, + Ok(_) => { + panic!("Expected a FileDescriptorResponse variant") + } + Err(e) => { + warn!("Error fetching file descriptor for {}: {}", filename, e); + return; + } }; for fd in file_descriptor_response.file_descriptor_proto { @@ -123,7 +143,7 @@ async fn file_descriptor_set_by_filename( async fn send_reflection_request( client: &mut ServerReflectionClient, BoxBody>>, message: MessageRequest, -) -> MessageResponse { +) -> Result { let reflection_request = ServerReflectionRequest { host: "".into(), // Doesn't matter message_request: Some(message), @@ -134,14 +154,17 @@ async fn send_reflection_request( client .server_reflection_info(request) .await - .expect("server reflection failed") + .map_err(|e| match e.code() { + Unimplemented => "Reflection not implemented for server".to_string(), + _ => e.to_string(), + })? .into_inner() .next() .await .expect("steamed response") - .expect("successful response") + .map_err(|e| e.to_string())? .message_response - .expect("some MessageResponse") + .ok_or("No reflection response".to_string()) } pub fn method_desc_to_path(md: &MethodDescriptor) -> PathAndQuery { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 1169de09..71dbd751 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -100,7 +100,7 @@ async fn cmd_grpc_reflect( .await .map_err(|e| e.to_string())?; let uri = safe_uri(&req.url).map_err(|e| e.to_string())?; - Ok(grpc::reflect(&uri).await) + grpc::reflect(&uri).await } #[tauri::command] @@ -150,7 +150,7 @@ async fn cmd_grpc_call_unary( .lock() .await .connect(&conn.clone().id, uri) - .await + .await? .unary( &req.service.unwrap_or_default(), &req.method.unwrap_or_default(), diff --git a/src-web/components/GlobalHooks.tsx b/src-web/components/GlobalHooks.tsx index 09616cf4..6b061a6a 100644 --- a/src-web/components/GlobalHooks.tsx +++ b/src-web/components/GlobalHooks.tsx @@ -86,10 +86,8 @@ export function GlobalHooks() { queryClient.setQueryData(queryKey, (values = []) => { const index = values.findIndex((v) => modelsEq(v, payload)) ?? -1; if (index >= 0) { - console.log('UPDATED MODEL', payload); return [...values.slice(0, index), payload, ...values.slice(index + 1)]; } else { - console.log('INSERTED MODEL', payload); return pushToFront ? [payload, ...(values ?? [])] : [...(values ?? []), payload]; } }); diff --git a/src-web/components/GrpcConnectionLayout.tsx b/src-web/components/GrpcConnectionLayout.tsx index 6a2fef9a..4217c889 100644 --- a/src-web/components/GrpcConnectionLayout.tsx +++ b/src-web/components/GrpcConnectionLayout.tsx @@ -1,27 +1,16 @@ -import useResizeObserver from '@react-hook/resize-observer'; import classNames from 'classnames'; -import { format } from 'date-fns'; -import type { CSSProperties, FormEvent } from 'react'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { CSSProperties } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { useActiveRequest } from '../hooks/useActiveRequest'; -import { useAlert } from '../hooks/useAlert'; import { useGrpc } from '../hooks/useGrpc'; import { useGrpcConnections } from '../hooks/useGrpcConnections'; import { useGrpcMessages } from '../hooks/useGrpcMessages'; import { useUpdateGrpcRequest } from '../hooks/useUpdateGrpcRequest'; import { Banner } from './core/Banner'; -import { Button } from './core/Button'; import { HotKeyList } from './core/HotKeyList'; -import { Icon } from './core/Icon'; -import { IconButton } from './core/IconButton'; -import { JsonAttributeTree } from './core/JsonAttributeTree'; -import { RadioDropdown } from './core/RadioDropdown'; -import { Separator } from './core/Separator'; import { SplitLayout } from './core/SplitLayout'; -import { HStack, VStack } from './core/Stacks'; -import { GrpcEditor } from './GrpcEditor'; -import { RecentConnectionsDropdown } from './RecentConnectionsDropdown'; -import { UrlBar } from './UrlBar'; +import { GrpcConnectionMessagesPane } from './GrpcConnectionMessagesPane'; +import { GrpcConnectionSetupPane } from './GrpcConnectionSetupPane'; interface Props { style: CSSProperties; @@ -30,62 +19,19 @@ interface Props { export function GrpcConnectionLayout({ style }: Props) { const activeRequest = useActiveRequest('grpc_request'); const updateRequest = useUpdateGrpcRequest(activeRequest?.id ?? null); - const alert = useAlert(); - const [activeMessageId, setActiveMessageId] = useState(null); const connections = useGrpcConnections(activeRequest?.id ?? null); const activeConnection = connections[0] ?? null; const messages = useGrpcMessages(activeConnection?.id ?? null); const grpc = useGrpc(activeRequest, activeConnection); - const activeMethod = useMemo(() => { - if (grpc.services == null || activeRequest == null) return null; - - const s = grpc.services.find((s) => s.name === activeRequest.service); - if (s == null) return null; - return s.methods.find((m) => m.name === activeRequest.method); - }, [activeRequest, grpc.services]); - - const handleConnect = useCallback( - async (e: FormEvent) => { - e.preventDefault(); - if (activeMethod == null || activeRequest == null) return; - - if (activeRequest.service == null || activeRequest.method == null) { - alert({ - id: 'grpc-invalid-service-method', - title: 'Error', - body: 'Service or method not selected', - }); - } - if (activeMethod.clientStreaming && activeMethod.serverStreaming) { - await grpc.streaming.mutateAsync(); - } else if (!activeMethod.clientStreaming && activeMethod.serverStreaming) { - await grpc.serverStreaming.mutateAsync(); - } else if (activeMethod.clientStreaming && !activeMethod.serverStreaming) { - await grpc.clientStreaming.mutateAsync(); - } else { - const msg = await grpc.unary.mutateAsync(); - setActiveMessageId(msg.id); - } - }, - [ - activeMethod, - activeRequest, - alert, - grpc.streaming, - grpc.serverStreaming, - grpc.clientStreaming, - grpc.unary, - ], - ); - + const services = grpc.reflect.data ?? null; useEffect(() => { - if (grpc.services == null || activeRequest == null) return; - const s = grpc.services.find((s) => s.name === activeRequest.service); + if (services == null || activeRequest == null) return; + const s = services.find((s) => s.name === activeRequest.service); if (s == null) { updateRequest.mutate({ - service: grpc.services[0]?.name ?? null, - method: grpc.services[0]?.methods[0]?.name ?? null, + service: services[0]?.name ?? null, + method: services[0]?.methods[0]?.name ?? null, }); return; } @@ -95,61 +41,30 @@ export function GrpcConnectionLayout({ style }: Props) { updateRequest.mutate({ method: s.methods[0]?.name ?? null }); return; } - }, [activeRequest, grpc.services, updateRequest]); + }, [activeRequest, services, updateRequest]); - const handleChangeService = useCallback( - async (v: string) => { - const [serviceName, methodName] = v.split('/', 2); - if (serviceName == null || methodName == null) throw new Error('Should never happen'); - await updateRequest.mutateAsync({ - service: serviceName, - method: methodName, - }); - }, - [updateRequest], - ); + const activeMethod = useMemo(() => { + if (services == null || activeRequest == null) return null; - const handleChangeUrl = useCallback( - (url: string) => updateRequest.mutateAsync({ url }), - [updateRequest], - ); + const s = services.find((s) => s.name === activeRequest.service); + if (s == null) return null; + return s.methods.find((m) => m.name === activeRequest.method); + }, [activeRequest, services]); - const handleChangeMessage = useCallback( - (message: string) => updateRequest.mutateAsync({ message }), - [updateRequest], - ); - - const select = useMemo(() => { - const options = - grpc.services?.flatMap((s) => - s.methods.map((m) => ({ - label: `${s.name.split('.', 2).pop() ?? s.name}/${m.name}`, - value: `${s.name}/${m.name}`, - })), - ) ?? []; - const value = `${activeRequest?.service ?? ''}/${activeRequest?.method ?? ''}`; - return { value, options }; - }, [activeRequest?.method, activeRequest?.service, grpc.services]); - - const [paneSize, setPaneSize] = useState(99999); - const urlContainerEl = useRef(null); - useResizeObserver(urlContainerEl.current, (entry) => { - setPaneSize(entry.contentRect.width); - }); - - const activeMessage = useMemo( - () => messages.find((m) => m.id === activeMessageId) ?? null, - [activeMessageId, messages], - ); - - const messageType: 'unary' | 'server_streaming' | 'client_streaming' | 'streaming' = - useMemo(() => { - if (activeMethod == null) return 'unary'; // Good enough - if (activeMethod.clientStreaming && activeMethod.serverStreaming) return 'streaming'; - if (activeMethod.clientStreaming) return 'client_streaming'; - if (activeMethod.serverStreaming) return 'server_streaming'; - return 'unary'; - }, [activeMethod]); + const methodType: + | 'unary' + | 'server_streaming' + | 'client_streaming' + | 'streaming' + | 'no-schema' + | 'no-method' = useMemo(() => { + if (services == null) return 'no-schema'; + if (activeMethod == null) return 'no-method'; + if (activeMethod.clientStreaming && activeMethod.serverStreaming) return 'streaming'; + if (activeMethod.clientStreaming) return 'client_streaming'; + if (activeMethod.serverStreaming) return 'server_streaming'; + return 'unary'; + }, [activeMethod, services]); if (activeRequest == null) { return null; @@ -160,110 +75,28 @@ export function GrpcConnectionLayout({ style }: Props) { name="grpc_layout" className="p-3 gap-1.5" style={style} - firstSlot={() => ( - -
- - - ({ - label: o.label, - value: o.value, - type: 'default', - shortLabel: o.label, - }))} - onChange={handleChangeService} - > - - - {!grpc.isStreaming && ( - - )} - {grpc.isStreaming && ( - grpc.cancel.mutateAsync()} - icon="x" - disabled={!grpc.isStreaming} - /> - )} - {activeMethod?.clientStreaming && - !activeMethod.serverStreaming && - grpc.isStreaming && ( - grpc.commit.mutateAsync()} - icon="check" - /> - )} - {activeMethod?.clientStreaming && grpc.isStreaming && ( - grpc.send.mutateAsync({ message: activeRequest.message ?? '' })} - icon="sendHorizontal" - /> - )} - -
- -
+ firstSlot={({ style }) => ( + )} - secondSlot={() => + secondSlot={({ style }) => !grpc.unary.isLoading && (
) : messages.length >= 0 ? ( - ( -
- - - {messages.filter((m) => !m.isInfo).length} messages - {grpc.isStreaming && ( - - )} - - {activeConnection && ( - { - // todo - }} - /> - )} - -
- {...messages.map((m) => ( - { - if (m.id === activeMessageId) setActiveMessageId(null); - else setActiveMessageId(m.id); - }} - alignItems="center" - className={classNames( - 'px-2 py-1 font-mono', - m === activeMessage && 'bg-highlight', - )} - > - -
{m.message}
-
- {format(m.createdAt, 'HH:mm:ss')} -
-
- ))} -
-
- )} - secondSlot={ - !activeMessage - ? null - : () => ( -
-
- -
-
- {activeMessage.isInfo ? ( - {activeMessage.message} - ) : ( - - )} -
-
- ) - } - /> + ) : ( - // ) : ? ( - // )}
diff --git a/src-web/components/GrpcConnectionMessagesPane.tsx b/src-web/components/GrpcConnectionMessagesPane.tsx new file mode 100644 index 00000000..6547c79a --- /dev/null +++ b/src-web/components/GrpcConnectionMessagesPane.tsx @@ -0,0 +1,109 @@ +import classNames from 'classnames'; +import { format } from 'date-fns'; +import type { CSSProperties } from 'react'; +import React, { useMemo, useState } from 'react'; +import { useGrpcConnections } from '../hooks/useGrpcConnections'; +import { useGrpcMessages } from '../hooks/useGrpcMessages'; +import type { GrpcRequest } from '../lib/models'; +import { Icon } from './core/Icon'; +import { JsonAttributeTree } from './core/JsonAttributeTree'; +import { Separator } from './core/Separator'; +import { SplitLayout } from './core/SplitLayout'; +import { HStack } from './core/Stacks'; +import { RecentConnectionsDropdown } from './RecentConnectionsDropdown'; + +interface Props { + style?: CSSProperties; + className?: string; + activeRequest: GrpcRequest; + methodType: + | 'unary' + | 'client_streaming' + | 'server_streaming' + | 'streaming' + | 'no-schema' + | 'no-method'; +} + +export function GrpcConnectionMessagesPane({ style, methodType, activeRequest }: Props) { + const [activeMessageId, setActiveMessageId] = useState(null); + const connections = useGrpcConnections(activeRequest.id ?? null); + const activeConnection = connections[0] ?? null; + const messages = useGrpcMessages(activeConnection?.id ?? null); + + const activeMessage = useMemo( + () => messages.find((m) => m.id === activeMessageId) ?? null, + [activeMessageId, messages], + ); + + return ( + ( +
+ + + {messages.filter((m) => !m.isInfo).length} messages + {activeConnection?.elapsed === 0 && ( + + )} + + {activeConnection && ( + { + // todo + }} + /> + )} + +
+ {...messages.map((m) => ( + { + if (m.id === activeMessageId) setActiveMessageId(null); + else setActiveMessageId(m.id); + }} + alignItems="center" + className={classNames('px-2 py-1 font-mono', m === activeMessage && 'bg-highlight')} + > + +
{m.message}
+
{format(m.createdAt, 'HH:mm:ss')}
+
+ ))} +
+
+ )} + secondSlot={ + activeMessage && + (() => ( +
+
+ +
+
+ {activeMessage.isInfo ? ( + {activeMessage.message} + ) : ( + + )} +
+
+ )) + } + /> + ); +} diff --git a/src-web/components/GrpcConnectionSetupPane.tsx b/src-web/components/GrpcConnectionSetupPane.tsx new file mode 100644 index 00000000..368fd263 --- /dev/null +++ b/src-web/components/GrpcConnectionSetupPane.tsx @@ -0,0 +1,242 @@ +import useResizeObserver from '@react-hook/resize-observer'; +import classNames from 'classnames'; +import type { CSSProperties, FormEvent } from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import type { ReflectResponseService } from '../hooks/useGrpc'; +import { useGrpcConnections } from '../hooks/useGrpcConnections'; +import { useUpdateGrpcRequest } from '../hooks/useUpdateGrpcRequest'; +import type { GrpcRequest } from '../lib/models'; +import { Banner } from './core/Banner'; +import { Button } from './core/Button'; +import { FormattedError } from './core/FormattedError'; +import { Icon } from './core/Icon'; +import { IconButton } from './core/IconButton'; +import { RadioDropdown } from './core/RadioDropdown'; +import { HStack, VStack } from './core/Stacks'; +import { GrpcEditor } from './GrpcEditor'; +import { UrlBar } from './UrlBar'; + +interface Props { + style?: CSSProperties; + className?: string; + activeRequest: GrpcRequest; + reflectionError?: string; + reflectionLoading?: boolean; + methodType: + | 'unary' + | 'client_streaming' + | 'server_streaming' + | 'streaming' + | 'no-schema' + | 'no-method'; + onUnary: () => void; + onCommit: () => void; + onCancel: () => void; + onSend: (v: { message: string }) => void; + onClientStreaming: () => void; + onServerStreaming: () => void; + onStreaming: () => void; + onReflectRefetch: () => void; + services: ReflectResponseService[] | null; +} + +export function GrpcConnectionSetupPane({ + style, + services, + methodType, + activeRequest, + reflectionError, + reflectionLoading, + onReflectRefetch, + onStreaming, + onClientStreaming, + onServerStreaming, + onCommit, + onCancel, + onSend, + onUnary, +}: Props) { + const connections = useGrpcConnections(activeRequest.id ?? null); + const updateRequest = useUpdateGrpcRequest(activeRequest?.id ?? null); + const activeConnection = connections[0] ?? null; + const isStreaming = activeConnection?.elapsed === 0; + + const [paneSize, setPaneSize] = useState(99999); + const urlContainerEl = useRef(null); + useResizeObserver(urlContainerEl.current, (entry) => { + setPaneSize(entry.contentRect.width); + }); + + const handleChangeUrl = useCallback( + (url: string) => updateRequest.mutateAsync({ url }), + [updateRequest], + ); + + const handleChangeMessage = useCallback( + (message: string) => updateRequest.mutateAsync({ message }), + [updateRequest], + ); + + const select = useMemo(() => { + const options = + services?.flatMap((s) => + s.methods.map((m) => ({ + label: `${s.name.split('.', 2).pop() ?? s.name}/${m.name}`, + value: `${s.name}/${m.name}`, + })), + ) ?? []; + const value = `${activeRequest?.service ?? ''}/${activeRequest?.method ?? ''}`; + return { value, options }; + }, [activeRequest?.method, activeRequest?.service, services]); + + const handleChangeService = useCallback( + async (v: string) => { + const [serviceName, methodName] = v.split('/', 2); + if (serviceName == null || methodName == null) throw new Error('Should never happen'); + await updateRequest.mutateAsync({ + service: serviceName, + method: methodName, + }); + }, + [updateRequest], + ); + + const handleConnect = useCallback( + async (e: FormEvent) => { + e.preventDefault(); + if (activeRequest == null) return; + + if (activeRequest.service == null || activeRequest.method == null) { + alert({ + id: 'grpc-invalid-service-method', + title: 'Error', + body: 'Service or method not selected', + }); + } + if (methodType === 'streaming') { + onStreaming(); + } else if (methodType === 'server_streaming') { + onServerStreaming(); + } else if (methodType === 'client_streaming') { + onClientStreaming(); + } else { + onUnary(); + } + }, + [activeRequest, methodType, onStreaming, onServerStreaming, onClientStreaming, onUnary], + ); + + return ( + +
+ + + ({ + label: o.label, + value: o.value, + type: 'default', + shortLabel: o.label, + }))} + extraItems={[ + { + label: 'Custom', + type: 'default', + key: 'custom', + }, + ]} + > + + + {!isStreaming && ( + + )} + {isStreaming && ( + + )} + {methodType === 'client_streaming' && isStreaming && ( + + )} + {(methodType === 'client_streaming' || methodType === 'streaming') && isStreaming && ( + onSend({ message: activeRequest.message ?? '' })} + icon="sendHorizontal" + /> + )} + +
+ +
+ ); +} diff --git a/src-web/components/GrpcEditor.tsx b/src-web/components/GrpcEditor.tsx index 771cf3e0..e5d264b1 100644 --- a/src-web/components/GrpcEditor.tsx +++ b/src-web/components/GrpcEditor.tsx @@ -1,15 +1,16 @@ import type { EditorView } from 'codemirror'; import { updateSchema } from 'codemirror-json-schema'; import { useEffect, useRef } from 'react'; -import { useActiveRequestId } from '../hooks/useActiveRequestId'; import { useAlert } from '../hooks/useAlert'; -import { useGrpc } from '../hooks/useGrpc'; +import type { ReflectResponseService } from '../hooks/useGrpc'; import { tryFormatJson } from '../lib/formatters'; +import { Button } from './core/Button'; import type { EditorProps } from './core/Editor'; import { Editor } from './core/Editor'; import { FormattedError } from './core/FormattedError'; import { InlineCode } from './core/InlineCode'; -import { VStack } from './core/Stacks'; +import { HStack, VStack } from './core/Stacks'; +import { useDialog } from './DialogContext'; type Props = Pick< EditorProps, @@ -18,17 +19,30 @@ type Props = Pick< url: string; service: string | null; method: string | null; + services: ReflectResponseService[] | null; + reflectionError?: string; + reflectionLoading?: boolean; + onReflect: () => void; }; -export function GrpcEditor({ url, service, method, defaultValue, ...extraEditorProps }: Props) { +export function GrpcEditor({ + service, + method, + services, + defaultValue, + reflectionError, + reflectionLoading, + onReflect, + ...extraEditorProps +}: Props) { const editorViewRef = useRef(null); - const activeRequestId = useActiveRequestId(); - const grpc = useGrpc(url, activeRequestId); const alert = useAlert(); + const dialog = useDialog(); useEffect(() => { - if (editorViewRef.current == null || grpc.services == null) return; - const s = grpc.services?.find((s) => s.name === service); + if (editorViewRef.current == null || services === null) return; + + const s = services?.find((s) => s.name === service); if (service != null && s == null) { alert({ id: 'grpc-find-service-error', @@ -79,7 +93,7 @@ export function GrpcEditor({ url, service, method, defaultValue, ...extraEditorP }); console.log('Failed to parse method schema', method, schema); } - }, [alert, grpc.services, method, service]); + }, [alert, services, method, service]); return (
@@ -90,6 +104,46 @@ export function GrpcEditor({ url, service, method, defaultValue, ...extraEditorP heightMode="auto" placeholder="..." ref={editorViewRef} + actions={ + reflectionError || reflectionLoading + ? [ +
+ + + + + ), + }); + }} + > + {reflectionError ? 'Reflection Failed' : 'Reflecting'} + +
, + ] + : [] + } {...extraEditorProps} />
diff --git a/src-web/components/Sidebar.tsx b/src-web/components/Sidebar.tsx index c381bb05..dfe12338 100644 --- a/src-web/components/Sidebar.tsx +++ b/src-web/components/Sidebar.tsx @@ -755,12 +755,14 @@ const SidebarItem = forwardRef(function SidebarItem( )} {latestGrpcConnection ? (
- {latestGrpcConnection.elapsed === 0 && } + {latestGrpcConnection.elapsed === 0 && ( + + )}
) : latestHttpResponse ? (
{isResponseLoading(latestHttpResponse) ? ( - + ) : ( )} diff --git a/src-web/components/core/Button.tsx b/src-web/components/core/Button.tsx index d45dcc50..60bd65be 100644 --- a/src-web/components/core/Button.tsx +++ b/src-web/components/core/Button.tsx @@ -8,6 +8,7 @@ import { Icon } from './Icon'; export type ButtonProps = Omit, 'color'> & { innerClassName?: string; color?: 'custom' | 'default' | 'gray' | 'primary' | 'secondary' | 'warning' | 'danger'; + variant?: 'border' | 'solid'; isLoading?: boolean; size?: 'sm' | 'md' | 'xs'; justify?: 'start' | 'center'; @@ -27,10 +28,11 @@ export const Button = forwardRef(function Button innerClassName, children, forDropdown, - color, + color = 'default', type = 'button', justify = 'center', size = 'md', + variant = 'solid', leftSlot, rightSlot, disabled, @@ -53,24 +55,53 @@ export const Button = forwardRef(function Button 'flex-shrink-0 flex items-center', 'focus-visible-or-class:ring rounded-md', disabled ? 'pointer-events-none opacity-disabled' : 'pointer-events-auto', - color === 'custom' && 'ring-blue-500/50', - color === 'default' && - 'text-gray-700 enabled:hocus:bg-gray-700/10 enabled:hocus:text-gray-1000 ring-blue-500/50', - color === 'gray' && - 'text-gray-800 bg-highlight enabled:hocus:bg-gray-500/20 enabled:hocus:text-gray-1000 ring-blue-500/50', - color === 'primary' && 'bg-blue-400 text-white enabled:hocus:bg-blue-500 ring-blue-500/50', - color === 'secondary' && - 'bg-violet-400 text-white enabled:hocus:bg-violet-500 ring-violet-500/50', - color === 'warning' && - 'bg-orange-400 text-white enabled:hocus:bg-orange-500 ring-orange-500/50', - color === 'danger' && 'bg-red-400 text-white enabled:hocus:bg-red-500 ring-red-500/50', 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', + variant === 'border' && 'border', + // Solids + variant === 'solid' && color === 'custom' && 'ring-blue-500/50', + variant === 'solid' && + color === 'default' && + 'text-gray-700 enabled:hocus:bg-gray-700/10 enabled:hocus:text-gray-1000 ring-blue-500/50', + variant === 'solid' && + color === 'gray' && + 'text-gray-800 bg-highlight enabled:hocus:bg-gray-500/20 enabled:hocus:text-gray-1000 ring-blue-500/50', + variant === 'solid' && + color === 'primary' && + 'bg-blue-400 text-white enabled:hocus:bg-blue-500 ring-blue-500/50', + variant === 'solid' && + color === 'secondary' && + 'bg-violet-400 text-white enabled:hocus:bg-violet-500 ring-violet-500/50', + variant === 'solid' && + color === 'warning' && + 'bg-orange-400 text-white enabled:hocus:bg-orange-500 ring-orange-500/50', + variant === 'solid' && + color === 'danger' && + 'bg-red-400 text-white enabled:hocus:bg-red-500 ring-red-500/50', + // Borders + variant === 'border' && + color === 'default' && + 'border-highlight text-gray-700 enabled:hocus:border-focus enabled:hocus:text-gray-1000 ring-blue-500/50', + variant === 'border' && + color === 'gray' && + 'border-highlight enabled:hocus:bg-gray-500/20 enabled:hocus:text-gray-1000 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', ), - [className, disabled, color, justify, size], + [className, disabled, justify, size, variant, color], ); const buttonRef = useRef(null); diff --git a/src-web/components/core/FormattedError.tsx b/src-web/components/core/FormattedError.tsx index 97823fb3..be9f456f 100644 --- a/src-web/components/core/FormattedError.tsx +++ b/src-web/components/core/FormattedError.tsx @@ -1,7 +1,8 @@ import classNames from 'classnames'; +import type { ReactNode } from 'react'; interface Props { - children: string; + children: ReactNode; } export function FormattedError({ children }: Props) { diff --git a/src-web/hooks/useGrpc.ts b/src-web/hooks/useGrpc.ts index 7bc28176..7da81aa3 100644 --- a/src-web/hooks/useGrpc.ts +++ b/src-web/hooks/useGrpc.ts @@ -1,9 +1,10 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { invoke } from '@tauri-apps/api'; import { emit } from '@tauri-apps/api/event'; +import { useCallback } from 'react'; import type { GrpcConnection, GrpcMessage, GrpcRequest } from '../lib/models'; -interface ReflectResponseService { +export interface ReflectResponseService { name: string; methods: { name: string; schema: string; serverStreaming: boolean; clientStreaming: boolean }[]; } @@ -13,58 +14,46 @@ export function useGrpc(req: GrpcRequest | null, conn: GrpcConnection | null) { const unary = useMutation({ mutationKey: ['grpc_unary', conn?.id ?? 'n/a'], - mutationFn: async () => { - const message = (await invoke('cmd_grpc_call_unary', { + mutationFn: async () => + (await invoke('cmd_grpc_call_unary', { requestId, - })) as GrpcMessage; - return message; - }, + })) as GrpcMessage, }); const clientStreaming = useMutation({ mutationKey: ['grpc_client_streaming', conn?.id ?? 'n/a'], - mutationFn: async () => { - await invoke('cmd_grpc_client_streaming', { requestId }); - }, + mutationFn: async () => await invoke('cmd_grpc_client_streaming', { requestId }), }); const serverStreaming = useMutation({ mutationKey: ['grpc_server_streaming', conn?.id ?? 'n/a'], - mutationFn: async () => { - await invoke('cmd_grpc_server_streaming', { requestId }); - }, + mutationFn: async () => await invoke('cmd_grpc_server_streaming', { requestId }), }); const streaming = useMutation({ mutationKey: ['grpc_streaming', conn?.id ?? 'n/a'], - mutationFn: async () => { - await invoke('cmd_grpc_streaming', { requestId }); - }, + mutationFn: async () => await invoke('cmd_grpc_streaming', { requestId }), }); const send = useMutation({ - mutationFn: async ({ message }: { message: string }) => { - await emit(`grpc_client_msg_${conn?.id ?? 'none'}`, { Message: message }); - }, + mutationFn: async ({ message }: { message: string }) => + await emit(`grpc_client_msg_${conn?.id ?? 'none'}`, { Message: message }), }); const cancel = useMutation({ mutationKey: ['grpc_cancel', conn?.id ?? 'n/a'], - mutationFn: async () => { - await emit(`grpc_client_msg_${conn?.id ?? 'none'}`, 'Cancel'); - }, + mutationFn: async () => await emit(`grpc_client_msg_${conn?.id ?? 'none'}`, 'Cancel'), }); const commit = useMutation({ mutationKey: ['grpc_commit', conn?.id ?? 'n/a'], - mutationFn: async () => { - await emit(`grpc_client_msg_${conn?.id ?? 'none'}`, 'Commit'); - }, + mutationFn: async () => await emit(`grpc_client_msg_${conn?.id ?? 'none'}`, 'Commit'), }); const reflect = useQuery({ - queryKey: ['grpc_reflect', conn?.id ?? 'n/a'], + queryKey: ['grpc_reflect', req?.url ?? 'n/a'], queryFn: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); return (await invoke('cmd_grpc_reflect', { requestId })) as ReflectResponseService[]; }, }); @@ -74,7 +63,7 @@ export function useGrpc(req: GrpcRequest | null, conn: GrpcConnection | null) { clientStreaming, serverStreaming, streaming, - services: reflect.data, + reflect, cancel, commit, isStreaming: conn?.elapsed === 0,