From 562a36d616f4fadd11e97844534fd1b4cb554206 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 6 Feb 2024 12:29:23 -0800 Subject: [PATCH] Proto selection UI/models --- ...041d9a55194bb97819225a2612fdeb60ad42.json} | 10 +- ...9c1746c81ca50d2c413e540b74c8c8e908b7.json} | 10 +- ...823d096e8cbcb9f06544ab245f41425335a25.json | 12 -- ...fc30eaeffeed6883e712bda4b4d6ca49cf740.json | 12 ++ src-tauri/grpc/Cargo.toml | 2 +- src-tauri/grpc/src/lib.rs | 31 ---- src-tauri/grpc/src/manager.rs | 60 +++++-- src-tauri/grpc/src/proto.rs | 17 +- .../migrations/20240206191206_grpc-protos.sql | 1 + src-tauri/src/main.rs | 15 +- src-tauri/src/models.rs | 16 +- src-web/components/GrpcConnectionLayout.tsx | 2 +- .../components/GrpcConnectionSetupPane.tsx | 14 +- src-web/components/GrpcEditor.tsx | 146 +++++++++++------- src-web/components/GrpcProtoSelection.tsx | 79 ++++++++++ src-web/components/SidebarActions.tsx | 9 +- src-web/components/core/Button.tsx | 6 +- src-web/components/core/InlineCode.tsx | 2 +- src-web/components/core/Link.tsx | 35 +++++ src-web/hooks/useGrpc.ts | 16 +- src-web/hooks/useGrpcRequest.ts | 7 + .../{useRequest.ts => useHttpRequest.ts} | 2 +- src-web/hooks/useKeyValue.ts | 1 + src-web/hooks/useUpdateAnyGrpcRequest.ts | 1 + src-web/hooks/useUpdateGrpcRequest.ts | 6 +- src-web/lib/minPromiseMillis.ts | 14 +- src-web/lib/models.ts | 1 + src-web/main.css | 9 ++ 28 files changed, 382 insertions(+), 154 deletions(-) rename src-tauri/.sqlx/{query-35c9607291ee400e7696393bec7f4aa254a2dd163c4f79ea368ac6ee5f74c365.json => query-7398403d3de2dc5c5b4b6392f083041d9a55194bb97819225a2612fdeb60ad42.json} (78%) rename src-tauri/.sqlx/{query-c5f9b12bca35fe65ae2a1625e7f09c7855ce107046113a45f6da2abcff41819f.json => query-761d27c3ec425c37ad9abe9c732a9c1746c81ca50d2c413e540b74c8c8e908b7.json} (78%) delete mode 100644 src-tauri/.sqlx/query-d2dc9a652fe08623d70b8b0a2e6823d096e8cbcb9f06544ab245f41425335a25.json create mode 100644 src-tauri/.sqlx/query-ee562f85ec28c554c607adde670fc30eaeffeed6883e712bda4b4d6ca49cf740.json create mode 100644 src-tauri/migrations/20240206191206_grpc-protos.sql create mode 100644 src-web/components/GrpcProtoSelection.tsx create mode 100644 src-web/components/core/Link.tsx create mode 100644 src-web/hooks/useGrpcRequest.ts rename src-web/hooks/{useRequest.ts => useHttpRequest.ts} (73%) diff --git a/src-tauri/.sqlx/query-35c9607291ee400e7696393bec7f4aa254a2dd163c4f79ea368ac6ee5f74c365.json b/src-tauri/.sqlx/query-7398403d3de2dc5c5b4b6392f083041d9a55194bb97819225a2612fdeb60ad42.json similarity index 78% rename from src-tauri/.sqlx/query-35c9607291ee400e7696393bec7f4aa254a2dd163c4f79ea368ac6ee5f74c365.json rename to src-tauri/.sqlx/query-7398403d3de2dc5c5b4b6392f083041d9a55194bb97819225a2612fdeb60ad42.json index 2c2ac7a0..15e4f800 100644 --- a/src-tauri/.sqlx/query-35c9607291ee400e7696393bec7f4aa254a2dd163c4f79ea368ac6ee5f74c365.json +++ b/src-tauri/.sqlx/query-7398403d3de2dc5c5b4b6392f083041d9a55194bb97819225a2612fdeb60ad42.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n id, model, workspace_id, folder_id, created_at, updated_at, name, sort_priority,\n url, service, method, message\n FROM grpc_requests\n WHERE workspace_id = ?\n ", + "query": "\n SELECT\n id, model, workspace_id, folder_id, created_at, updated_at, name, sort_priority,\n url, service, method, message,\n proto_files AS \"proto_files!: sqlx::types::Json>\"\n FROM grpc_requests\n WHERE id = ?\n ", "describe": { "columns": [ { @@ -62,6 +62,11 @@ "name": "message", "ordinal": 11, "type_info": "Text" + }, + { + "name": "proto_files!: sqlx::types::Json>", + "ordinal": 12, + "type_info": "Text" } ], "parameters": { @@ -79,8 +84,9 @@ false, true, true, + false, false ] }, - "hash": "35c9607291ee400e7696393bec7f4aa254a2dd163c4f79ea368ac6ee5f74c365" + "hash": "7398403d3de2dc5c5b4b6392f083041d9a55194bb97819225a2612fdeb60ad42" } diff --git a/src-tauri/.sqlx/query-c5f9b12bca35fe65ae2a1625e7f09c7855ce107046113a45f6da2abcff41819f.json b/src-tauri/.sqlx/query-761d27c3ec425c37ad9abe9c732a9c1746c81ca50d2c413e540b74c8c8e908b7.json similarity index 78% rename from src-tauri/.sqlx/query-c5f9b12bca35fe65ae2a1625e7f09c7855ce107046113a45f6da2abcff41819f.json rename to src-tauri/.sqlx/query-761d27c3ec425c37ad9abe9c732a9c1746c81ca50d2c413e540b74c8c8e908b7.json index 8b9420e2..c24f110b 100644 --- a/src-tauri/.sqlx/query-c5f9b12bca35fe65ae2a1625e7f09c7855ce107046113a45f6da2abcff41819f.json +++ b/src-tauri/.sqlx/query-761d27c3ec425c37ad9abe9c732a9c1746c81ca50d2c413e540b74c8c8e908b7.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n id, model, workspace_id, folder_id, created_at, updated_at, name, sort_priority,\n url, service, method, message\n FROM grpc_requests\n WHERE id = ?\n ", + "query": "\n SELECT\n id, model, workspace_id, folder_id, created_at, updated_at, name, sort_priority,\n url, service, method, message,\n proto_files AS \"proto_files!: sqlx::types::Json>\"\n FROM grpc_requests\n WHERE workspace_id = ?\n ", "describe": { "columns": [ { @@ -62,6 +62,11 @@ "name": "message", "ordinal": 11, "type_info": "Text" + }, + { + "name": "proto_files!: sqlx::types::Json>", + "ordinal": 12, + "type_info": "Text" } ], "parameters": { @@ -79,8 +84,9 @@ false, true, true, + false, false ] }, - "hash": "c5f9b12bca35fe65ae2a1625e7f09c7855ce107046113a45f6da2abcff41819f" + "hash": "761d27c3ec425c37ad9abe9c732a9c1746c81ca50d2c413e540b74c8c8e908b7" } diff --git a/src-tauri/.sqlx/query-d2dc9a652fe08623d70b8b0a2e6823d096e8cbcb9f06544ab245f41425335a25.json b/src-tauri/.sqlx/query-d2dc9a652fe08623d70b8b0a2e6823d096e8cbcb9f06544ab245f41425335a25.json deleted file mode 100644 index bbce1939..00000000 --- a/src-tauri/.sqlx/query-d2dc9a652fe08623d70b8b0a2e6823d096e8cbcb9f06544ab245f41425335a25.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n INSERT INTO grpc_requests (\n id, name, workspace_id, folder_id, sort_priority, url, service, method, message\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n folder_id = excluded.folder_id,\n sort_priority = excluded.sort_priority,\n url = excluded.url,\n service = excluded.service,\n method = excluded.method,\n message = excluded.message\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 9 - }, - "nullable": [] - }, - "hash": "d2dc9a652fe08623d70b8b0a2e6823d096e8cbcb9f06544ab245f41425335a25" -} diff --git a/src-tauri/.sqlx/query-ee562f85ec28c554c607adde670fc30eaeffeed6883e712bda4b4d6ca49cf740.json b/src-tauri/.sqlx/query-ee562f85ec28c554c607adde670fc30eaeffeed6883e712bda4b4d6ca49cf740.json new file mode 100644 index 00000000..c1183863 --- /dev/null +++ b/src-tauri/.sqlx/query-ee562f85ec28c554c607adde670fc30eaeffeed6883e712bda4b4d6ca49cf740.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO grpc_requests (\n id, name, workspace_id, folder_id, sort_priority, url, service, method, message,\n proto_files\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n folder_id = excluded.folder_id,\n sort_priority = excluded.sort_priority,\n url = excluded.url,\n service = excluded.service,\n method = excluded.method,\n message = excluded.message,\n proto_files = excluded.proto_files\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 10 + }, + "nullable": [] + }, + "hash": "ee562f85ec28c554c607adde670fc30eaeffeed6883e712bda4b4d6ca49cf740" +} diff --git a/src-tauri/grpc/Cargo.toml b/src-tauri/grpc/Cargo.toml index 640896ed..3d1dda67 100644 --- a/src-tauri/grpc/Cargo.toml +++ b/src-tauri/grpc/Cargo.toml @@ -6,7 +6,7 @@ edition = "2021" [dependencies] tonic = "0.10.2" prost = "0.12" -tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1.0", features = ["macros", "rt-multi-thread", "fs"] } tonic-reflection = "0.10.2" tokio-stream = "0.1.14" prost-types = "0.12.3" diff --git a/src-tauri/grpc/src/lib.rs b/src-tauri/grpc/src/lib.rs index ada331cc..c2edc20e 100644 --- a/src-tauri/grpc/src/lib.rs +++ b/src-tauri/grpc/src/lib.rs @@ -1,8 +1,5 @@ use prost_reflect::SerializeOptions; use serde::{Deserialize, Serialize}; -use tonic::transport::Uri; - -use crate::proto::fill_pool; mod codec; mod json_schema; @@ -28,31 +25,3 @@ pub struct MethodDefinition { pub client_streaming: bool, pub server_streaming: bool, } - -pub async fn reflect(uri: &Uri) -> Result, String> { - let (pool, _) = fill_pool(uri).await?; - - Ok(pool - .services() - .map(|s| { - let mut def = ServiceDefinition { - name: s.full_name().to_string(), - methods: vec![], - }; - for method in s.methods() { - let input_message = method.input(); - def.methods.push(MethodDefinition { - name: method.name().to_string(), - server_streaming: method.is_server_streaming(), - client_streaming: method.is_client_streaming(), - schema: serde_json::to_string_pretty(&json_schema::message_to_json_schema( - &pool, - input_message, - )) - .unwrap(), - }) - } - def - }) - .collect::>()) -} diff --git a/src-tauri/grpc/src/manager.rs b/src-tauri/grpc/src/manager.rs index 057908c7..98031cca 100644 --- a/src-tauri/grpc/src/manager.rs +++ b/src-tauri/grpc/src/manager.rs @@ -6,7 +6,6 @@ use hyper_rustls::HttpsConnector; use prost_reflect::DescriptorPool; pub use prost_reflect::DynamicMessage; use serde_json::Deserializer; -use tokio::sync::mpsc; use tokio_stream::wrappers::ReceiverStream; use tokio_stream::StreamExt; use tonic::body::BoxBody; @@ -15,6 +14,7 @@ use tonic::{IntoRequest, IntoStreamingRequest, Streaming}; use crate::codec::DynamicCodec; use crate::proto::{fill_pool, method_desc_to_path}; +use crate::{json_schema, MethodDefinition, ServiceDefinition}; type Result = std::result::Result; @@ -145,25 +145,61 @@ impl GrpcConnection { } } -pub struct GrpcManager { +pub struct GrpcHandle { connections: HashMap, - pub send: mpsc::Sender, - pub recv: mpsc::Receiver, + pools: HashMap, } -impl Default for GrpcManager { +impl Default for GrpcHandle { fn default() -> Self { - let (send, recv) = mpsc::channel(100); let connections = HashMap::new(); - Self { - connections, - send, - recv, - } + let pools = HashMap::new(); + Self { connections, pools } } } -impl GrpcManager { +impl GrpcHandle { + pub async fn clean_reflect(&mut self, uri: &Uri) -> Result> { + self.pools.remove(&uri.to_string()); + self.reflect(uri).await + } + + pub async fn reflect(&mut self, uri: &Uri) -> Result> { + let pool = match self.pools.get(&uri.to_string()) { + Some(p) => p.clone(), + None => { + let (pool, _) = fill_pool(uri).await?; + self.pools.insert(uri.to_string(), pool.clone()); + pool + } + }; + + let result = + pool.services() + .map(|s| { + let mut def = ServiceDefinition { + name: s.full_name().to_string(), + methods: vec![], + }; + for method in s.methods() { + let input_message = method.input(); + def.methods.push(MethodDefinition { + name: method.name().to_string(), + server_streaming: method.is_server_streaming(), + client_streaming: method.is_client_streaming(), + schema: serde_json::to_string_pretty( + &json_schema::message_to_json_schema(&pool, input_message), + ) + .unwrap(), + }) + } + def + }) + .collect::>(); + + Ok(result) + } + pub async fn server_streaming( &mut self, id: &str, diff --git a/src-tauri/grpc/src/proto.rs b/src-tauri/grpc/src/proto.rs index c25f3480..c49a4e18 100644 --- a/src-tauri/grpc/src/proto.rs +++ b/src-tauri/grpc/src/proto.rs @@ -1,4 +1,5 @@ use std::ops::Deref; +use std::path::PathBuf; use std::str::FromStr; use anyhow::anyhow; @@ -9,17 +10,27 @@ use log::warn; use prost::Message; use prost_reflect::{DescriptorPool, MethodDescriptor}; use prost_types::FileDescriptorProto; +use tokio::fs; 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; use tonic_reflection::pb::server_reflection_response::MessageResponse; use tonic_reflection::pb::ServerReflectionRequest; +pub async fn fill_pool_from_files(paths: Vec) -> Result { + let mut pool = DescriptorPool::new(); + for p in paths { + let bytes = fs::read(p).await.unwrap(); + let fdp = FileDescriptorProto::decode(bytes.deref()).unwrap(); + pool.add_file_descriptor_proto(fdp) + .map_err(|e| e.to_string())?; + } + Ok(pool) +} pub async fn fill_pool( uri: &Uri, ) -> Result< @@ -155,7 +166,9 @@ async fn send_reflection_request( .server_reflection_info(request) .await .map_err(|e| match e.code() { - Unimplemented => "Reflection not implemented for server".to_string(), + tonic::Code::Unavailable => "Failed to connect to endpoint".to_string(), + tonic::Code::Unauthenticated => "Authentication failed".to_string(), + tonic::Code::DeadlineExceeded => "Deadline exceeded".to_string(), _ => e.to_string(), })? .into_inner() diff --git a/src-tauri/migrations/20240206191206_grpc-protos.sql b/src-tauri/migrations/20240206191206_grpc-protos.sql new file mode 100644 index 00000000..9ad50ba1 --- /dev/null +++ b/src-tauri/migrations/20240206191206_grpc-protos.sql @@ -0,0 +1 @@ +ALTER TABLE grpc_requests ADD COLUMN proto_files TEXT DEFAULT '[]' NOT NULL; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 71dbd751..4e54ab3a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -34,7 +34,7 @@ use tokio::sync::Mutex; use tokio::time::sleep; use window_shadows::set_shadow; -use grpc::manager::GrpcManager; +use grpc::manager::GrpcHandle; use grpc::ServiceDefinition; use window_ext::TrafficLightWindowExt; @@ -95,19 +95,20 @@ async fn migrate_db(app_handle: AppHandle, db: &Mutex>) -> Result<( async fn cmd_grpc_reflect( request_id: &str, app_handle: AppHandle, + grpc_handle: State<'_, Mutex>, ) -> Result, String> { let req = get_grpc_request(&app_handle, request_id) .await .map_err(|e| e.to_string())?; let uri = safe_uri(&req.url).map_err(|e| e.to_string())?; - grpc::reflect(&uri).await + grpc_handle.lock().await.clean_reflect(&uri).await } #[tauri::command] async fn cmd_grpc_call_unary( request_id: &str, app_handle: AppHandle, - grpc_handle: State<'_, Mutex>, + grpc_handle: State<'_, Mutex>, ) -> Result { let req = get_grpc_request(&app_handle, request_id) .await @@ -316,7 +317,7 @@ async fn cmd_grpc_client_streaming( let conn = conn.clone(); let req = req.clone(); async move { - let grpc_handle = app_handle.state::>(); + let grpc_handle = app_handle.state::>(); let msg = grpc_handle .lock() .await @@ -404,7 +405,7 @@ async fn cmd_grpc_client_streaming( async fn cmd_grpc_streaming( request_id: &str, app_handle: AppHandle, - grpc_handle: State<'_, Mutex>, + grpc_handle: State<'_, Mutex>, ) -> Result { let req = get_grpc_request(&app_handle, request_id) .await @@ -614,7 +615,7 @@ async fn cmd_grpc_streaming( async fn cmd_grpc_server_streaming( request_id: &str, app_handle: AppHandle, - grpc_handle: State<'_, Mutex>, + grpc_handle: State<'_, Mutex>, ) -> Result { let req = get_grpc_request(&app_handle, request_id) .await @@ -1627,7 +1628,7 @@ fn main() { app.manage(Mutex::new(yaak_updater)); // Add GRPC manager - let grpc_handle = GrpcManager::default(); + let grpc_handle = GrpcHandle::default(); app.manage(Mutex::new(grpc_handle)); // Add DB handle diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 0b702d4c..d288a387 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -204,6 +204,7 @@ pub struct GrpcRequest { pub service: Option, pub method: Option, pub message: String, + pub proto_files: Json>, } #[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)] @@ -497,9 +498,10 @@ pub async fn upsert_grpc_request( sqlx::query!( r#" INSERT INTO grpc_requests ( - id, name, workspace_id, folder_id, sort_priority, url, service, method, message + id, name, workspace_id, folder_id, sort_priority, url, service, method, message, + proto_files ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET updated_at = CURRENT_TIMESTAMP, name = excluded.name, @@ -508,7 +510,8 @@ pub async fn upsert_grpc_request( url = excluded.url, service = excluded.service, method = excluded.method, - message = excluded.message + message = excluded.message, + proto_files = excluded.proto_files "#, id, trimmed_name, @@ -519,6 +522,7 @@ pub async fn upsert_grpc_request( request.service, request.method, request.message, + request.proto_files, ) .execute(&db) .await?; @@ -539,7 +543,8 @@ pub async fn get_grpc_request( r#" SELECT id, model, workspace_id, folder_id, created_at, updated_at, name, sort_priority, - url, service, method, message + url, service, method, message, + proto_files AS "proto_files!: sqlx::types::Json>" FROM grpc_requests WHERE id = ? "#, @@ -559,7 +564,8 @@ pub async fn list_grpc_requests( r#" SELECT id, model, workspace_id, folder_id, created_at, updated_at, name, sort_priority, - url, service, method, message + url, service, method, message, + proto_files AS "proto_files!: sqlx::types::Json>" FROM grpc_requests WHERE workspace_id = ? "#, diff --git a/src-web/components/GrpcConnectionLayout.tsx b/src-web/components/GrpcConnectionLayout.tsx index 4217c889..10fdaa9e 100644 --- a/src-web/components/GrpcConnectionLayout.tsx +++ b/src-web/components/GrpcConnectionLayout.tsx @@ -90,7 +90,7 @@ export function GrpcConnectionLayout({ style }: Props) { onReflectRefetch={grpc.reflect.refetch} services={services ?? null} reflectionError={grpc.reflect.error as string | undefined} - reflectionLoading={grpc.reflect.isLoading} + reflectionLoading={grpc.reflect.isFetching} /> )} secondSlot={({ style }) => diff --git a/src-web/components/GrpcConnectionSetupPane.tsx b/src-web/components/GrpcConnectionSetupPane.tsx index 368fd263..7b193a6d 100644 --- a/src-web/components/GrpcConnectionSetupPane.tsx +++ b/src-web/components/GrpcConnectionSetupPane.tsx @@ -6,9 +6,7 @@ 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'; @@ -77,6 +75,11 @@ export function GrpcConnectionSetupPane({ [updateRequest], ); + const handleSelectProtoFiles = useCallback( + (paths: string[]) => updateRequest.mutateAsync({ protoFiles: paths }), + [updateRequest], + ); + const select = useMemo(() => { const options = services?.flatMap((s) => @@ -225,17 +228,14 @@ export function GrpcConnectionSetupPane({ ); diff --git a/src-web/components/GrpcEditor.tsx b/src-web/components/GrpcEditor.tsx index e5d264b1..189ad6a7 100644 --- a/src-web/components/GrpcEditor.tsx +++ b/src-web/components/GrpcEditor.tsx @@ -1,70 +1,72 @@ +import { open } from '@tauri-apps/api/dialog'; import type { EditorView } from 'codemirror'; import { updateSchema } from 'codemirror-json-schema'; import { useEffect, useRef } from 'react'; import { useAlert } from '../hooks/useAlert'; import type { ReflectResponseService } from '../hooks/useGrpc'; import { tryFormatJson } from '../lib/formatters'; +import type { GrpcRequest } from '../lib/models'; +import { count } from '../lib/pluralize'; +import { Banner } from './core/Banner'; 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 { Link } from './core/Link'; import { HStack, VStack } from './core/Stacks'; import { useDialog } from './DialogContext'; +import { GrpcProtoSelection } from './GrpcProtoSelection'; -type Props = Pick< - EditorProps, - 'heightMode' | 'onChange' | 'defaultValue' | 'className' | 'forceUpdateKey' -> & { - url: string; - service: string | null; - method: string | null; +type Props = Pick & { services: ReflectResponseService[] | null; reflectionError?: string; reflectionLoading?: boolean; + request: GrpcRequest; onReflect: () => void; + onSelectProtoFiles: (paths: string[]) => void; }; export function GrpcEditor({ - service, - method, services, - defaultValue, reflectionError, reflectionLoading, onReflect, + onSelectProtoFiles, + request, ...extraEditorProps }: Props) { const editorViewRef = useRef(null); const alert = useAlert(); const dialog = useDialog(); + // Find the schema for the selected service and method and update the editor useEffect(() => { if (editorViewRef.current == null || services === null) return; - const s = services?.find((s) => s.name === service); - if (service != null && s == null) { + const s = services.find((s) => s.name === request.service); + if (request.service != null && s == null) { alert({ id: 'grpc-find-service-error', title: "Couldn't Find Service", body: ( <> - Failed to find service {service} in schema + Failed to find service {request.service} in schema ), }); return; } - const schema = s?.methods.find((m) => m.name === method)?.schema; - if (method != null && schema == null) { + const schema = s?.methods.find((m) => m.name === request.method)?.schema; + if (request.method != null && schema == null) { alert({ id: 'grpc-find-schema-error', title: "Couldn't Find Method", body: ( <> - Failed to find method {method} for{' '} - {service} in schema + Failed to find method {request.method} for{' '} + {request.service} in schema ), }); @@ -84,66 +86,98 @@ export function GrpcEditor({ body: (

- For service {service} and method{' '} - {method} + For service {request.service} and method{' '} + {request.method}

{String(err)}
), }); - console.log('Failed to parse method schema', method, schema); } - }, [alert, services, method, service]); + }, [alert, services, request.method, request.service]); + + const reflectionUnavailable = reflectionError?.match(/unimplemented/i); + reflectionError = reflectionUnavailable ? undefined : reflectionError; return (
- + actions={[ +
+ - - - ), - }); - }} - > - {reflectionError ? 'Reflection Failed' : 'Reflecting'} - -
, - ] - : [] - } +
+ + + )} + + + ), + }); + }} + > + {reflectionLoading + ? 'Inspecting Schema' + : reflectionUnavailable + ? 'Select Proto Files' + : reflectionError + ? 'Server Error' + : services != null + ? 'Proto Schema' + : request.protoFiles.length > 0 + ? count('Proto File', request.protoFiles.length) + : 'Select Schema'} + + , + ]} {...extraEditorProps} /> diff --git a/src-web/components/GrpcProtoSelection.tsx b/src-web/components/GrpcProtoSelection.tsx new file mode 100644 index 00000000..1cb80c6d --- /dev/null +++ b/src-web/components/GrpcProtoSelection.tsx @@ -0,0 +1,79 @@ +import { open } from '@tauri-apps/api/dialog'; +import { useGrpcRequest } from '../hooks/useGrpcRequest'; +import { useUpdateGrpcRequest } from '../hooks/useUpdateGrpcRequest'; +import { Button } from './core/Button'; +import { IconButton } from './core/IconButton'; +import { HStack } from './core/Stacks'; + +export function GrpcProtoSelection({ requestId }: { requestId: string }) { + const request = useGrpcRequest(requestId); + const updateRequest = useUpdateGrpcRequest(request?.id ?? null); + + if (request == null) { + return null; + } + + return ( +
+ {request.protoFiles.length > 0 && ( + + + + + + + + + {request.protoFiles.map((f, i) => ( + + + + + ))} + +
+ *.proto Files +
{f.split('/').pop()} + { + updateRequest.mutate({ + protoFiles: request.protoFiles.filter((p) => p !== f), + }); + }} + /> +
+ )} + + + + +
+ ); +} diff --git a/src-web/components/SidebarActions.tsx b/src-web/components/SidebarActions.tsx index 421d26f9..79eb6589 100644 --- a/src-web/components/SidebarActions.tsx +++ b/src-web/components/SidebarActions.tsx @@ -1,4 +1,5 @@ import { memo } from 'react'; +import { Simulate } from 'react-dom/test-utils'; import { useCreateFolder } from '../hooks/useCreateFolder'; import { useCreateGrpcRequest } from '../hooks/useCreateGrpcRequest'; import { useCreateHttpRequest } from '../hooks/useCreateHttpRequest'; @@ -12,14 +13,18 @@ export const SidebarActions = memo(function SidebarActions() { const createHttpRequest = useCreateHttpRequest(); const createGrpcRequest = useCreateGrpcRequest(); const createFolder = useCreateFolder(); - const { hidden, toggle } = useSidebarHidden(); + const { hidden, show, hide } = useSidebarHidden(); return ( { trackEvent('Sidebar', 'Toggle'); - await toggle(); + + // NOTE: We're not using `toggle` because it may be out of sync + // from changes in other windows + if (hidden) await show(); + else await hide(); }} className="pointer-events-auto" size="sm" diff --git a/src-web/components/core/Button.tsx b/src-web/components/core/Button.tsx index 60bd65be..918d5aeb 100644 --- a/src-web/components/core/Button.tsx +++ b/src-web/components/core/Button.tsx @@ -60,7 +60,6 @@ export const Button = forwardRef(function Button 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' && @@ -82,12 +81,13 @@ export const Button = forwardRef(function Button color === 'danger' && 'bg-red-400 text-white enabled:hocus:bg-red-500 ring-red-500/50', // Borders + variant === 'border' && 'border', variant === 'border' && color === 'default' && - 'border-highlight text-gray-700 enabled:hocus:border-focus enabled:hocus:text-gray-1000 ring-blue-500/50', + 'border-highlight text-gray-700 enabled:hocus:border-focus enabled:hocus:text-gray-800 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', + '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', diff --git a/src-web/components/core/InlineCode.tsx b/src-web/components/core/InlineCode.tsx index 7c5ec681..e8de7953 100644 --- a/src-web/components/core/InlineCode.tsx +++ b/src-web/components/core/InlineCode.tsx @@ -6,7 +6,7 @@ export function InlineCode({ className, ...props }: HTMLAttributes { + href: string; +} + +export function Link({ href, children, className, ...other }: Props) { + const isExternal = href.match(/^https?:\/\//); + + className = classNames(className, 'relative underline hover:text-violet-600'); + + if (isExternal) { + return ( + + {children} + + + ); + } + + return ( + + {children} + + ); +} diff --git a/src-web/hooks/useGrpc.ts b/src-web/hooks/useGrpc.ts index 7da81aa3..9dc4946e 100644 --- a/src-web/hooks/useGrpc.ts +++ b/src-web/hooks/useGrpc.ts @@ -1,8 +1,9 @@ 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 { minPromiseMillis } from '../lib/minPromiseMillis'; import type { GrpcConnection, GrpcMessage, GrpcRequest } from '../lib/models'; +import { useDebouncedValue } from './useDebouncedValue'; export interface ReflectResponseService { name: string; @@ -50,11 +51,16 @@ export function useGrpc(req: GrpcRequest | null, conn: GrpcConnection | null) { mutationFn: async () => await emit(`grpc_client_msg_${conn?.id ?? 'none'}`, 'Commit'), }); - const reflect = useQuery({ - queryKey: ['grpc_reflect', req?.url ?? 'n/a'], + const debouncedUrl = useDebouncedValue(req?.url ?? 'n/a', 1000); + const reflect = useQuery({ + enabled: req != null && req.protoFiles.length === 0, + queryKey: ['grpc_reflect', debouncedUrl], queryFn: async () => { - await new Promise((resolve) => setTimeout(resolve, 1000)); - return (await invoke('cmd_grpc_reflect', { requestId })) as ReflectResponseService[]; + console.log('REFLECTING...'); + return (await minPromiseMillis( + invoke('cmd_grpc_reflect', { requestId }), + 1000, + )) as ReflectResponseService[]; }, }); diff --git a/src-web/hooks/useGrpcRequest.ts b/src-web/hooks/useGrpcRequest.ts new file mode 100644 index 00000000..be0789c5 --- /dev/null +++ b/src-web/hooks/useGrpcRequest.ts @@ -0,0 +1,7 @@ +import type { GrpcRequest } from '../lib/models'; +import { useGrpcRequests } from './useGrpcRequests'; + +export function useGrpcRequest(id: string | null): GrpcRequest | null { + const requests = useGrpcRequests(); + return requests.find((r) => r.id === id) ?? null; +} diff --git a/src-web/hooks/useRequest.ts b/src-web/hooks/useHttpRequest.ts similarity index 73% rename from src-web/hooks/useRequest.ts rename to src-web/hooks/useHttpRequest.ts index 7e93fbb1..c0b072ea 100644 --- a/src-web/hooks/useRequest.ts +++ b/src-web/hooks/useHttpRequest.ts @@ -1,7 +1,7 @@ import type { HttpRequest } from '../lib/models'; import { useHttpRequests } from './useHttpRequests'; -export function useRequest(id: string | null): HttpRequest | null { +export function useHttpRequest(id: string | null): HttpRequest | null { const requests = useHttpRequests(); return requests.find((r) => r.id === id) ?? null; } diff --git a/src-web/hooks/useKeyValue.ts b/src-web/hooks/useKeyValue.ts index aaa5561b..bdec4cab 100644 --- a/src-web/hooks/useKeyValue.ts +++ b/src-web/hooks/useKeyValue.ts @@ -28,6 +28,7 @@ export function useKeyValue({ const query = useQuery({ queryKey: keyValueQueryKey({ namespace, key }), queryFn: async () => getKeyValue({ namespace, key, fallback: defaultValue }), + refetchOnWindowFocus: false, }); const mutate = useMutation({ diff --git a/src-web/hooks/useUpdateAnyGrpcRequest.ts b/src-web/hooks/useUpdateAnyGrpcRequest.ts index 2a5c630c..2c8caa85 100644 --- a/src-web/hooks/useUpdateAnyGrpcRequest.ts +++ b/src-web/hooks/useUpdateAnyGrpcRequest.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { invoke } from '@tauri-apps/api'; import type { GrpcRequest } from '../lib/models'; +import { sleep } from '../lib/sleep'; import { getGrpcRequest } from '../lib/store'; import { grpcRequestsQueryKey } from './useGrpcRequests'; diff --git a/src-web/hooks/useUpdateGrpcRequest.ts b/src-web/hooks/useUpdateGrpcRequest.ts index da8bd89a..4f05f14e 100644 --- a/src-web/hooks/useUpdateGrpcRequest.ts +++ b/src-web/hooks/useUpdateGrpcRequest.ts @@ -3,8 +3,10 @@ import type { GrpcRequest } from '../lib/models'; import { useUpdateAnyGrpcRequest } from './useUpdateAnyGrpcRequest'; export function useUpdateGrpcRequest(id: string | null) { - const updateAnyRequest = useUpdateAnyGrpcRequest(); + const updateAnyGrpcRequest = useUpdateAnyGrpcRequest(); return useMutation | ((r: GrpcRequest) => GrpcRequest)>({ - mutationFn: async (update) => updateAnyRequest.mutateAsync({ id: id ?? 'n/a', update }), + mutationFn: async (update) => { + return updateAnyGrpcRequest.mutateAsync({ id: id ?? 'n/a', update }); + }, }); } diff --git a/src-web/lib/minPromiseMillis.ts b/src-web/lib/minPromiseMillis.ts index 8287a429..c9272a50 100644 --- a/src-web/lib/minPromiseMillis.ts +++ b/src-web/lib/minPromiseMillis.ts @@ -1,9 +1,19 @@ import { sleep } from './sleep'; +/** Ensures a promise takes at least a certain number of milliseconds to resolve */ export async function minPromiseMillis(promise: Promise, millis: number) { const start = Date.now(); - const result = await promise; + let result; + let err; + + try { + result = await promise; + } catch (e) { + err = e; + } + const delayFor = millis - (Date.now() - start); await sleep(delayFor); - return result; + if (err) throw err; + else return result; } diff --git a/src-web/lib/models.ts b/src-web/lib/models.ts index 74b7a5b5..56acf0e7 100644 --- a/src-web/lib/models.ts +++ b/src-web/lib/models.ts @@ -114,6 +114,7 @@ export interface GrpcRequest extends BaseModel { service: string | null; method: string | null; message: string; + protoFiles: string[]; } export interface GrpcMessage extends BaseModel { diff --git a/src-web/main.css b/src-web/main.css index 2eb0a0e2..cf0c5588 100644 --- a/src-web/main.css +++ b/src-web/main.css @@ -28,6 +28,15 @@ @apply select-none cursor-default; } + a, + a * { + @apply cursor-pointer !important; + } + + table th { + @apply text-left; + } + .hide-scrollbars { &::-webkit-scrollbar-corner, &::-webkit-scrollbar {