mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-01-11 20:00:29 +01:00
Hacky server streaming done
This commit is contained in:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -30,6 +30,7 @@
|
||||
"codemirror": "^6.0.1",
|
||||
"codemirror-json-schema": "^0.6.1",
|
||||
"codemirror-json5": "^1.0.3",
|
||||
"date-fns": "^3.3.1",
|
||||
"focus-trap-react": "^10.1.1",
|
||||
"format-graphql": "^1.4.0",
|
||||
"framer-motion": "^9.0.4",
|
||||
@@ -3805,6 +3806,15 @@
|
||||
"resolved": "https://registry.npmjs.org/dataloader/-/dataloader-1.4.0.tgz",
|
||||
"integrity": "sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw=="
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.3.1.tgz",
|
||||
"integrity": "sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"codemirror": "^6.0.1",
|
||||
"codemirror-json-schema": "^0.6.1",
|
||||
"codemirror-json5": "^1.0.3",
|
||||
"date-fns": "^3.3.1",
|
||||
"focus-trap-react": "^10.1.1",
|
||||
"format-graphql": "^1.4.0",
|
||||
"framer-motion": "^9.0.4",
|
||||
|
||||
@@ -22,8 +22,7 @@ pub struct JsonSchemaEntry {
|
||||
enum_: Option<Vec<String>>,
|
||||
|
||||
/// Don't allow any other properties in the object
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
additional_properties: Option<bool>,
|
||||
additional_properties: bool,
|
||||
|
||||
/// Set all properties to required
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use prost_reflect::DynamicMessage;
|
||||
use prost::Message;
|
||||
use prost_reflect::{DynamicMessage, SerializeOptions};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Deserializer;
|
||||
use tonic::IntoRequest;
|
||||
use tokio_stream::{Stream, StreamExt};
|
||||
use tonic::transport::Uri;
|
||||
use tonic::{IntoRequest, Response, Streaming};
|
||||
|
||||
use crate::codec::DynamicCodec;
|
||||
use crate::proto::{fill_pool, method_desc_to_path};
|
||||
@@ -11,19 +13,32 @@ mod codec;
|
||||
mod json_schema;
|
||||
mod proto;
|
||||
|
||||
pub fn serialize_options() -> SerializeOptions {
|
||||
SerializeOptions::new().skip_default_fields(false)
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ServiceDefinition {
|
||||
pub name: String,
|
||||
pub methods: Vec<MethodDefinition>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct MethodDefinition {
|
||||
pub name: String,
|
||||
pub schema: String,
|
||||
pub client_streaming: bool,
|
||||
pub server_streaming: bool,
|
||||
}
|
||||
|
||||
pub async fn call(uri: &Uri, service: &str, method: &str, message_json: &str) -> String {
|
||||
pub async fn unary(
|
||||
uri: &Uri,
|
||||
service: &str,
|
||||
method: &str,
|
||||
message_json: &str,
|
||||
) -> Result<String, String> {
|
||||
let (pool, conn) = fill_pool(uri).await;
|
||||
|
||||
let service = pool.get_service_by_name(service).unwrap();
|
||||
@@ -31,7 +46,8 @@ pub async fn call(uri: &Uri, service: &str, method: &str, message_json: &str) ->
|
||||
let input_message = method.input();
|
||||
|
||||
let mut deserializer = Deserializer::from_str(message_json);
|
||||
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer).unwrap();
|
||||
let req_message =
|
||||
DynamicMessage::deserialize(input_message, &mut deserializer).map_err(|e| e.to_string())?;
|
||||
deserializer.end().unwrap();
|
||||
|
||||
let mut client = tonic::client::Grpc::new(conn);
|
||||
@@ -47,10 +63,99 @@ pub async fn call(uri: &Uri, service: &str, method: &str, message_json: &str) ->
|
||||
client.ready().await.unwrap();
|
||||
|
||||
let resp = client.unary(req, path, codec).await.unwrap();
|
||||
let msg = resp.into_inner();
|
||||
let response_json = serde_json::to_string_pretty(&msg).expect("json to string");
|
||||
println!("\n---------- RECEIVING ---------------\n{}", response_json,);
|
||||
|
||||
Ok(response_json)
|
||||
}
|
||||
|
||||
struct ClientStream {}
|
||||
|
||||
impl Stream for ClientStream {
|
||||
type Item = DynamicMessage;
|
||||
|
||||
fn poll_next(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<Option<Self::Item>> {
|
||||
println!("poll_next");
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn client_streaming(
|
||||
uri: &Uri,
|
||||
service: &str,
|
||||
method: &str,
|
||||
message_json: &str,
|
||||
) -> Result<String, String> {
|
||||
let (pool, conn) = fill_pool(uri).await;
|
||||
|
||||
let service = pool.get_service_by_name(service).unwrap();
|
||||
let method = &service.methods().find(|m| m.name() == method).unwrap();
|
||||
let input_message = method.input();
|
||||
|
||||
let mut deserializer = Deserializer::from_str(message_json);
|
||||
let req_message =
|
||||
DynamicMessage::deserialize(input_message, &mut deserializer).map_err(|e| e.to_string())?;
|
||||
deserializer.end().unwrap();
|
||||
|
||||
let mut client = tonic::client::Grpc::new(conn);
|
||||
|
||||
println!(
|
||||
"\n---------- SENDING -----------------\n{}",
|
||||
serde_json::to_string_pretty(&req_message).expect("json")
|
||||
);
|
||||
|
||||
let req = tonic::Request::new(ClientStream {});
|
||||
|
||||
let path = method_desc_to_path(method);
|
||||
let codec = DynamicCodec::new(method.clone());
|
||||
client.ready().await.unwrap();
|
||||
|
||||
let resp = client.client_streaming(req, path, codec).await.unwrap();
|
||||
let response_json = serde_json::to_string_pretty(&resp.into_inner()).expect("json to string");
|
||||
println!("\n---------- RECEIVING ---------------\n{}", response_json,);
|
||||
|
||||
response_json
|
||||
Ok(response_json)
|
||||
}
|
||||
|
||||
pub async fn server_streaming(
|
||||
uri: &Uri,
|
||||
service: &str,
|
||||
method: &str,
|
||||
message_json: &str,
|
||||
) -> Result<Response<Streaming<DynamicMessage>>, String> {
|
||||
let (pool, conn) = fill_pool(uri).await;
|
||||
|
||||
let service = pool.get_service_by_name(service).unwrap();
|
||||
let method = &service.methods().find(|m| m.name() == method).unwrap();
|
||||
let input_message = method.input();
|
||||
|
||||
let mut deserializer = Deserializer::from_str(message_json);
|
||||
let req_message =
|
||||
DynamicMessage::deserialize(input_message, &mut deserializer).map_err(|e| e.to_string())?;
|
||||
deserializer.end().unwrap();
|
||||
|
||||
let mut client = tonic::client::Grpc::new(conn);
|
||||
|
||||
println!(
|
||||
"\n---------- SENDING -----------------\n{}",
|
||||
serde_json::to_string_pretty(&req_message).expect("json")
|
||||
);
|
||||
|
||||
let req = req_message.into_request();
|
||||
let path = method_desc_to_path(method);
|
||||
let codec = DynamicCodec::new(method.clone());
|
||||
client.ready().await.unwrap();
|
||||
|
||||
let resp = client.server_streaming(req, path, codec).await.unwrap();
|
||||
// let response_json = serde_json::to_string_pretty(&resp.into_inner()).expect("json to string");
|
||||
// println!("\n---------- RECEIVING ---------------\n{}", response_json,);
|
||||
|
||||
// Ok(response_json)
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
pub async fn callable(uri: &Uri) -> Vec<ServiceDefinition> {
|
||||
@@ -60,12 +165,14 @@ pub async fn callable(uri: &Uri) -> Vec<ServiceDefinition> {
|
||||
.map(|s| {
|
||||
let mut def = ServiceDefinition {
|
||||
name: s.full_name().to_string(),
|
||||
..Default::default()
|
||||
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,
|
||||
|
||||
@@ -8,32 +8,33 @@ extern crate core;
|
||||
#[macro_use]
|
||||
extern crate objc;
|
||||
|
||||
use ::http::Uri;
|
||||
use std::collections::HashMap;
|
||||
use std::env::current_dir;
|
||||
use std::fs::{create_dir_all, read_to_string, File};
|
||||
use std::fs::{create_dir_all, File, read_to_string};
|
||||
use std::process::exit;
|
||||
use std::str::FromStr;
|
||||
|
||||
use ::http::Uri;
|
||||
use fern::colors::ColoredLevelConfig;
|
||||
use grpc::ServiceDefinition;
|
||||
use futures::StreamExt;
|
||||
use log::{debug, error, info, warn};
|
||||
use rand::random;
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{Pool, Sqlite, SqlitePool};
|
||||
use sqlx::migrate::Migrator;
|
||||
use sqlx::types::Json;
|
||||
use sqlx::{Pool, Sqlite, SqlitePool};
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::TitleBarStyle;
|
||||
use tauri::{AppHandle, RunEvent, State, Window, WindowUrl, Wry};
|
||||
use tauri::{Manager, WindowEvent};
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::TitleBarStyle;
|
||||
use tauri_plugin_log::{fern, LogTarget};
|
||||
use tauri_plugin_window_state::{StateFlags, WindowExt};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::sleep;
|
||||
use window_shadows::set_shadow;
|
||||
|
||||
use grpc::ServiceDefinition;
|
||||
use window_ext::TrafficLightWindowExt;
|
||||
|
||||
use crate::analytics::{AnalyticsAction, AnalyticsResource};
|
||||
@@ -106,7 +107,59 @@ async fn grpc_call_unary(
|
||||
} else {
|
||||
Uri::from_str(&format!("http://{}", endpoint)).map_err(|e| e.to_string())?
|
||||
};
|
||||
Ok(grpc::call(&uri, service, method, message).await)
|
||||
grpc::unary(&uri, service, method, message).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn grpc_client_streaming(
|
||||
endpoint: &str,
|
||||
service: &str,
|
||||
method: &str,
|
||||
message: &str,
|
||||
// app_handle: AppHandle<Wry>,
|
||||
// db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<String, String> {
|
||||
let uri = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
|
||||
Uri::from_str(endpoint).map_err(|e| e.to_string())?
|
||||
} else {
|
||||
Uri::from_str(&format!("http://{}", endpoint)).map_err(|e| e.to_string())?
|
||||
};
|
||||
grpc::client_streaming(&uri, service, method, message).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn grpc_server_streaming(
|
||||
endpoint: &str,
|
||||
service: &str,
|
||||
method: &str,
|
||||
message: &str,
|
||||
app_handle: AppHandle<Wry>,
|
||||
// db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<String, String> {
|
||||
let uri = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
|
||||
Uri::from_str(endpoint).map_err(|e| e.to_string())?
|
||||
} else {
|
||||
Uri::from_str(&format!("http://{}", endpoint)).map_err(|e| e.to_string())?
|
||||
};
|
||||
|
||||
let mut stream = grpc::server_streaming(&uri, service, method, message)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
while let Some(item) = stream.next().await {
|
||||
match item {
|
||||
Ok(item) => {
|
||||
let s = serde_json::to_string(&item).unwrap();
|
||||
emit_side_effect(&app_handle, "grpc_message", s.clone());
|
||||
println!("GOt item: {}", s);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("\terror: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok("foo".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -937,6 +990,9 @@ fn main() {
|
||||
.level_for("reqwest", log::LevelFilter::Info)
|
||||
.level_for("tokio_util", log::LevelFilter::Info)
|
||||
.level_for("cookie_store", log::LevelFilter::Info)
|
||||
.level_for("h2", log::LevelFilter::Info)
|
||||
.level_for("tower", log::LevelFilter::Info)
|
||||
.level_for("tonic", log::LevelFilter::Info)
|
||||
.with_colors(ColoredLevelConfig::default())
|
||||
.level(log::LevelFilter::Trace)
|
||||
.build(),
|
||||
@@ -1012,6 +1068,8 @@ fn main() {
|
||||
get_settings,
|
||||
get_workspace,
|
||||
grpc_call_unary,
|
||||
grpc_client_streaming,
|
||||
grpc_server_streaming,
|
||||
grpc_reflect,
|
||||
import_data,
|
||||
list_cookie_jars,
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import type { Props } from 'focus-trap-react';
|
||||
import classNames from 'classnames';
|
||||
import type { CSSProperties, FormEvent } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useAlert } from '../hooks/useAlert';
|
||||
import { useGrpc } from '../hooks/useGrpc';
|
||||
import { useKeyValue } from '../hooks/useKeyValue';
|
||||
import { Banner } from './core/Banner';
|
||||
import { Editor } from './core/Editor';
|
||||
import { HotKeyList } from './core/HotKeyList';
|
||||
import { Icon } from './core/Icon';
|
||||
import { Select } from './core/Select';
|
||||
import { SplitLayout } from './core/SplitLayout';
|
||||
import { VStack } from './core/Stacks';
|
||||
import { HStack, VStack } from './core/Stacks';
|
||||
import { GrpcEditor } from './GrpcEditor';
|
||||
import { UrlBar } from './UrlBar';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface Props {
|
||||
style: CSSProperties;
|
||||
@@ -15,6 +21,17 @@ interface Props {
|
||||
|
||||
export function GrpcConnectionLayout({ style }: Props) {
|
||||
const url = useKeyValue<string>({ namespace: 'debug', key: 'grpc_url', defaultValue: '' });
|
||||
const alert = useAlert();
|
||||
const service = useKeyValue<string | null>({
|
||||
namespace: 'debug',
|
||||
key: 'grpc_service',
|
||||
defaultValue: null,
|
||||
});
|
||||
const method = useKeyValue<string | null>({
|
||||
namespace: 'debug',
|
||||
key: 'grpc_method',
|
||||
defaultValue: null,
|
||||
});
|
||||
const message = useKeyValue<string>({
|
||||
namespace: 'debug',
|
||||
key: 'grpc_message',
|
||||
@@ -22,23 +39,90 @@ export function GrpcConnectionLayout({ style }: Props) {
|
||||
});
|
||||
const [resp, setResp] = useState<string>('');
|
||||
const grpc = useGrpc(url.value ?? null);
|
||||
|
||||
const activeMethod = useMemo(() => {
|
||||
if (grpc.schema == null) return null;
|
||||
const s = grpc.schema.find((s) => s.name === service.value);
|
||||
if (s == null) return null;
|
||||
return s.methods.find((m) => m.name === method.value);
|
||||
}, [grpc.schema, method.value, service.value]);
|
||||
|
||||
const handleConnect = useCallback(
|
||||
async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setResp(
|
||||
await grpc.callUnary.mutateAsync({
|
||||
service: 'helloworld.Greeter',
|
||||
method: 'SayHello',
|
||||
if (activeMethod == null) return;
|
||||
|
||||
if (service.value == null || method.value == null) {
|
||||
alert({
|
||||
id: 'grpc-invalid-service-method',
|
||||
title: 'Error',
|
||||
body: 'Service or method not selected',
|
||||
});
|
||||
}
|
||||
if (activeMethod.serverStreaming && !activeMethod.clientStreaming) {
|
||||
await grpc.serverStreaming.mutateAsync({
|
||||
service: service.value ?? 'n/a',
|
||||
method: method.value ?? 'n/a',
|
||||
message: message.value ?? '',
|
||||
}),
|
||||
);
|
||||
});
|
||||
} else {
|
||||
setResp(
|
||||
await grpc.unary.mutateAsync({
|
||||
service: service.value ?? 'n/a',
|
||||
method: method.value ?? 'n/a',
|
||||
message: message.value ?? '',
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[grpc.callUnary, message.value],
|
||||
[
|
||||
activeMethod,
|
||||
alert,
|
||||
grpc.serverStreaming,
|
||||
grpc.unary,
|
||||
message.value,
|
||||
method.value,
|
||||
service.value,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('REFLECT SCHEMA', grpc.schema);
|
||||
}, [grpc.schema]);
|
||||
if (grpc.schema == null) return;
|
||||
const s = grpc.schema.find((s) => s.name === service.value);
|
||||
if (s == null) {
|
||||
service.set(grpc.schema[0]?.name ?? null);
|
||||
method.set(grpc.schema[0]?.methods[0]?.name ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
const m = s.methods.find((m) => m.name === method.value);
|
||||
if (m == null) {
|
||||
method.set(s.methods[0]?.name ?? null);
|
||||
return;
|
||||
}
|
||||
}, [grpc.schema, method, service]);
|
||||
|
||||
const handleChangeService = useCallback(
|
||||
(v: string) => {
|
||||
const [serviceName, methodName] = v.split('/', 2);
|
||||
if (serviceName == null || methodName == null) throw new Error('Should never happen');
|
||||
method.set(methodName);
|
||||
service.set(serviceName);
|
||||
},
|
||||
[method, service],
|
||||
);
|
||||
|
||||
const select = useMemo(() => {
|
||||
const options =
|
||||
grpc.schema?.flatMap((s) =>
|
||||
s.methods.map((m) => ({
|
||||
label: `${s.name.split('.', 2).pop() ?? s.name}/${m.name}`,
|
||||
value: `${s.name}/${m.name}`,
|
||||
})),
|
||||
) ?? [];
|
||||
const value = `${service.value ?? ''}/${method.value ?? ''}`;
|
||||
return { value, options };
|
||||
}, [grpc.schema, method.value, service.value]);
|
||||
|
||||
if (url.isLoading || url.value == null) {
|
||||
return null;
|
||||
@@ -49,33 +133,84 @@ export function GrpcConnectionLayout({ style }: Props) {
|
||||
style={style}
|
||||
leftSlot={() => (
|
||||
<VStack space={2}>
|
||||
<UrlBar
|
||||
id="foo"
|
||||
url={url.value ?? ''}
|
||||
method={null}
|
||||
placeholder="localhost:50051"
|
||||
onSubmit={handleConnect}
|
||||
isLoading={false}
|
||||
onUrlChange={url.set}
|
||||
forceUpdateKey={''}
|
||||
/>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] gap-1.5">
|
||||
<UrlBar
|
||||
id="foo"
|
||||
url={url.value ?? ''}
|
||||
method={null}
|
||||
forceUpdateKey="to-do"
|
||||
placeholder="localhost:50051"
|
||||
onSubmit={handleConnect}
|
||||
isLoading={grpc.unary.isLoading}
|
||||
onUrlChange={url.set}
|
||||
submitIcon={
|
||||
!activeMethod?.clientStreaming && activeMethod?.serverStreaming
|
||||
? 'arrowDownToDot'
|
||||
: activeMethod?.clientStreaming && !activeMethod?.serverStreaming
|
||||
? 'arrowUpFromDot'
|
||||
: activeMethod?.clientStreaming && activeMethod?.serverStreaming
|
||||
? 'arrowUpDown'
|
||||
: 'sendHorizontal'
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
hideLabel
|
||||
name="service"
|
||||
label="Service"
|
||||
size="sm"
|
||||
value={select.value}
|
||||
onChange={handleChangeService}
|
||||
options={select.options}
|
||||
/>
|
||||
</div>
|
||||
<GrpcEditor
|
||||
forceUpdateKey={[service, method].join('::')}
|
||||
url={url.value ?? ''}
|
||||
defaultValue={message.value}
|
||||
onChange={message.set}
|
||||
service={service.value ?? null}
|
||||
method={method.value ?? null}
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</VStack>
|
||||
)}
|
||||
rightSlot={() => (
|
||||
<Editor
|
||||
className="bg-gray-50 border border-highlight"
|
||||
contentType="application/json"
|
||||
defaultValue={resp}
|
||||
readOnly
|
||||
forceUpdateKey={resp}
|
||||
/>
|
||||
)}
|
||||
rightSlot={() =>
|
||||
!grpc.unary.isLoading && (
|
||||
<div
|
||||
className={classNames(
|
||||
'max-h-full h-full grid grid-rows-[minmax(0,1fr)] grid-cols-1',
|
||||
'bg-gray-50 dark:bg-gray-100 rounded-md border border-highlight',
|
||||
'shadow shadow-gray-100 dark:shadow-gray-0 relative py-1',
|
||||
)}
|
||||
>
|
||||
{grpc.unary.error ? (
|
||||
<Banner color="danger" className="m-2">
|
||||
{grpc.unary.error}
|
||||
</Banner>
|
||||
) : grpc.messages.length > 0 ? (
|
||||
<VStack className="h-full overflow-y-auto">
|
||||
{[...grpc.messages].reverse().map((m, i) => (
|
||||
<HStack key={m.time.getTime()} space={3} className="px-2 py-1 font-mono text-xs">
|
||||
<Icon icon="arrowDownToDot" />
|
||||
<div>{format(m.time, 'HH:mm:ss')}</div>
|
||||
<div>{m.message}</div>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
) : resp ? (
|
||||
<Editor
|
||||
className="bg-gray-50 dark:bg-gray-100"
|
||||
contentType="application/json"
|
||||
defaultValue={resp}
|
||||
readOnly
|
||||
forceUpdateKey={resp}
|
||||
/>
|
||||
) : (
|
||||
<HotKeyList hotkeys={['grpc.send', 'sidebar.toggle', 'urlBar.focus']} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,83 @@
|
||||
import type { EditorView } from 'codemirror';
|
||||
import { updateSchema } from 'codemirror-json-schema';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAlert } from '../hooks/useAlert';
|
||||
import { useGrpc } from '../hooks/useGrpc';
|
||||
import { tryFormatJson } from '../lib/formatters';
|
||||
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';
|
||||
|
||||
type Props = Pick<
|
||||
EditorProps,
|
||||
'heightMode' | 'onChange' | 'defaultValue' | 'className' | 'forceUpdateKey'
|
||||
> & {
|
||||
url: string;
|
||||
service: string | null;
|
||||
method: string | null;
|
||||
};
|
||||
|
||||
export function GrpcEditor({ url, defaultValue, ...extraEditorProps }: Props) {
|
||||
export function GrpcEditor({ url, service, method, defaultValue, ...extraEditorProps }: Props) {
|
||||
const editorViewRef = useRef<EditorView>(null);
|
||||
const { schema } = useGrpc(url);
|
||||
const grpc = useGrpc(url);
|
||||
const alert = useAlert();
|
||||
|
||||
useEffect(() => {
|
||||
if (editorViewRef.current == null || schema == null) return;
|
||||
const foo = schema[0].methods[0].schema;
|
||||
console.log('UPDATE SCHEMA', foo);
|
||||
updateSchema(editorViewRef.current, JSON.parse(foo));
|
||||
}, [schema]);
|
||||
if (editorViewRef.current == null || grpc.schema == null) return;
|
||||
const s = grpc.schema?.find((s) => s.name === service);
|
||||
if (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
|
||||
</>
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const schema = s?.methods.find((m) => m.name === method)?.schema;
|
||||
if (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
|
||||
</>
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
updateSchema(editorViewRef.current, JSON.parse(schema));
|
||||
} catch (err) {
|
||||
alert({
|
||||
id: 'grpc-parse-schema-error',
|
||||
title: 'Failed to Parse Schema',
|
||||
body: (
|
||||
<VStack space={4}>
|
||||
<p>
|
||||
For service <InlineCode>{service}</InlineCode> and method{' '}
|
||||
<InlineCode>{method}</InlineCode>
|
||||
</p>
|
||||
<FormattedError>{String(err)}</FormattedError>
|
||||
</VStack>
|
||||
),
|
||||
});
|
||||
console.log('Failed to parse method schema', method, schema);
|
||||
}
|
||||
}, [alert, grpc.schema, method, service]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
|
||||
|
||||
@@ -29,11 +29,20 @@ export const SettingsDialog = () => {
|
||||
size="sm"
|
||||
value={settings.appearance}
|
||||
onChange={(appearance) => updateSettings.mutateAsync({ ...settings, appearance })}
|
||||
options={{
|
||||
system: 'System',
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
label: 'System',
|
||||
value: 'system',
|
||||
},
|
||||
{
|
||||
label: 'Light',
|
||||
value: 'light',
|
||||
},
|
||||
{
|
||||
label: 'Dark',
|
||||
value: 'dark',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Select
|
||||
@@ -44,10 +53,16 @@ export const SettingsDialog = () => {
|
||||
size="sm"
|
||||
value={settings.updateChannel}
|
||||
onChange={(updateChannel) => updateSettings.mutateAsync({ ...settings, updateChannel })}
|
||||
options={{
|
||||
stable: 'Release',
|
||||
beta: 'Early Bird (Beta)',
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
label: 'Release',
|
||||
value: 'stable',
|
||||
},
|
||||
{
|
||||
label: 'Early Bird (Beta)',
|
||||
value: 'beta',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { FormEvent } from 'react';
|
||||
import { memo, useRef, useState } from 'react';
|
||||
import { useHotKey } from '../hooks/useHotKey';
|
||||
import type { HttpRequest } from '../lib/models';
|
||||
import type { IconProps } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { Input } from './core/Input';
|
||||
import { RequestMethodDropdown } from './RequestMethodDropdown';
|
||||
@@ -13,6 +14,7 @@ type Props = Pick<HttpRequest, 'id' | 'url'> & {
|
||||
placeholder: string;
|
||||
onSubmit: (e: FormEvent) => void;
|
||||
onUrlChange: (url: string) => void;
|
||||
submitIcon?: IconProps['icon'];
|
||||
onMethodChange?: (method: string) => void;
|
||||
isLoading: boolean;
|
||||
forceUpdateKey: string;
|
||||
@@ -27,6 +29,7 @@ export const UrlBar = memo(function UrlBar({
|
||||
className,
|
||||
onSubmit,
|
||||
onMethodChange,
|
||||
submitIcon = 'sendHorizontal',
|
||||
isLoading,
|
||||
}: Props) {
|
||||
const inputRef = useRef<EditorView>(null);
|
||||
@@ -77,7 +80,7 @@ export const UrlBar = memo(function UrlBar({
|
||||
title="Send Request"
|
||||
type="submit"
|
||||
className="w-8 mr-0.5 my-0.5"
|
||||
icon={isLoading ? 'update' : 'sendHorizontal'}
|
||||
icon={isLoading ? 'update' : submitIcon}
|
||||
spin={isLoading}
|
||||
hotkeyAction="request.send"
|
||||
/>
|
||||
|
||||
@@ -29,6 +29,7 @@ const icons = {
|
||||
magicWand: lucide.Wand2Icon,
|
||||
moreVertical: lucide.MoreVerticalIcon,
|
||||
pencil: lucide.PencilIcon,
|
||||
plug: lucide.Plug,
|
||||
plus: lucide.PlusIcon,
|
||||
plusCircle: lucide.PlusCircleIcon,
|
||||
question: lucide.ShieldQuestionIcon,
|
||||
@@ -39,6 +40,9 @@ const icons = {
|
||||
trash: lucide.TrashIcon,
|
||||
update: lucide.RefreshCcwIcon,
|
||||
upload: lucide.UploadIcon,
|
||||
arrowUpFromDot: lucide.ArrowUpFromDotIcon,
|
||||
arrowDownToDot: lucide.ArrowDownToDotIcon,
|
||||
arrowUpDown: lucide.ArrowUpDownIcon,
|
||||
x: lucide.XIcon,
|
||||
|
||||
empty: (props: HTMLAttributes<HTMLSpanElement>) => <span {...props} />,
|
||||
|
||||
@@ -6,7 +6,8 @@ export function InlineCode({ className, ...props }: HTMLAttributes<HTMLSpanEleme
|
||||
<code
|
||||
className={classNames(
|
||||
className,
|
||||
'font-mono text-sm bg-highlight border-0 border-gray-200 px-1.5 py-0.5 rounded text-gray-800 shadow-inner',
|
||||
'font-mono text-sm bg-highlight border-0 border-gray-200',
|
||||
'px-1.5 py-0.5 rounded text-gray-800 shadow-inner',
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -6,10 +6,11 @@ interface Props<T extends string> {
|
||||
labelPosition?: 'top' | 'left';
|
||||
labelClassName?: string;
|
||||
hideLabel?: boolean;
|
||||
value: string;
|
||||
options: Record<T, string>;
|
||||
value: T;
|
||||
options: { label: string; value: T }[];
|
||||
onChange: (value: T) => void;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Select<T extends string>({
|
||||
@@ -21,12 +22,14 @@ export function Select<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
className,
|
||||
size = 'md',
|
||||
}: Props<T>) {
|
||||
const id = `input-${name}`;
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
'w-full',
|
||||
'pointer-events-auto', // Just in case we're placing in disabled parent
|
||||
labelPosition === 'left' && 'flex items-center gap-2',
|
||||
@@ -48,7 +51,7 @@ export function Select<T extends string>({
|
||||
style={selectBackgroundStyles}
|
||||
onChange={(e) => onChange(e.target.value as T)}
|
||||
className={classNames(
|
||||
'font-mono text-xs border w-full px-2 outline-none bg-transparent',
|
||||
'font-mono text-xs border w-full outline-none bg-transparent pl-2 pr-7',
|
||||
'border-highlight focus:border-focus',
|
||||
size === 'xs' && 'h-xs',
|
||||
size === 'sm' && 'h-sm',
|
||||
@@ -56,8 +59,8 @@ export function Select<T extends string>({
|
||||
size === 'lg' && 'h-lg',
|
||||
)}
|
||||
>
|
||||
{Object.entries<string>(options).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{options.map(({ label, value }) => (
|
||||
<option key={label} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
@@ -68,7 +71,7 @@ export function Select<T extends string>({
|
||||
|
||||
const selectBackgroundStyles = {
|
||||
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`,
|
||||
backgroundPosition: 'right 0.5rem center',
|
||||
backgroundPosition: 'right 0.3rem center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: '1.5em 1.5em',
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { DialogProps } from '../components/core/Dialog';
|
||||
import { useDialog } from '../components/DialogContext';
|
||||
import type { AlertProps } from './Alert';
|
||||
@@ -5,20 +6,16 @@ import { Alert } from './Alert';
|
||||
|
||||
export function useAlert() {
|
||||
const dialog = useDialog();
|
||||
return ({
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
}: {
|
||||
id: string;
|
||||
title: DialogProps['title'];
|
||||
body: AlertProps['body'];
|
||||
}) =>
|
||||
dialog.show({
|
||||
id,
|
||||
title,
|
||||
hideX: true,
|
||||
size: 'sm',
|
||||
render: ({ hide }) => Alert({ onHide: hide, body }),
|
||||
});
|
||||
return useCallback(
|
||||
({ id, title, body }: { id: string; title: DialogProps['title']; body: AlertProps['body'] }) =>
|
||||
dialog.show({
|
||||
id,
|
||||
title,
|
||||
hideX: true,
|
||||
size: 'sm',
|
||||
render: ({ hide }) => Alert({ onHide: hide, body }),
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import { useState } from 'react';
|
||||
import { useListenToTauriEvent } from './useListenToTauriEvent';
|
||||
|
||||
interface ReflectResponseService {
|
||||
name: string;
|
||||
methods: { name: string; schema: string; serverStreaming: boolean; clientStreaming: boolean }[];
|
||||
}
|
||||
|
||||
interface Message {
|
||||
message: string;
|
||||
time: Date;
|
||||
}
|
||||
|
||||
export function useGrpc(url: string | null) {
|
||||
const callUnary = useMutation<
|
||||
string,
|
||||
unknown,
|
||||
{ service: string; method: string; message: string }
|
||||
>({
|
||||
mutationKey: ['grpc_call_reflect', url],
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
useListenToTauriEvent<string>(
|
||||
'grpc_message',
|
||||
(event) => {
|
||||
console.log('GOT MESSAGE', event);
|
||||
setMessages((prev) => [...prev, { message: event.payload, time: new Date() }]);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const unary = useMutation<string, string, { service: string; method: string; message: string }>({
|
||||
mutationKey: ['grpc_unary', url],
|
||||
mutationFn: async ({ service, method, message }) => {
|
||||
if (url === null) throw new Error('No URL provided');
|
||||
return (await invoke('grpc_call_unary', {
|
||||
@@ -19,17 +36,36 @@ export function useGrpc(url: string | null) {
|
||||
},
|
||||
});
|
||||
|
||||
const reflect = useQuery<string | null>({
|
||||
const serverStreaming = useMutation<
|
||||
string,
|
||||
string,
|
||||
{ service: string; method: string; message: string }
|
||||
>({
|
||||
mutationKey: ['grpc_server_streaming', url],
|
||||
mutationFn: async ({ service, method, message }) => {
|
||||
if (url === null) throw new Error('No URL provided');
|
||||
return (await invoke('grpc_server_streaming', {
|
||||
endpoint: url,
|
||||
service,
|
||||
method,
|
||||
message,
|
||||
})) as string;
|
||||
},
|
||||
});
|
||||
|
||||
const reflect = useQuery<ReflectResponseService[]>({
|
||||
queryKey: ['grpc_reflect', url ?? ''],
|
||||
queryFn: async () => {
|
||||
if (url === null) return null;
|
||||
if (url === null) return [];
|
||||
console.log('GETTING SCHEMA', url);
|
||||
return (await invoke('grpc_reflect', { endpoint: url })) as string;
|
||||
return (await invoke('grpc_reflect', { endpoint: url })) as ReflectResponseService[];
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
callUnary,
|
||||
unary,
|
||||
serverStreaming,
|
||||
schema: reflect.data,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,44 +5,47 @@ import { debounce } from '../lib/debounce';
|
||||
import { useOsInfo } from './useOsInfo';
|
||||
|
||||
export type HotkeyAction =
|
||||
| 'request.send'
|
||||
| 'environmentEditor.toggle'
|
||||
| 'grpc.send'
|
||||
| 'hotkeys.showHelp'
|
||||
| 'request.create'
|
||||
| 'request.duplicate'
|
||||
| 'sidebar.toggle'
|
||||
| 'sidebar.focus'
|
||||
| 'urlBar.focus'
|
||||
| 'environmentEditor.toggle'
|
||||
| 'hotkeys.showHelp'
|
||||
| 'requestSwitcher.prev'
|
||||
| 'request.send'
|
||||
| 'requestSwitcher.next'
|
||||
| 'settings.show';
|
||||
| 'requestSwitcher.prev'
|
||||
| 'settings.show'
|
||||
| 'sidebar.focus'
|
||||
| 'sidebar.toggle'
|
||||
| 'urlBar.focus';
|
||||
|
||||
const hotkeys: Record<HotkeyAction, string[]> = {
|
||||
'request.send': ['CmdCtrl+Enter', 'CmdCtrl+r'],
|
||||
'environmentEditor.toggle': ['CmdCtrl+Shift+e'],
|
||||
'grpc.send': ['CmdCtrl+Enter', 'CmdCtrl+r'],
|
||||
'hotkeys.showHelp': ['CmdCtrl+Shift+/'],
|
||||
'request.create': ['CmdCtrl+n'],
|
||||
'request.duplicate': ['CmdCtrl+d'],
|
||||
'sidebar.toggle': ['CmdCtrl+b'],
|
||||
'sidebar.focus': ['CmdCtrl+1'],
|
||||
'urlBar.focus': ['CmdCtrl+l'],
|
||||
'environmentEditor.toggle': ['CmdCtrl+Shift+e'],
|
||||
'hotkeys.showHelp': ['CmdCtrl+Shift+/'],
|
||||
'settings.show': ['CmdCtrl+,'],
|
||||
'requestSwitcher.prev': ['Control+Tab'],
|
||||
'request.send': ['CmdCtrl+Enter', 'CmdCtrl+r'],
|
||||
'requestSwitcher.next': ['Control+Shift+Tab'],
|
||||
'requestSwitcher.prev': ['Control+Tab'],
|
||||
'settings.show': ['CmdCtrl+,'],
|
||||
'sidebar.focus': ['CmdCtrl+1'],
|
||||
'sidebar.toggle': ['CmdCtrl+b'],
|
||||
'urlBar.focus': ['CmdCtrl+l'],
|
||||
};
|
||||
|
||||
const hotkeyLabels: Record<HotkeyAction, string> = {
|
||||
'request.send': 'Send Request',
|
||||
'environmentEditor.toggle': 'Edit Environments',
|
||||
'grpc.send': 'Send Message',
|
||||
'hotkeys.showHelp': 'Show Keyboard Shortcuts',
|
||||
'request.create': 'New Request',
|
||||
'request.duplicate': 'Duplicate Request',
|
||||
'sidebar.toggle': 'Toggle Sidebar',
|
||||
'sidebar.focus': 'Focus Sidebar',
|
||||
'urlBar.focus': 'Focus URL',
|
||||
'environmentEditor.toggle': 'Edit Environments',
|
||||
'hotkeys.showHelp': 'Show Keyboard Shortcuts',
|
||||
'requestSwitcher.prev': 'Go To Next Request',
|
||||
'request.send': 'Send Request',
|
||||
'requestSwitcher.next': 'Go To Previous Request',
|
||||
'requestSwitcher.prev': 'Go To Next Request',
|
||||
'settings.show': 'Open Settings',
|
||||
'sidebar.focus': 'Focus Sidebar',
|
||||
'sidebar.toggle': 'Toggle Sidebar',
|
||||
'urlBar.focus': 'Focus URL',
|
||||
};
|
||||
|
||||
export const hotkeyActions: HotkeyAction[] = Object.keys(hotkeys) as (keyof typeof hotkeys)[];
|
||||
|
||||
Reference in New Issue
Block a user