Proto selection UI/models

This commit is contained in:
Gregory Schier
2024-02-06 12:29:23 -08:00
parent 8309c19167
commit 1293870e11
28 changed files with 382 additions and 154 deletions

View File

@@ -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<Vec<String>>\"\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<Vec<String>>",
"ordinal": 12,
"type_info": "Text"
}
],
"parameters": {
@@ -79,8 +84,9 @@
false,
true,
true,
false,
false
]
},
"hash": "35c9607291ee400e7696393bec7f4aa254a2dd163c4f79ea368ac6ee5f74c365"
"hash": "7398403d3de2dc5c5b4b6392f083041d9a55194bb97819225a2612fdeb60ad42"
}

View File

@@ -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<Vec<String>>\"\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<Vec<String>>",
"ordinal": 12,
"type_info": "Text"
}
],
"parameters": {
@@ -79,8 +84,9 @@
false,
true,
true,
false,
false
]
},
"hash": "c5f9b12bca35fe65ae2a1625e7f09c7855ce107046113a45f6da2abcff41819f"
"hash": "761d27c3ec425c37ad9abe9c732a9c1746c81ca50d2c413e540b74c8c8e908b7"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"

View File

@@ -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<Vec<ServiceDefinition>, 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::<Vec<_>>())
}

View File

@@ -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<T> = std::result::Result<T, String>;
@@ -145,25 +145,61 @@ impl GrpcConnection {
}
}
pub struct GrpcManager {
pub struct GrpcHandle {
connections: HashMap<String, GrpcConnection>,
pub send: mpsc::Sender<String>,
pub recv: mpsc::Receiver<String>,
pools: HashMap<String, DescriptorPool>,
}
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<Vec<ServiceDefinition>> {
self.pools.remove(&uri.to_string());
self.reflect(uri).await
}
pub async fn reflect(&mut self, uri: &Uri) -> Result<Vec<ServiceDefinition>> {
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::<Vec<_>>();
Ok(result)
}
pub async fn server_streaming(
&mut self,
id: &str,

View File

@@ -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<PathBuf>) -> Result<DescriptorPool, String> {
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()

View File

@@ -0,0 +1 @@
ALTER TABLE grpc_requests ADD COLUMN proto_files TEXT DEFAULT '[]' NOT NULL;

View File

@@ -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<Pool<Sqlite>>) -> Result<(
async fn cmd_grpc_reflect(
request_id: &str,
app_handle: AppHandle,
grpc_handle: State<'_, Mutex<GrpcHandle>>,
) -> Result<Vec<ServiceDefinition>, 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<GrpcManager>>,
grpc_handle: State<'_, Mutex<GrpcHandle>>,
) -> Result<GrpcMessage, String> {
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::<Mutex<GrpcManager>>();
let grpc_handle = app_handle.state::<Mutex<GrpcHandle>>();
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<GrpcManager>>,
grpc_handle: State<'_, Mutex<GrpcHandle>>,
) -> Result<String, String> {
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<GrpcManager>>,
grpc_handle: State<'_, Mutex<GrpcHandle>>,
) -> Result<GrpcConnection, String> {
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

View File

@@ -204,6 +204,7 @@ pub struct GrpcRequest {
pub service: Option<String>,
pub method: Option<String>,
pub message: String,
pub proto_files: Json<Vec<String>>,
}
#[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<Vec<String>>"
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<Vec<String>>"
FROM grpc_requests
WHERE workspace_id = ?
"#,

View File

@@ -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 }) =>

View File

@@ -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({
</HStack>
</div>
<GrpcEditor
forceUpdateKey={activeRequest?.id ?? ''}
url={activeRequest.url ?? ''}
defaultValue={activeRequest.message}
onChange={handleChangeMessage}
service={activeRequest.service}
services={services}
method={activeRequest.method}
className="bg-gray-50"
reflectionError={reflectionError}
reflectionLoading={reflectionLoading}
onReflect={onReflectRefetch}
onSelectProtoFiles={handleSelectProtoFiles}
request={activeRequest}
/>
</VStack>
);

View File

@@ -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<EditorProps, 'heightMode' | 'onChange' | 'className'> & {
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<EditorView>(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 <InlineCode>{service}</InlineCode> in schema
Failed to find service <InlineCode>{request.service}</InlineCode> 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 <InlineCode>{method}</InlineCode> for{' '}
<InlineCode>{service}</InlineCode> in schema
Failed to find method <InlineCode>{request.method}</InlineCode> for{' '}
<InlineCode>{request.service}</InlineCode> in schema
</>
),
});
@@ -84,66 +86,98 @@ export function GrpcEditor({
body: (
<VStack space={4}>
<p>
For service <InlineCode>{service}</InlineCode> and method{' '}
<InlineCode>{method}</InlineCode>
For service <InlineCode>{request.service}</InlineCode> and method{' '}
<InlineCode>{request.method}</InlineCode>
</p>
<FormattedError>{String(err)}</FormattedError>
</VStack>
),
});
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 (
<div className="h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
<Editor
contentType="application/grpc"
defaultValue={defaultValue}
forceUpdateKey={request.id}
defaultValue={request.message}
format={tryFormatJson}
heightMode="auto"
placeholder="..."
ref={editorViewRef}
actions={
reflectionError || reflectionLoading
? [
<div key="introspection" className="!opacity-100">
<Button
key="introspection"
size="xs"
color={reflectionError ? 'danger' : 'gray'}
isLoading={reflectionLoading}
onClick={() => {
dialog.show({
title: 'Introspection Failed',
size: 'dynamic',
id: 'introspection-failed',
render: () => (
<>
<FormattedError>{reflectionError ?? 'unknown'}</FormattedError>
<HStack className="w-full my-4" space={2} justifyContent="end">
<Button color="gray">Select .proto</Button>
actions={[
<div key="reflection" className="!opacity-100">
<Button
size="xs"
color={
reflectionLoading
? 'gray'
: reflectionUnavailable
? 'secondary'
: reflectionError
? 'danger'
: 'gray'
}
isLoading={reflectionLoading}
onClick={() => {
dialog.show({
title: 'Configure Schema',
size: 'md',
id: 'reflection-failed',
render: ({ hide }) => (
<VStack space={6} className="pb-5">
{reflectionError && <FormattedError>{reflectionError}</FormattedError>}
{reflectionUnavailable && request.protoFiles.length === 0 && (
<Banner>
<VStack space={3}>
<p>
<InlineCode>{request.url}</InlineCode> doesn&apos;t implement{' '}
<Link href="https://github.com/grpc/grpc/blob/9aa3c5835a4ed6afae9455b63ed45c761d695bca/doc/server-reflection.md">
Server Reflection
</Link>{' '}
. Please manually add the <InlineCode>.proto</InlineCode> files to get
started.
</p>
<div>
<Button
size="xs"
color="gray"
variant="border"
onClick={() => {
dialog.hide('introspection-failed');
hide();
onReflect();
}}
color="secondary"
>
Try Again
Retry Reflection
</Button>
</HStack>
</>
),
});
}}
>
{reflectionError ? 'Reflection Failed' : 'Reflecting'}
</Button>
</div>,
]
: []
}
</div>
</VStack>
</Banner>
)}
<GrpcProtoSelection requestId={request.id} />
</VStack>
),
});
}}
>
{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'}
</Button>
</div>,
]}
{...extraEditorProps}
/>
</div>

View File

@@ -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 (
<div>
{request.protoFiles.length > 0 && (
<table className="w-full divide-y mb-3">
<thead>
<tr>
<th className="text-gray-600">
<span className="font-mono text-sm">*.proto</span> Files
</th>
<th></th>
</tr>
</thead>
<tbody className="divide-y">
{request.protoFiles.map((f, i) => (
<tr key={f + i} className="group">
<td className="pl-1 text-sm 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"
onClick={() => {
updateRequest.mutate({
protoFiles: request.protoFiles.filter((p) => p !== f),
});
}}
/>
</td>
</tr>
))}
</tbody>
</table>
)}
<HStack space={2} justifyContent="end">
<Button
color="gray"
size="sm"
onClick={async () => {
updateRequest.mutate({ protoFiles: [] });
}}
>
Clear Files
</Button>
<Button
color="primary"
size="sm"
onClick={async () => {
const files = await open({
title: 'Select Proto Files',
multiple: true,
filters: [{ name: 'Proto Files', extensions: ['proto'] }],
});
if (files == null || typeof files === 'string') return;
const newFiles = files.filter((f) => !request.protoFiles.includes(f));
updateRequest.mutate({ protoFiles: [...request.protoFiles, ...newFiles] });
}}
>
Add Files
</Button>
</HStack>
</div>
);
}

View File

@@ -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 (
<HStack>
<IconButton
onClick={async () => {
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"

View File

@@ -60,7 +60,6 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(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<HTMLButtonElement, ButtonProps>(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',

View File

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

View File

@@ -0,0 +1,35 @@
import classNames from 'classnames';
import type { HTMLAttributes } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { Icon } from './Icon';
interface Props extends HTMLAttributes<HTMLAnchorElement> {
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 (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={classNames(className, 'pr-4')}
{...other}
>
<span className="underline">{children}</span>
<Icon className="inline absolute right-0.5 top-0.5" size="xs" icon="externalLink" />
</a>
);
}
return (
<RouterLink to={href} className={className} {...other}>
{children}
</RouterLink>
);
}

View File

@@ -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<ReflectResponseService[]>({
queryKey: ['grpc_reflect', req?.url ?? 'n/a'],
const debouncedUrl = useDebouncedValue<string>(req?.url ?? 'n/a', 1000);
const reflect = useQuery<ReflectResponseService[] | null>({
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[];
},
});

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -28,6 +28,7 @@ export function useKeyValue<T extends Object | null>({
const query = useQuery<T>({
queryKey: keyValueQueryKey({ namespace, key }),
queryFn: async () => getKeyValue({ namespace, key, fallback: defaultValue }),
refetchOnWindowFocus: false,
});
const mutate = useMutation<void, unknown, T>({

View File

@@ -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';

View File

@@ -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<void, unknown, Partial<GrpcRequest> | ((r: GrpcRequest) => GrpcRequest)>({
mutationFn: async (update) => updateAnyRequest.mutateAsync({ id: id ?? 'n/a', update }),
mutationFn: async (update) => {
return updateAnyGrpcRequest.mutateAsync({ id: id ?? 'n/a', update });
},
});
}

View File

@@ -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<T>(promise: Promise<T>, 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;
}

View File

@@ -114,6 +114,7 @@ export interface GrpcRequest extends BaseModel {
service: string | null;
method: string | null;
message: string;
protoFiles: string[];
}
export interface GrpcMessage extends BaseModel {

View File

@@ -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 {