Hacky server streaming done

This commit is contained in:
Gregory Schier
2024-01-31 22:13:46 -08:00
parent c64f1108f0
commit 1e309e821e
15 changed files with 546 additions and 119 deletions

10
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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} />,

View File

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

View File

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

View File

@@ -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
[],
);
}

View File

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

View File

@@ -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)[];