Compare commits

...

12 Commits

Author SHA1 Message Date
Gregory Schier
9fe077f598 Sign vendored binaries with hardened runtime on macOS 2026-01-11 10:14:39 -08:00
Gregory Schier
a6eca1cf2e Add Windows binary paths to tauri resources 2026-01-11 09:55:12 -08:00
Gregory Schier
31edd1013f Add missing bootstrap step to release workflow 2026-01-11 09:42:36 -08:00
Gregory Schier
28e9657ea5 Add EventDetailHeader component and fix EventViewer overflow
- Create standardized EventDetailHeader with title, timestamp, actions, and copyText props
- Fix EventViewer firstSlot overflow/scrolling issue
- Update GrpcResponsePane, WebsocketResponsePane, HttpResponseTimeline, and EventStreamViewer to use EventDetailHeader
- Fix Timeline title consistency when toggling Raw/Formatted views
2026-01-11 08:51:36 -08:00
Gregory Schier
ff084a224a Consolidate event viewer interfaces (#355) 2026-01-11 07:57:05 -08:00
Gregory Schier
bbcae34575 Fix race condition where streamed events could be lost
Events stream in via model_write listener while also being fetched
from the database. If the DB fetch completed before all events were
persisted, replaceModelsInStore would wipe out events that came in
via model_write.

Added mergeModelsInStore that adds fetched events without removing
existing ones. Applied to HTTP, gRPC, and WebSocket event hooks.
2026-01-11 07:42:04 -08:00
Gregory Schier
2a5587c128 Show sent/received cookie counts in Cookies tab
- Add getCookieCounts function to parse cookie headers and count
  individual cookies (not just headers)
- Deduplicates by cookie name using Sets
- Display as sent/received format like Headers tab
- Add showZero to CountBadge so 0/3 displays properly
- Add tests for getCookieCounts
2026-01-11 07:20:01 -08:00
Gregory Schier
c41e173a63 Fix dropdown menu hotkeys not working when menu is closed
The nested menu PR introduced an early return null when !isOpen,
which prevented MenuItemHotKey components from being rendered.
Fixed by extracting hotKeyElements and rendering them even when
the menu is closed.
2026-01-11 07:19:56 -08:00
Gregory Schier
2b43407ddf Fix gRPC autocomplete schema not being applied
Two issues fixed:

1. Initialize stateExtensions with empty object {} instead of undefined.
   When called with no argument, the schema state was undefined, causing
   jsonCompletion() to return [] instead of a proper result object, which
   CodeMirror's autocomplete didn't handle correctly.

2. Change editorView from useRef to useState so the effect that calls
   updateSchema() properly re-runs when the editor view is set. With useRef,
   the effect could run before the editor was mounted or with a stale
   reference when the editor was recreated.
2026-01-10 14:57:28 -08:00
Gregory Schier
4d75b8ef06 Surface gRPC message deserialization errors to UI
Previously, when a gRPC streaming message failed to deserialize (e.g., wrong
type like int instead of string), the error was silently logged and the message
was dropped. Now errors are surfaced to the UI as GrpcEventType::Error events.

Changed the streaming/client_streaming methods to accept an on_message callback
that handles both success (logs ClientMessage) and error (logs Error) cases,
rather than logging the client message prematurely before deserialization.
2026-01-10 14:57:28 -08:00
Gregory Schier
aa79fb05f9 Fix gRPC stream panic: use async stream combinators instead of block_on
The gRPC streaming code was using tokio::runtime::Handle::current().block_on()
inside filter_map closures, which caused a panic ('Cannot start a runtime from
within a runtime') when called from an async context.

Fixed by replacing the pattern with .then(async move { ... }).filter_map(|x| x)
which properly handles async operations in stream pipelines.

This fixes the gRPC Ping/Pong freeze issue and restores request cancellation.
2026-01-10 14:57:28 -08:00
Gregory Schier
fe01796536 feat: add ctx.prompt.form() plugin API for multi-field form dialogs (#359) 2026-01-10 08:55:43 -08:00
29 changed files with 1345 additions and 809 deletions

View File

@@ -1,7 +1,7 @@
name: Generate Artifacts
on:
push:
tags: [ v* ]
tags: [v*]
jobs:
build-artifacts:
@@ -13,37 +13,37 @@ jobs:
fail-fast: false
matrix:
include:
- platform: 'macos-latest' # for Arm-based Macs (M1 and above).
args: '--target aarch64-apple-darwin'
yaak_arch: 'arm64'
os: 'macos'
targets: 'aarch64-apple-darwin'
- platform: 'macos-latest' # for Intel-based Macs.
args: '--target x86_64-apple-darwin'
yaak_arch: 'x64'
os: 'macos'
targets: 'x86_64-apple-darwin'
- platform: 'ubuntu-22.04'
args: ''
yaak_arch: 'x64'
os: 'ubuntu'
targets: ''
- platform: 'ubuntu-22.04-arm'
args: ''
yaak_arch: 'arm64'
os: 'ubuntu'
targets: ''
- platform: 'windows-latest'
args: ''
yaak_arch: 'x64'
os: 'windows'
targets: ''
- platform: "macos-latest" # for Arm-based Macs (M1 and above).
args: "--target aarch64-apple-darwin"
yaak_arch: "arm64"
os: "macos"
targets: "aarch64-apple-darwin"
- platform: "macos-latest" # for Intel-based Macs.
args: "--target x86_64-apple-darwin"
yaak_arch: "x64"
os: "macos"
targets: "x86_64-apple-darwin"
- platform: "ubuntu-22.04"
args: ""
yaak_arch: "x64"
os: "ubuntu"
targets: ""
- platform: "ubuntu-22.04-arm"
args: ""
yaak_arch: "arm64"
os: "ubuntu"
targets: ""
- platform: "windows-latest"
args: ""
yaak_arch: "x64"
os: "windows"
targets: ""
# Windows ARM64
- platform: 'windows-latest'
args: '--target aarch64-pc-windows-msvc'
yaak_arch: 'arm64'
os: 'windows'
targets: 'aarch64-pc-windows-msvc'
- platform: "windows-latest"
args: "--target aarch64-pc-windows-msvc"
yaak_arch: "arm64"
os: "windows"
targets: "aarch64-pc-windows-msvc"
runs-on: ${{ matrix.platform }}
timeout-minutes: 40
steps:
@@ -88,6 +88,7 @@ jobs:
& $exe --version
- run: npm ci
- run: npm run bootstrap
- run: npm run lint
- name: Run JS Tests
run: npm test
@@ -99,6 +100,29 @@ jobs:
env:
YAAK_VERSION: ${{ github.ref_name }}
- name: Sign vendored binaries (macOS only)
if: matrix.os == 'macos'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
# Create keychain
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Import certificate
echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12
security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
# Sign vendored binaries with hardened runtime
codesign --force --options runtime --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app/vendored/protoc/yaakprotoc || true
codesign --force --options runtime --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app/vendored/node/yaaknode || true
- uses: tauri-apps/tauri-action@v0
env:
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
@@ -121,9 +145,9 @@ jobs:
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
with:
tagName: 'v__VERSION__'
releaseName: 'Release __VERSION__'
releaseBody: '[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)'
tagName: "v__VERSION__"
releaseName: "Release __VERSION__"
releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)"
releaseDraft: true
prerelease: true
args: '${{ matrix.args }} --config ./crates-tauri/yaak-app/tauri.release.conf.json'
args: "${{ matrix.args }} --config ./crates-tauri/yaak-app/tauri.release.conf.json"

View File

@@ -360,10 +360,8 @@ async fn cmd_grpc_go<R: Runtime>(
let cb = {
let cancelled_rx = cancelled_rx.clone();
let app_handle = app_handle.clone();
let environment_chain = environment_chain.clone();
let window = window.clone();
let base_msg = base_msg.clone();
let plugin_manager = plugin_manager.clone();
let encryption_manager = encryption_manager.clone();
@@ -385,14 +383,12 @@ async fn cmd_grpc_go<R: Runtime>(
match serde_json::from_str::<IncomingMsg>(ev.payload()) {
Ok(IncomingMsg::Message(msg)) => {
let window = window.clone();
let app_handle = app_handle.clone();
let base_msg = base_msg.clone();
let environment_chain = environment_chain.clone();
let plugin_manager = plugin_manager.clone();
let encryption_manager = encryption_manager.clone();
let msg = block_in_place(|| {
tauri::async_runtime::block_on(async {
render_template(
let result = render_template(
msg.as_str(),
environment_chain,
&PluginTemplateCallback::new(
@@ -406,24 +402,11 @@ async fn cmd_grpc_go<R: Runtime>(
),
&RenderOptions { error_behavior: RenderErrorBehavior::Throw },
)
.await
.expect("Failed to render template")
.await;
result.expect("Failed to render template")
})
});
in_msg_tx.try_send(msg.clone()).unwrap();
tauri::async_runtime::spawn(async move {
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
content: msg,
event_type: GrpcEventType::ClientMessage,
..base_msg.clone()
},
&UpdateSource::from_window_label(window.label()),
)
.unwrap();
});
}
Ok(IncomingMsg::Commit) => {
maybe_in_msg_tx.take();
@@ -470,12 +453,48 @@ async fn cmd_grpc_go<R: Runtime>(
)?;
async move {
// Create callback for streaming methods that handles both success and error
let on_message = {
let app_handle = app_handle.clone();
let base_event = base_event.clone();
let window_label = window.label().to_string();
move |result: std::result::Result<String, String>| match result {
Ok(msg) => {
let _ = app_handle.db().upsert_grpc_event(
&GrpcEvent {
content: msg,
event_type: GrpcEventType::ClientMessage,
..base_event.clone()
},
&UpdateSource::from_window_label(&window_label),
);
}
Err(error) => {
let _ = app_handle.db().upsert_grpc_event(
&GrpcEvent {
content: format!("Failed to send message: {}", error),
event_type: GrpcEventType::Error,
..base_event.clone()
},
&UpdateSource::from_window_label(&window_label),
);
}
}
};
let (maybe_stream, maybe_msg) =
match (method_desc.is_client_streaming(), method_desc.is_server_streaming()) {
(true, true) => (
Some(
connection
.streaming(&service, &method, in_msg_stream, &metadata, client_cert)
.streaming(
&service,
&method,
in_msg_stream,
&metadata,
client_cert,
on_message.clone(),
)
.await,
),
None,
@@ -490,6 +509,7 @@ async fn cmd_grpc_go<R: Runtime>(
in_msg_stream,
&metadata,
client_cert,
on_message.clone(),
)
.await,
),

View File

@@ -57,6 +57,10 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
let window = get_window_from_plugin_context(app_handle, &plugin_context)?;
Ok(call_frontend(&window, event).await)
}
InternalEventPayload::PromptFormRequest(_) => {
let window = get_window_from_plugin_context(app_handle, &plugin_context)?;
Ok(call_frontend(&window, event).await)
}
InternalEventPayload::FindHttpResponsesRequest(req) => {
let http_responses = app_handle
.db()

View File

@@ -44,8 +44,8 @@
"vendored/protoc/include",
"vendored/plugins",
"vendored/plugin-runtime",
"vendored/node/yaaknode",
"vendored/protoc/yaakprotoc"
"vendored/node/yaaknode*",
"vendored/protoc/yaakprotoc*"
]
}
}

View File

@@ -115,14 +115,18 @@ impl GrpcConnection {
Ok(client.unary(req, path, codec).await?)
}
pub async fn streaming(
pub async fn streaming<F>(
&self,
service: &str,
method: &str,
stream: ReceiverStream<String>,
metadata: &BTreeMap<String, String>,
client_cert: Option<ClientCertificateConfig>,
) -> Result<Response<Streaming<DynamicMessage>>> {
on_message: F,
) -> Result<Response<Streaming<DynamicMessage>>>
where
F: Fn(std::result::Result<String, String>) + Send + Sync + Clone + 'static,
{
let method = &self.method(&service, &method).await?;
let mapped_stream = {
let input_message = method.input();
@@ -131,31 +135,39 @@ impl GrpcConnection {
let md = metadata.clone();
let use_reflection = self.use_reflection.clone();
let client_cert = client_cert.clone();
stream.filter_map(move |json| {
let pool = pool.clone();
let uri = uri.clone();
let input_message = input_message.clone();
let md = md.clone();
let use_reflection = use_reflection.clone();
let client_cert = client_cert.clone();
tokio::runtime::Handle::current().block_on(async move {
if use_reflection {
if let Err(e) =
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
{
warn!("Failed to resolve Any types: {e}");
stream
.then(move |json| {
let pool = pool.clone();
let uri = uri.clone();
let input_message = input_message.clone();
let md = md.clone();
let use_reflection = use_reflection.clone();
let client_cert = client_cert.clone();
let on_message = on_message.clone();
let json_clone = json.clone();
async move {
if use_reflection {
if let Err(e) =
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
{
warn!("Failed to resolve Any types: {e}");
}
}
}
let mut de = Deserializer::from_str(&json);
match DynamicMessage::deserialize(input_message, &mut de) {
Ok(m) => Some(m),
Err(e) => {
warn!("Failed to deserialize message: {e}");
None
let mut de = Deserializer::from_str(&json);
match DynamicMessage::deserialize(input_message, &mut de) {
Ok(m) => {
on_message(Ok(json_clone));
Some(m)
}
Err(e) => {
warn!("Failed to deserialize message: {e}");
on_message(Err(e.to_string()));
None
}
}
}
})
})
.filter_map(|x| x)
};
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
@@ -169,14 +181,18 @@ impl GrpcConnection {
Ok(client.streaming(req, path, codec).await?)
}
pub async fn client_streaming(
pub async fn client_streaming<F>(
&self,
service: &str,
method: &str,
stream: ReceiverStream<String>,
metadata: &BTreeMap<String, String>,
client_cert: Option<ClientCertificateConfig>,
) -> Result<Response<DynamicMessage>> {
on_message: F,
) -> Result<Response<DynamicMessage>>
where
F: Fn(std::result::Result<String, String>) + Send + Sync + Clone + 'static,
{
let method = &self.method(&service, &method).await?;
let mapped_stream = {
let input_message = method.input();
@@ -185,31 +201,39 @@ impl GrpcConnection {
let md = metadata.clone();
let use_reflection = self.use_reflection.clone();
let client_cert = client_cert.clone();
stream.filter_map(move |json| {
let pool = pool.clone();
let uri = uri.clone();
let input_message = input_message.clone();
let md = md.clone();
let use_reflection = use_reflection.clone();
let client_cert = client_cert.clone();
tokio::runtime::Handle::current().block_on(async move {
if use_reflection {
if let Err(e) =
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
{
warn!("Failed to resolve Any types: {e}");
stream
.then(move |json| {
let pool = pool.clone();
let uri = uri.clone();
let input_message = input_message.clone();
let md = md.clone();
let use_reflection = use_reflection.clone();
let client_cert = client_cert.clone();
let on_message = on_message.clone();
let json_clone = json.clone();
async move {
if use_reflection {
if let Err(e) =
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
{
warn!("Failed to resolve Any types: {e}");
}
}
}
let mut de = Deserializer::from_str(&json);
match DynamicMessage::deserialize(input_message, &mut de) {
Ok(m) => Some(m),
Err(e) => {
warn!("Failed to deserialize message: {e}");
None
let mut de = Deserializer::from_str(&json);
match DynamicMessage::deserialize(input_message, &mut de) {
Ok(m) => {
on_message(Ok(json_clone));
Some(m)
}
Err(e) => {
warn!("Failed to deserialize message: {e}");
on_message(Err(e.to_string()));
None
}
}
}
})
})
.filter_map(|x| x)
};
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());

View File

@@ -342,7 +342,8 @@ mod tests {
#[tokio::test]
async fn test_transaction_single_redirect() {
let redirect_headers = vec![("Location".to_string(), "https://example.com/new".to_string())];
let redirect_headers =
vec![("Location".to_string(), "https://example.com/new".to_string())];
let responses = vec![
MockResponse { status: 302, headers: redirect_headers, body: vec![] },
@@ -373,7 +374,8 @@ mod tests {
#[tokio::test]
async fn test_transaction_max_redirects_exceeded() {
let redirect_headers = vec![("Location".to_string(), "https://example.com/loop".to_string())];
let redirect_headers =
vec![("Location".to_string(), "https://example.com/loop".to_string())];
// Create more redirects than allowed
let responses: Vec<MockResponse> = (0..12)
@@ -525,7 +527,8 @@ mod tests {
_request: SendableHttpRequest,
_event_tx: mpsc::Sender<HttpResponseEvent>,
) -> Result<HttpResponse> {
let headers = vec![("set-cookie".to_string(), "session=xyz789; Path=/".to_string())];
let headers =
vec![("set-cookie".to_string(), "session=xyz789; Path=/".to_string())];
let body_stream: Pin<Box<dyn AsyncRead + Send>> =
Box::pin(std::io::Cursor::new(vec![]));
@@ -584,7 +587,10 @@ mod tests {
let headers = vec![
("set-cookie".to_string(), "session=abc123; Path=/".to_string()),
("set-cookie".to_string(), "user_id=42; Path=/".to_string()),
("set-cookie".to_string(), "preferences=dark; Path=/; Max-Age=86400".to_string()),
(
"set-cookie".to_string(),
"preferences=dark; Path=/; Max-Age=86400".to_string(),
),
];
let body_stream: Pin<Box<dyn AsyncRead + Send>> =

View File

@@ -206,6 +206,22 @@ export function replaceModelsInStore<
});
}
export function mergeModelsInStore<
M extends AnyModel['model'],
T extends Extract<AnyModel, { model: M }>,
>(model: M, models: T[]) {
mustStore().set(modelStoreDataAtom, (prev: ModelStoreData) => {
const existingModels = { ...prev[model] } as Record<string, T>;
for (const m of models) {
existingModels[m.id] = m;
}
return {
...prev,
[model]: existingModels,
};
});
}
function shouldIgnoreModel({ model, updateSource }: ModelPayload) {
// Never ignore updates from non-user sources
if (updateSource.type !== 'window') {

File diff suppressed because one or more lines are too long

View File

@@ -157,6 +157,9 @@ pub enum InternalEventPayload {
PromptTextRequest(PromptTextRequest),
PromptTextResponse(PromptTextResponse),
PromptFormRequest(PromptFormRequest),
PromptFormResponse(PromptFormResponse),
WindowInfoRequest(WindowInfoRequest),
WindowInfoResponse(WindowInfoResponse),
@@ -571,6 +574,28 @@ pub struct PromptTextResponse {
pub value: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct PromptFormRequest {
pub id: String,
pub title: String,
#[ts(optional)]
pub description: Option<String>,
pub inputs: Vec<FormInput>,
#[ts(optional)]
pub confirm_text: Option<String>,
#[ts(optional)]
pub cancel_text: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct PromptFormResponse {
pub values: Option<HashMap<String, JsonPrimitive>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]

File diff suppressed because one or more lines are too long

View File

@@ -11,6 +11,8 @@ import type {
ListHttpRequestsRequest,
ListHttpRequestsResponse,
OpenWindowRequest,
PromptFormRequest,
PromptFormResponse,
PromptTextRequest,
PromptTextResponse,
RenderGrpcRequestRequest,
@@ -37,6 +39,7 @@ export interface Context {
};
prompt: {
text(args: PromptTextRequest): Promise<PromptTextResponse['value']>;
form(args: PromptFormRequest): Promise<PromptFormResponse['values']>;
};
store: {
set<T>(key: string, value: T): Promise<void>;

View File

@@ -28,6 +28,7 @@ import type {
ListHttpRequestsResponse,
ListWorkspacesResponse,
PluginContext,
PromptFormResponse,
PromptTextResponse,
RenderGrpcRequestResponse,
RenderHttpRequestResponse,
@@ -661,6 +662,13 @@ export class PluginInstance {
});
return reply.value;
},
form: async (args) => {
const reply: PromptFormResponse = await this.#sendForReply(context, {
type: 'prompt_form_request',
...args,
});
return reply.values;
},
},
httpResponse: {
find: async (args) => {

View File

@@ -10,7 +10,7 @@ import {
stateExtensions,
updateSchema,
} from 'codemirror-json-schema';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ReflectResponseService } from '../hooks/useGrpc';
import { showAlert } from '../lib/alert';
import { showDialog } from '../lib/dialog';
@@ -39,15 +39,15 @@ export function GrpcEditor({
protoFiles,
...extraEditorProps
}: Props) {
const editorViewRef = useRef<EditorView>(null);
const [editorView, setEditorView] = useState<EditorView | null>(null);
const handleInitEditorViewRef = useCallback((h: EditorView | null) => {
editorViewRef.current = h;
setEditorView(h);
}, []);
// Find the schema for the selected service and method and update the editor
useEffect(() => {
if (
editorViewRef.current == null ||
editorView == null ||
services === null ||
request.service === null ||
request.method === null
@@ -91,7 +91,7 @@ export function GrpcEditor({
}
try {
updateSchema(editorViewRef.current, JSON.parse(schema));
updateSchema(editorView, JSON.parse(schema));
} catch (err) {
showAlert({
id: 'grpc-parse-schema-error',
@@ -107,7 +107,7 @@ export function GrpcEditor({
),
});
}
}, [services, request.method, request.service]);
}, [editorView, services, request.method, request.service]);
const extraExtensions = useMemo(
() => [
@@ -118,7 +118,7 @@ export function GrpcEditor({
jsonLanguage.data.of({
autocomplete: jsonCompletion(),
}),
stateExtensions(/** Init with empty schema **/),
stateExtensions({}),
],
[],
);

View File

@@ -1,9 +1,7 @@
import type { GrpcEvent, GrpcRequest } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { format } from 'date-fns';
import { useAtomValue, useSetAtom } from 'jotai';
import type { CSSProperties } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
activeGrpcConnectionAtom,
activeGrpcConnections,
@@ -11,18 +9,14 @@ import {
useGrpcEvents,
} from '../hooks/usePinnedGrpcConnection';
import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { copyToClipboard } from '../lib/copy';
import { AutoScroller } from './core/AutoScroller';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { Editor } from './core/Editor/LazyEditor';
import { EventDetailHeader, EventViewer } from './core/EventViewer';
import { EventViewerRow } from './core/EventViewerRow';
import { HotkeyList } from './core/HotkeyList';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { Icon, type IconProps } from './core/Icon';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
import { LoadingIcon } from './core/LoadingIcon';
import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout';
import { HStack, VStack } from './core/Stacks';
import { EmptyStateText } from './EmptyStateText';
import { ErrorBoundary } from './ErrorBoundary';
@@ -42,7 +36,7 @@ interface Props {
}
export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
const [activeEventId, setActiveEventId] = useState<string | null>(null);
const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null);
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
const [showingLarge, setShowingLarge] = useState<boolean>(false);
const connections = useAtomValue(activeGrpcConnections);
@@ -51,8 +45,8 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
const setPinnedGrpcConnectionId = useSetAtom(pinnedGrpcConnectionIdAtom);
const activeEvent = useMemo(
() => events.find((m) => m.id === activeEventId) ?? null,
[activeEventId, events],
() => (activeEventIndex != null ? events[activeEventIndex] : null),
[activeEventIndex, events],
);
// Set the active message to the first message received if unary
@@ -61,223 +55,188 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
if (events.length === 0 || activeEvent != null || methodType !== 'unary') {
return;
}
setActiveEventId(events.find((m) => m.eventType === 'server_message')?.id ?? null);
const firstServerMessageIndex = events.findIndex((m) => m.eventType === 'server_message');
if (firstServerMessageIndex !== -1) {
setActiveEventIndex(firstServerMessageIndex);
}
}, [events.length]);
if (activeConnection == null) {
return (
<HotkeyList hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']} />
);
}
const header = (
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle overflow-x-auto hide-scrollbars">
<HStack space={2}>
<span className="whitespace-nowrap">{events.length} Messages</span>
{activeConnection.state !== 'closed' && (
<LoadingIcon size="sm" className="text-text-subtlest" />
)}
</HStack>
<div className="ml-auto">
<RecentGrpcConnectionsDropdown
connections={connections}
activeConnection={activeConnection}
onPinnedConnectionId={setPinnedGrpcConnectionId}
/>
</div>
</HStack>
);
return (
<SplitLayout
layout="vertical"
style={style}
name="grpc_events"
defaultRatio={0.4}
minHeightPx={20}
firstSlot={() =>
activeConnection == null ? (
<HotkeyList
hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']}
/>
) : (
<div className="w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 items-center">
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle overflow-x-auto hide-scrollbars">
<HStack space={2}>
<span className="whitespace-nowrap">{events.length} Messages</span>
{activeConnection.state !== 'closed' && (
<LoadingIcon size="sm" className="text-text-subtlest" />
)}
</HStack>
<div className="ml-auto">
<RecentGrpcConnectionsDropdown
connections={connections}
activeConnection={activeConnection}
onPinnedConnectionId={setPinnedGrpcConnectionId}
/>
</div>
</HStack>
<ErrorBoundary name="GRPC Events">
<AutoScroller
data={events}
header={
activeConnection.error && (
<Banner color="danger" className="m-3">
{activeConnection.error}
</Banner>
)
}
render={(event) => (
<EventRow
key={event.id}
event={event}
isActive={event.id === activeEventId}
onClick={() => {
if (event.id === activeEventId) setActiveEventId(null);
else setActiveEventId(event.id);
}}
/>
)}
/>
</ErrorBoundary>
</div>
)
}
secondSlot={
activeEvent != null && activeConnection != null
? () => (
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
<div className="pb-3 px-2">
<Separator />
</div>
<div className="h-full pl-2 overflow-y-auto grid grid-rows-[auto_minmax(0,1fr)] ">
{activeEvent.eventType === 'client_message' ||
activeEvent.eventType === 'server_message' ? (
<>
<div className="mb-2 select-text cursor-text grid grid-cols-[minmax(0,1fr)_auto] items-center">
<div className="font-semibold">
Message {activeEvent.eventType === 'client_message' ? 'Sent' : 'Received'}
</div>
<IconButton
title="Copy message"
icon="copy"
size="xs"
onClick={() => copyToClipboard(activeEvent.content)}
/>
</div>
{!showLarge && activeEvent.content.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden
<div>
<Button
onClick={() => {
setShowingLarge(true);
setTimeout(() => {
setShowLarge(true);
setShowingLarge(false);
}, 500);
}}
isLoading={showingLarge}
color="secondary"
variant="border"
size="xs"
>
Try Showing
</Button>
</div>
</VStack>
) : (
<Editor
language="json"
defaultValue={activeEvent.content ?? ''}
wrapLines={false}
readOnly={true}
stateKey={null}
/>
)}
</>
) : (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<div>
<div className="select-text cursor-text font-semibold">
{activeEvent.content}
</div>
{activeEvent.error && (
<div className="select-text cursor-text text-sm font-mono py-1 text-warning">
{activeEvent.error}
</div>
)}
</div>
<div className="py-2 h-full">
{Object.keys(activeEvent.metadata).length === 0 ? (
<EmptyStateText>
No{' '}
{activeEvent.eventType === 'connection_end' ? 'trailers' : 'metadata'}
</EmptyStateText>
) : (
<KeyValueRows>
{Object.entries(activeEvent.metadata).map(([key, value]) => (
<KeyValueRow key={key} label={key}>
{value}
</KeyValueRow>
))}
</KeyValueRows>
)}
</div>
</div>
)}
</div>
</div>
)
: null
<div style={style} className="h-full">
<ErrorBoundary name="GRPC Events">
<EventViewer
events={events}
getEventKey={(event) => event.id}
error={activeConnection.error}
header={header}
splitLayoutName="grpc_events"
defaultRatio={0.4}
renderRow={({ event, isActive, onClick }) => (
<GrpcEventRow event={event} isActive={isActive} onClick={onClick} />
)}
renderDetail={({ event }) => (
<GrpcEventDetail
event={event}
showLarge={showLarge}
showingLarge={showingLarge}
setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge}
/>
)}
/>
</ErrorBoundary>
</div>
);
}
function GrpcEventRow({
event,
isActive,
onClick,
}: {
event: GrpcEvent;
isActive: boolean;
onClick: () => void;
}) {
const { eventType, status, content, error } = event;
const display = getEventDisplay(eventType, status);
return (
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon color={display.color} title={display.title} icon={display.icon} />}
content={
<span className="text-xs">
{content.slice(0, 1000)}
{error && <span className="text-warning"> ({error})</span>}
</span>
}
timestamp={event.createdAt}
/>
);
}
function EventRow({
onClick,
isActive,
function GrpcEventDetail({
event,
showLarge,
showingLarge,
setShowLarge,
setShowingLarge,
}: {
onClick?: () => void;
isActive?: boolean;
event: GrpcEvent;
showLarge: boolean;
showingLarge: boolean;
setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void;
}) {
const { eventType, status, createdAt, content, error } = event;
const ref = useRef<HTMLDivElement>(null);
if (event.eventType === 'client_message' || event.eventType === 'server_message') {
const title = `Message ${event.eventType === 'client_message' ? 'Sent' : 'Received'}`;
return (
<div className="px-1" ref={ref}>
<button
type="button"
onClick={onClick}
className={classNames(
'w-full grid grid-cols-[auto_minmax(0,3fr)_auto] gap-2 items-center text-left',
'px-1.5 h-xs font-mono cursor-default group focus:outline-none focus:text-text rounded',
isActive && '!bg-surface-active !text-text',
'text-text-subtle hover:text',
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<EventDetailHeader title={title} timestamp={event.createdAt} copyText={event.content} />
{!showLarge && event.content.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden
<div>
<Button
onClick={() => {
setShowingLarge(true);
setTimeout(() => {
setShowLarge(true);
setShowingLarge(false);
}, 500);
}}
isLoading={showingLarge}
color="secondary"
variant="border"
size="xs"
>
Try Showing
</Button>
</div>
</VStack>
) : (
<Editor
language="json"
defaultValue={event.content ?? ''}
wrapLines={false}
readOnly={true}
stateKey={null}
/>
)}
>
<Icon
color={
eventType === 'server_message'
? 'info'
: eventType === 'client_message'
? 'primary'
: eventType === 'error' || (status != null && status > 0)
? 'danger'
: eventType === 'connection_end'
? 'success'
: undefined
}
title={
eventType === 'server_message'
? 'Server message'
: eventType === 'client_message'
? 'Client message'
: eventType === 'error' || (status != null && status > 0)
? 'Error'
: eventType === 'connection_end'
? 'Connection response'
: undefined
}
icon={
eventType === 'server_message'
? 'arrow_big_down_dash'
: eventType === 'client_message'
? 'arrow_big_up_dash'
: eventType === 'error' || (status != null && status > 0)
? 'alert_triangle'
: eventType === 'connection_end'
? 'check'
: 'info'
}
/>
<div className={classNames('w-full truncate text-xs')}>
{content.slice(0, 1000)}
{error && <span className="text-warning"> ({error})</span>}
</div>
);
}
// Error or connection_end - show metadata/trailers
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<EventDetailHeader title={event.content} timestamp={event.createdAt} />
{event.error && (
<div className="select-text cursor-text text-sm font-mono py-1 text-warning">
{event.error}
</div>
<div className={classNames('opacity-50 text-xs')}>
{format(`${createdAt}Z`, 'HH:mm:ss.SSS')}
</div>
</button>
)}
<div className="py-2 h-full">
{Object.keys(event.metadata).length === 0 ? (
<EmptyStateText>
No {event.eventType === 'connection_end' ? 'trailers' : 'metadata'}
</EmptyStateText>
) : (
<KeyValueRows>
{Object.entries(event.metadata).map(([key, value]) => (
<KeyValueRow key={key} label={key}>
{value}
</KeyValueRow>
))}
</KeyValueRows>
)}
</div>
</div>
);
}
function getEventDisplay(
eventType: GrpcEvent['eventType'],
status: GrpcEvent['status'],
): { icon: IconProps['icon']; color: IconProps['color']; title: string } {
if (eventType === 'server_message') {
return { icon: 'arrow_big_down_dash', color: 'info', title: 'Server message' };
}
if (eventType === 'client_message') {
return { icon: 'arrow_big_up_dash', color: 'primary', title: 'Client message' };
}
if (eventType === 'error' || (status != null && status > 0)) {
return { icon: 'alert_triangle', color: 'danger', title: 'Error' };
}
if (eventType === 'connection_end') {
return { icon: 'check', color: 'success', title: 'Connection response' };
}
return { icon: 'info', color: undefined, title: 'Event' };
}

View File

@@ -9,7 +9,7 @@ import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useResponseBodyBytes, useResponseBodyText } from '../hooks/useResponseBodyText';
import { useResponseViewMode } from '../hooks/useResponseViewMode';
import { getMimeTypeFromContentType } from '../lib/contentType';
import { getContentTypeFromHeaders } from '../lib/model_util';
import { getCookieCounts, getContentTypeFromHeaders } from '../lib/model_util';
import { ConfirmLargeResponse } from './ConfirmLargeResponse';
import { ConfirmLargeResponseRequest } from './ConfirmLargeResponseRequest';
import { Banner } from './core/Banner';
@@ -67,20 +67,10 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const responseEvents = useHttpResponseEvents(activeResponse);
const cookieCount = useMemo(() => {
if (!responseEvents.data) return 0;
let count = 0;
for (const event of responseEvents.data) {
const e = event.event;
if (
(e.type === 'header_up' && e.name.toLowerCase() === 'cookie') ||
(e.type === 'header_down' && e.name.toLowerCase() === 'set-cookie')
) {
count++;
}
}
return count;
}, [responseEvents.data]);
const cookieCounts = useMemo(
() => getCookieCounts(responseEvents.data),
[responseEvents.data],
);
const tabs = useMemo<TabItem[]>(
() => [
@@ -107,15 +97,19 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
label: 'Headers',
rightSlot: (
<CountBadge
count2={activeResponse?.headers.length ?? 0}
count={activeResponse?.requestHeaders.length ?? 0}
count2={activeResponse?.headers.length ?? 0}
showZero
/>
),
},
{
value: TAB_COOKIES,
label: 'Cookies',
rightSlot: cookieCount > 0 ? <CountBadge count={cookieCount} /> : null,
rightSlot:
cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
<CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />
) : null,
},
{
value: TAB_TIMELINE,
@@ -127,7 +121,8 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
activeResponse?.headers,
activeResponse?.requestContentLength,
activeResponse?.requestHeaders.length,
cookieCount,
cookieCounts.sent,
cookieCounts.received,
mimeType,
responseEvents.data?.length,
setViewMode,

View File

@@ -3,18 +3,15 @@ import type {
HttpResponseEvent,
HttpResponseEventData,
} from '@yaakapp-internal/models';
import classNames from 'classnames';
import { format } from 'date-fns';
import { type ReactNode, useMemo, useState } from 'react';
import { type ReactNode, useState } from 'react';
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
import { AutoScroller } from './core/AutoScroller';
import { Banner } from './core/Banner';
import { Editor } from './core/Editor/LazyEditor';
import { EventDetailHeader, EventViewer, type EventDetailAction } from './core/EventViewer';
import { EventViewerRow } from './core/EventViewerRow';
import { HttpMethodTagRaw } from './core/HttpMethodTag';
import { HttpStatusTagRaw } from './core/HttpStatusTag';
import { Icon, type IconProps } from './core/Icon';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout';
interface Props {
response: HttpResponse;
@@ -25,121 +22,88 @@ export function HttpResponseTimeline({ response }: Props) {
}
function Inner({ response }: Props) {
const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null);
const [showRaw, setShowRaw] = useState(false);
const { data: events, error, isLoading } = useHttpResponseEvents(response);
const activeEvent = useMemo(
() => (activeEventIndex == null ? null : events?.[activeEventIndex]),
[activeEventIndex, events],
);
if (isLoading) {
return <div className="p-3 text-text-subtlest italic">Loading events...</div>;
}
if (error) {
return (
<Banner color="danger" className="m-3">
{String(error)}
</Banner>
);
}
if (!events || events.length === 0) {
return <div className="p-3 text-text-subtlest italic">No events recorded</div>;
}
return (
<SplitLayout
layout="vertical"
name="http_response_events"
<EventViewer
events={events ?? []}
getEventKey={(event) => event.id}
error={error ? String(error) : null}
isLoading={isLoading}
loadingMessage="Loading events..."
emptyMessage="No events recorded"
splitLayoutName="http_response_events"
defaultRatio={0.25}
minHeightPx={10}
firstSlot={() => (
<AutoScroller
data={events}
render={(event, i) => (
<EventRow
key={event.id}
event={event}
isActive={i === activeEventIndex}
onClick={() => {
if (i === activeEventIndex) setActiveEventIndex(null);
else setActiveEventIndex(i);
}}
/>
)}
/>
renderRow={({ event, isActive, onClick }) => {
const display = getEventDisplay(event.event);
return (
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon color={display.color} icon={display.icon} size="sm" />}
content={display.summary}
timestamp={event.createdAt}
/>
);
}}
renderDetail={({ event }) => (
<EventDetails event={event} showRaw={showRaw} setShowRaw={setShowRaw} />
)}
secondSlot={
activeEvent
? () => (
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
<div className="pb-3 px-2">
<Separator />
</div>
<div className="mx-2 overflow-y-auto">
<EventDetails event={activeEvent} />
</div>
</div>
)
: null
}
/>
);
}
function EventRow({
onClick,
isActive,
event,
}: {
onClick: () => void;
isActive: boolean;
event: HttpResponseEvent;
}) {
const display = getEventDisplay(event.event);
const { icon, color, summary } = display;
return (
<div className="px-1">
<button
type="button"
onClick={onClick}
className={classNames(
'w-full grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2 items-center text-left',
'px-1.5 h-xs font-mono text-editor cursor-default group focus:outline-none focus:text-text rounded',
isActive && '!bg-surface-active !text-text',
'text-text-subtle hover:text',
)}
>
<Icon color={color} icon={icon} size="sm" />
<div className="w-full truncate">{summary}</div>
<div className="opacity-50">{format(`${event.createdAt}Z`, 'HH:mm:ss.SSS')}</div>
</button>
</div>
);
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function EventDetails({ event }: { event: HttpResponseEvent }) {
function EventDetails({
event,
showRaw,
setShowRaw,
}: {
event: HttpResponseEvent;
showRaw: boolean;
setShowRaw: (v: boolean) => void;
}) {
const { label } = getEventDisplay(event.event);
const timestamp = format(new Date(`${event.createdAt}Z`), 'HH:mm:ss.SSS');
const e = event.event;
const actions: EventDetailAction[] = [
{
key: 'toggle-raw',
label: showRaw ? 'Formatted' : 'Text',
onClick: () => setShowRaw(!showRaw),
},
];
// Determine the title based on event type
const title =
e.type === 'header_up'
? 'Header Sent'
: e.type === 'header_down'
? 'Header Received'
: label;
// Raw view - show plaintext representation
if (showRaw) {
const rawText = formatEventRaw(event.event);
return (
<div className="flex flex-col gap-2 h-full">
<EventDetailHeader title={title} timestamp={event.createdAt} actions={actions} />
<Editor language="text" defaultValue={rawText} readOnly stateKey={null} />
</div>
);
}
// Headers - show name and value with Editor for JSON
if (e.type === 'header_up' || e.type === 'header_down') {
return (
<div className="flex flex-col gap-2 h-full">
<DetailHeader
title={e.type === 'header_down' ? 'Header Received' : 'Header Sent'}
timestamp={timestamp}
/>
<EventDetailHeader title={title} timestamp={event.createdAt} actions={actions} />
<KeyValueRows>
<KeyValueRow label="Header">{e.name}</KeyValueRow>
<KeyValueRow label="Value">{e.value}</KeyValueRow>
@@ -152,7 +116,7 @@ function EventDetails({ event }: { event: HttpResponseEvent }) {
if (e.type === 'send_url') {
return (
<div className="flex flex-col gap-2">
<DetailHeader title="Request" timestamp={timestamp} />
<EventDetailHeader title="Request" timestamp={event.createdAt} actions={actions} />
<KeyValueRows>
<KeyValueRow label="Method">
<HttpMethodTagRaw forceColor method={e.method} />
@@ -167,7 +131,7 @@ function EventDetails({ event }: { event: HttpResponseEvent }) {
if (e.type === 'receive_url') {
return (
<div className="flex flex-col gap-2">
<DetailHeader title="Response" timestamp={timestamp} />
<EventDetailHeader title="Response" timestamp={event.createdAt} actions={actions} />
<KeyValueRows>
<KeyValueRow label="HTTP Version">{e.version}</KeyValueRow>
<KeyValueRow label="Status">
@@ -182,7 +146,7 @@ function EventDetails({ event }: { event: HttpResponseEvent }) {
if (e.type === 'redirect') {
return (
<div className="flex flex-col gap-2">
<DetailHeader title="Redirect" timestamp={timestamp} />
<EventDetailHeader title="Redirect" timestamp={event.createdAt} actions={actions} />
<KeyValueRows>
<KeyValueRow label="Status">
<HttpStatusTagRaw status={e.status} />
@@ -200,7 +164,7 @@ function EventDetails({ event }: { event: HttpResponseEvent }) {
if (e.type === 'setting') {
return (
<div className="flex flex-col gap-2">
<DetailHeader title="Apply Setting" timestamp={timestamp} />
<EventDetailHeader title="Apply Setting" timestamp={event.createdAt} actions={actions} />
<KeyValueRows>
<KeyValueRow label="Setting">{e.name}</KeyValueRow>
<KeyValueRow label="Value">{e.value}</KeyValueRow>
@@ -214,7 +178,11 @@ function EventDetails({ event }: { event: HttpResponseEvent }) {
const direction = e.type === 'chunk_sent' ? 'Sent' : 'Received';
return (
<div className="flex flex-col gap-2">
<DetailHeader title={`Data ${direction}`} timestamp={timestamp} />
<EventDetailHeader
title={`Data ${direction}`}
timestamp={event.createdAt}
actions={actions}
/>
<div className="font-mono text-editor">{formatBytes(e.bytes)}</div>
</div>
);
@@ -224,19 +192,36 @@ function EventDetails({ event }: { event: HttpResponseEvent }) {
const { summary } = getEventDisplay(event.event);
return (
<div className="flex flex-col gap-1">
<DetailHeader title={label} timestamp={timestamp} />
<EventDetailHeader title={label} timestamp={event.createdAt} actions={actions} />
<div className="font-mono text-editor">{summary}</div>
</div>
);
}
function DetailHeader({ title, timestamp }: { title: string; timestamp: string }) {
return (
<div className="flex items-center justify-between gap-2">
<h3 className="font-semibold select-auto cursor-auto">{title}</h3>
<span className="text-text-subtlest font-mono text-editor">{timestamp}</span>
</div>
);
/** Format event as raw plaintext for debugging */
function formatEventRaw(event: HttpResponseEventData): string {
switch (event.type) {
case 'send_url':
return `${event.method} ${event.path}`;
case 'receive_url':
return `${event.version} ${event.status}`;
case 'header_up':
return `${event.name}: ${event.value}`;
case 'header_down':
return `${event.name}: ${event.value}`;
case 'redirect':
return `${event.status} Redirect: ${event.url}`;
case 'setting':
return `${event.name} = ${event.value}`;
case 'info':
return `${event.message}`;
case 'chunk_sent':
return `[${formatBytes(event.bytes)} sent]`;
case 'chunk_received':
return `[${formatBytes(event.bytes)} received]`;
default:
return '[unknown event]';
}
}
type EventDisplay = {

View File

@@ -1,9 +1,7 @@
import type { WebsocketEvent, WebsocketRequest } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { format } from 'date-fns';
import { hexy } from 'hexy';
import { useAtomValue } from 'jotai';
import { useMemo, useRef, useState } from 'react';
import { useMemo, useState } from 'react';
import { useFormatText } from '../hooks/useFormatText';
import {
activeWebsocketConnectionAtom,
@@ -13,17 +11,13 @@ import {
} from '../hooks/usePinnedWebsocketConnection';
import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { languageFromContentType } from '../lib/contentType';
import { copyToClipboard } from '../lib/copy';
import { AutoScroller } from './core/AutoScroller';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { Editor } from './core/Editor/LazyEditor';
import { EventDetailHeader, EventViewer, type EventDetailAction } from './core/EventViewer';
import { EventViewerRow } from './core/EventViewerRow';
import { HotkeyList } from './core/HotkeyList';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { LoadingIcon } from './core/LoadingIcon';
import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout';
import { HStack, VStack } from './core/Stacks';
import { WebsocketStatusTag } from './core/WebsocketStatusTag';
import { EmptyStateText } from './EmptyStateText';
@@ -35,227 +29,199 @@ interface Props {
}
export function WebsocketResponsePane({ activeRequest }: Props) {
const [activeEventId, setActiveEventId] = useState<string | null>(null);
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
const [showingLarge, setShowingLarge] = useState<boolean>(false);
const [hexDumps, setHexDumps] = useState<Record<string, boolean>>({});
const [hexDumps, setHexDumps] = useState<Record<number, boolean>>({});
const activeConnection = useAtomValue(activeWebsocketConnectionAtom);
const connections = useAtomValue(activeWebsocketConnectionsAtom);
const events = useWebsocketEvents(activeConnection?.id ?? null);
const activeEvent = useMemo(
() => events.find((m) => m.id === activeEventId) ?? null,
[activeEventId, events],
if (activeConnection == null) {
return (
<HotkeyList hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']} />
);
}
const header = (
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle">
<HStack space={2}>
{activeConnection.state !== 'closed' && (
<LoadingIcon size="sm" className="text-text-subtlest" />
)}
<WebsocketStatusTag connection={activeConnection} />
<span>&bull;</span>
<span>{events.length} Messages</span>
</HStack>
<HStack space={0.5} className="ml-auto">
<RecentWebsocketConnectionsDropdown
connections={connections}
activeConnection={activeConnection}
onPinnedConnectionId={setPinnedWebsocketConnectionId}
/>
</HStack>
</HStack>
);
const hexDump = hexDumps[activeEventId ?? 'n/a'] ?? activeEvent?.messageType === 'binary';
const message = useMemo(() => {
if (hexDump) {
return activeEvent?.message ? hexy(activeEvent?.message) : '';
}
return activeEvent?.message
? new TextDecoder('utf-8').decode(Uint8Array.from(activeEvent.message))
: '';
}, [activeEvent?.message, hexDump]);
const language = languageFromContentType(null, message);
const formattedMessage = useFormatText({ language, text: message, pretty: true });
return (
<SplitLayout
layout="vertical"
name="grpc_events"
defaultRatio={0.4}
minHeightPx={20}
firstSlot={() =>
activeConnection == null ? (
<HotkeyList
hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']}
<ErrorBoundary name="Websocket Events">
<EventViewer
events={events}
getEventKey={(event) => event.id}
error={activeConnection.error}
header={header}
splitLayoutName="websocket_events"
defaultRatio={0.4}
renderRow={({ event, isActive, onClick }) => (
<WebsocketEventRow event={event} isActive={isActive} onClick={onClick} />
)}
renderDetail={({ event, index }) => (
<WebsocketEventDetail
event={event}
hexDump={hexDumps[index] ?? event.messageType === 'binary'}
setHexDump={(v) => setHexDumps({ ...hexDumps, [index]: v })}
showLarge={showLarge}
showingLarge={showingLarge}
setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge}
/>
) : (
<div className="w-full grid grid-rows-[auto_minmax(0,1fr)] items-center">
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle">
<HStack space={2}>
{activeConnection.state !== 'closed' && (
<LoadingIcon size="sm" className="text-text-subtlest" />
)}
<WebsocketStatusTag connection={activeConnection} />
<span>&bull;</span>
<span>{events.length} Messages</span>
</HStack>
<HStack space={0.5} className="ml-auto">
<RecentWebsocketConnectionsDropdown
connections={connections}
activeConnection={activeConnection}
onPinnedConnectionId={setPinnedWebsocketConnectionId}
/>
</HStack>
</HStack>
<ErrorBoundary name="Websocket Events">
<AutoScroller
data={events}
header={
activeConnection.error && (
<Banner color="danger" className="m-3">
{activeConnection.error}
</Banner>
)
}
render={(event) => (
<EventRow
key={event.id}
event={event}
isActive={event.id === activeEventId}
onClick={() => {
if (event.id === activeEventId) setActiveEventId(null);
else setActiveEventId(event.id);
}}
/>
)}
/>
</ErrorBoundary>
</div>
)
}
secondSlot={
activeEvent != null && activeConnection != null
? () => (
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
<div className="pb-3 px-2">
<Separator />
</div>
<div className="mx-2 overflow-y-auto grid grid-rows-[auto_minmax(0,1fr)]">
<div className="h-xs mb-2 grid grid-cols-[minmax(0,1fr)_auto] items-center">
<div className="font-semibold">
{activeEvent.messageType === 'close'
? 'Connection Closed'
: activeEvent.messageType === 'open'
? 'Connection open'
: `Message ${activeEvent.isServer ? 'Received' : 'Sent'}`}
</div>
{message !== '' && (
<HStack space={1}>
<Button
variant="border"
size="xs"
onClick={() => {
if (activeEventId == null) return;
setHexDumps({ ...hexDumps, [activeEventId]: !hexDump });
}}
>
{hexDump ? 'Show Message' : 'Show Hexdump'}
</Button>
<IconButton
title="Copy message"
icon="copy"
size="xs"
onClick={() => copyToClipboard(formattedMessage ?? '')}
/>
</HStack>
)}
</div>
{!showLarge && activeEvent.message.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden
<div>
<Button
onClick={() => {
setShowingLarge(true);
setTimeout(() => {
setShowLarge(true);
setShowingLarge(false);
}, 500);
}}
isLoading={showingLarge}
color="secondary"
variant="border"
size="xs"
>
Try Showing
</Button>
</div>
</VStack>
) : activeEvent.message.length === 0 ? (
<EmptyStateText>No Content</EmptyStateText>
) : (
<Editor
language={language}
defaultValue={formattedMessage ?? ''}
wrapLines={false}
readOnly={true}
stateKey={null}
/>
)}
</div>
</div>
)
: null
}
/>
)}
/>
</ErrorBoundary>
);
}
function EventRow({
onClick,
isActive,
function WebsocketEventRow({
event,
isActive,
onClick,
}: {
onClick?: () => void;
isActive?: boolean;
event: WebsocketEvent;
isActive: boolean;
onClick: () => void;
}) {
const { createdAt, message: messageBytes, isServer, messageType } = event;
const ref = useRef<HTMLDivElement>(null);
const { message: messageBytes, isServer, messageType } = event;
const message = messageBytes
? new TextDecoder('utf-8').decode(Uint8Array.from(messageBytes))
: '';
const iconColor =
messageType === 'close' || messageType === 'open' ? 'secondary' : isServer ? 'info' : 'primary';
const icon =
messageType === 'close' || messageType === 'open'
? 'info'
: isServer
? 'arrow_big_down_dash'
: 'arrow_big_up_dash';
const content =
messageType === 'close' ? (
'Disconnected from server'
) : messageType === 'open' ? (
'Connected to server'
) : message === '' ? (
<em className="italic text-text-subtlest">No content</em>
) : (
<span className="text-xs">{message.slice(0, 1000)}</span>
);
return (
<div className="px-1" ref={ref}>
<button
type="button"
onClick={onClick}
className={classNames(
'w-full grid grid-cols-[auto_minmax(0,3fr)_auto] gap-2 items-center text-left',
'px-1.5 h-xs font-mono cursor-default group focus:outline-none focus:text-text rounded',
isActive && '!bg-surface-active !text-text',
'text-text-subtle hover:text',
)}
>
<Icon
color={
messageType === 'close' || messageType === 'open'
? 'secondary'
: isServer
? 'info'
: 'primary'
}
icon={
messageType === 'close' || messageType === 'open'
? 'info'
: isServer
? 'arrow_big_down_dash'
: 'arrow_big_up_dash'
}
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon color={iconColor} icon={icon} />}
content={content}
timestamp={event.createdAt}
/>
);
}
function WebsocketEventDetail({
event,
hexDump,
setHexDump,
showLarge,
showingLarge,
setShowLarge,
setShowingLarge,
}: {
event: WebsocketEvent;
hexDump: boolean;
setHexDump: (v: boolean) => void;
showLarge: boolean;
showingLarge: boolean;
setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void;
}) {
const message = useMemo(() => {
if (hexDump) {
return event.message ? hexy(event.message) : '';
}
return event.message ? new TextDecoder('utf-8').decode(Uint8Array.from(event.message)) : '';
}, [event.message, hexDump]);
const language = languageFromContentType(null, message);
const formattedMessage = useFormatText({ language, text: message, pretty: true });
const title =
event.messageType === 'close'
? 'Connection Closed'
: event.messageType === 'open'
? 'Connection Open'
: `Message ${event.isServer ? 'Received' : 'Sent'}`;
const actions: EventDetailAction[] =
message !== ''
? [
{
key: 'toggle-hexdump',
label: hexDump ? 'Show Message' : 'Show Hexdump',
onClick: () => setHexDump(!hexDump),
},
]
: [];
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<EventDetailHeader
title={title}
timestamp={event.createdAt}
actions={actions}
copyText={formattedMessage || undefined}
/>
<div className={classNames('w-full truncate text-xs')}>
{messageType === 'close' ? (
'Disconnected from server'
) : messageType === 'open' ? (
'Connected to server'
) : message === '' ? (
<em className="italic text-text-subtlest">No content</em>
) : (
message.slice(0, 1000)
)}
{/*{error && <span className="text-warning"> ({error})</span>}*/}
</div>
<div className={classNames('opacity-50 text-xs')}>
{format(`${createdAt}Z`, 'HH:mm:ss.SSS')}
</div>
</button>
{!showLarge && event.message.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden
<div>
<Button
onClick={() => {
setShowingLarge(true);
setTimeout(() => {
setShowLarge(true);
setShowingLarge(false);
}, 500);
}}
isLoading={showingLarge}
color="secondary"
variant="border"
size="xs"
>
Try Showing
</Button>
</div>
</VStack>
) : event.message.length === 0 ? (
<EmptyStateText>No Content</EmptyStateText>
) : (
<Editor
language={language}
defaultValue={formattedMessage ?? ''}
wrapLines={false}
readOnly={true}
stateKey={null}
/>
)}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useVirtualizer } from '@tanstack/react-virtual';
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual';
import type { ReactElement, ReactNode, UIEvent } from 'react';
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { IconButton } from './IconButton';
@@ -7,9 +7,19 @@ interface Props<T> {
data: T[];
render: (item: T, index: number) => ReactElement<HTMLElement>;
header?: ReactNode;
/** Make container focusable for keyboard navigation */
focusable?: boolean;
/** Callback to expose the virtualizer for keyboard navigation */
onVirtualizerReady?: (virtualizer: Virtualizer<HTMLDivElement, Element>) => void;
}
export function AutoScroller<T>({ data, render, header }: Props<T>) {
export function AutoScroller<T>({
data,
render,
header,
focusable = false,
onVirtualizerReady,
}: Props<T>) {
const containerRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState<boolean>(true);
@@ -20,6 +30,11 @@ export function AutoScroller<T>({ data, render, header }: Props<T>) {
estimateSize: () => 27, // react-virtual requires a height, so we'll give it one
});
// Expose virtualizer to parent for keyboard navigation
useLayoutEffect(() => {
onVirtualizerReady?.(rowVirtualizer);
}, [rowVirtualizer, onVirtualizerReady]);
// Scroll to new items
const handleScroll = useCallback(
(e: UIEvent<HTMLDivElement>) => {
@@ -48,7 +63,7 @@ export function AutoScroller<T>({ data, render, header }: Props<T>) {
}, [autoScroll, data.length]);
return (
<div className="h-full w-full relative grid grid-rows-[minmax(0,auto)_minmax(0,1fr)]">
<div className="h-full w-full relative grid grid-rows-[auto_minmax(0,1fr)]">
{!autoScroll && (
<div className="absolute bottom-0 right-0 m-2">
<IconButton
@@ -63,7 +78,12 @@ export function AutoScroller<T>({ data, render, header }: Props<T>) {
</div>
)}
{header ?? <span aria-hidden />}
<div ref={containerRef} className="h-full w-full overflow-y-auto" onScroll={handleScroll}>
<div
ref={containerRef}
className="h-full w-full overflow-y-auto"
onScroll={handleScroll}
tabIndex={focusable ? 0 : undefined}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,

View File

@@ -766,8 +766,24 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
</m.div>
);
// Hotkeys must be rendered even when menu is closed (so they work globally)
const hotKeyElements = items.map(
(item, i) =>
item.type !== 'separator' &&
item.type !== 'content' &&
!item.hotKeyLabelOnly &&
item.hotKeyAction && (
<MenuItemHotKey
key={`${item.hotKeyAction}::${i}`}
onSelect={handleSelect}
item={item}
action={item.hotKeyAction}
/>
),
);
if (!isOpen) {
return null;
return <>{hotKeyElements}</>;
}
if (isSubmenu) {
@@ -776,20 +792,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
return (
<>
{items.map(
(item, i) =>
item.type !== 'separator' &&
item.type !== 'content' &&
!item.hotKeyLabelOnly &&
item.hotKeyAction && (
<MenuItemHotKey
key={`${item.hotKeyAction}::${i}`}
onSelect={handleSelect}
item={item}
action={item.hotKeyAction}
/>
),
)}
{hotKeyElements}
<Overlay noBackdrop open={isOpen} portalName="dropdown-menu">
{menuContent}
</Overlay>

View File

@@ -0,0 +1,239 @@
import type { Virtualizer } from '@tanstack/react-virtual';
import { format } from 'date-fns';
import type { ReactNode } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useEventViewerKeyboard } from '../../hooks/useEventViewerKeyboard';
import { CopyIconButton } from '../CopyIconButton';
import { AutoScroller } from './AutoScroller';
import { Banner } from './Banner';
import { Button } from './Button';
import { Separator } from './Separator';
import { SplitLayout } from './SplitLayout';
import { HStack } from './Stacks';
interface EventViewerProps<T> {
/** Array of events to display */
events: T[];
/** Get unique key for each event */
getEventKey: (event: T, index: number) => string;
/** Render the event row - receives event, index, isActive, and onClick */
renderRow: (props: {
event: T;
index: number;
isActive: boolean;
onClick: () => void;
}) => ReactNode;
/** Render the detail pane for the selected event */
renderDetail?: (props: { event: T; index: number }) => ReactNode;
/** Optional header above the event list (e.g., connection status) */
header?: ReactNode;
/** Error message to display as a banner */
error?: string | null;
/** Name for SplitLayout state persistence */
splitLayoutName: string;
/** Default ratio for the split (0.0 - 1.0) */
defaultRatio?: number;
/** Enable keyboard navigation (arrow keys) */
enableKeyboardNav?: boolean;
/** Loading state */
isLoading?: boolean;
/** Message to show while loading */
loadingMessage?: string;
/** Message to show when no events */
emptyMessage?: string;
/** Callback when active index changes (for controlled state in parent) */
onActiveIndexChange?: (index: number | null) => void;
}
export function EventViewer<T>({
events,
getEventKey,
renderRow,
renderDetail,
header,
error,
splitLayoutName,
defaultRatio = 0.4,
enableKeyboardNav = true,
isLoading = false,
loadingMessage = 'Loading events...',
emptyMessage = 'No events recorded',
onActiveIndexChange,
}: EventViewerProps<T>) {
const [activeIndex, setActiveIndexInternal] = useState<number | null>(null);
// Wrap setActiveIndex to notify parent
const setActiveIndex = useCallback(
(indexOrUpdater: number | null | ((prev: number | null) => number | null)) => {
setActiveIndexInternal((prev) => {
const newIndex =
typeof indexOrUpdater === 'function' ? indexOrUpdater(prev) : indexOrUpdater;
onActiveIndexChange?.(newIndex);
return newIndex;
});
},
[onActiveIndexChange],
);
const containerRef = useRef<HTMLDivElement>(null);
const virtualizerRef = useRef<Virtualizer<HTMLDivElement, Element> | null>(null);
const activeEvent = useMemo(
() => (activeIndex != null ? events[activeIndex] : null),
[activeIndex, events],
);
// Check if the event list container is focused
const isContainerFocused = useCallback(() => {
return containerRef.current?.contains(document.activeElement) ?? false;
}, []);
// Keyboard navigation
useEventViewerKeyboard({
totalCount: events.length,
activeIndex,
setActiveIndex,
virtualizer: virtualizerRef.current,
isContainerFocused,
enabled: enableKeyboardNav,
});
// Handle virtualizer ready callback
const handleVirtualizerReady = useCallback(
(virtualizer: Virtualizer<HTMLDivElement, Element>) => {
virtualizerRef.current = virtualizer;
},
[],
);
// Toggle selection on click
const handleRowClick = useCallback(
(index: number) => {
setActiveIndex((prev) => (prev === index ? null : index));
},
[setActiveIndex],
);
if (isLoading) {
return <div className="p-3 text-text-subtlest italic">{loadingMessage}</div>;
}
if (events.length === 0 && !error) {
return <div className="p-3 text-text-subtlest italic">{emptyMessage}</div>;
}
return (
<div ref={containerRef} className="h-full">
<SplitLayout
layout="vertical"
name={splitLayoutName}
defaultRatio={defaultRatio}
minHeightPx={10}
firstSlot={({ style }) => (
<div style={style} className="w-full h-full grid grid-rows-[auto_minmax(0,1fr)]">
{header ?? <span aria-hidden />}
<AutoScroller
data={events}
focusable={enableKeyboardNav}
onVirtualizerReady={handleVirtualizerReady}
header={
error && (
<Banner color="danger" className="m-3">
{error}
</Banner>
)
}
render={(event, index) => (
<div key={getEventKey(event, index)}>
{renderRow({
event,
index,
isActive: index === activeIndex,
onClick: () => handleRowClick(index),
})}
</div>
)}
/>
</div>
)}
secondSlot={
activeEvent != null && renderDetail
? ({ style }) => (
<div style={style} className="grid grid-rows-[auto_minmax(0,1fr)] bg-surface">
<div className="pb-3 px-2">
<Separator />
</div>
<div className="mx-2 overflow-y-auto">
{renderDetail({ event: activeEvent, index: activeIndex ?? 0 })}
</div>
</div>
)
: null
}
/>
</div>
);
}
export interface EventDetailAction {
/** Unique key for React */
key: string;
/** Button label */
label: string;
/** Optional icon */
icon?: ReactNode;
/** Click handler */
onClick: () => void;
}
interface EventDetailHeaderProps {
/** Title/label for the event */
title: string;
/** Timestamp string (ISO format) - will be formatted as HH:mm:ss.SSS */
timestamp?: string;
/** Optional action buttons to show before timestamp */
actions?: EventDetailAction[];
/** Text to copy when copy button is clicked - renders a copy icon button after actions */
copyText?: string;
}
/** Standardized header for event detail panes */
export function EventDetailHeader({
title,
timestamp,
actions,
copyText,
}: EventDetailHeaderProps) {
const formattedTime = timestamp ? format(new Date(`${timestamp}Z`), 'HH:mm:ss.SSS') : null;
return (
<div className="flex items-center justify-between gap-2 mb-2 h-xs">
<h3 className="font-semibold select-auto cursor-auto">{title}</h3>
<HStack space={2} className="items-center">
{actions?.map((action) => (
<Button key={action.key} variant="border" size="xs" onClick={action.onClick}>
{action.icon}
{action.label}
</Button>
))}
{copyText != null && (
<CopyIconButton text={copyText} size="xs" title="Copy" variant="border" iconSize="sm" />
)}
{formattedTime && (
<span className="text-text-subtlest font-mono text-editor">{formattedTime}</span>
)}
</HStack>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import classNames from 'classnames';
import { format } from 'date-fns';
import type { ReactNode } from 'react';
interface EventViewerRowProps {
isActive: boolean;
onClick: () => void;
icon: ReactNode;
content: ReactNode;
timestamp: string;
}
export function EventViewerRow({
isActive,
onClick,
icon,
content,
timestamp,
}: EventViewerRowProps) {
return (
<div className="px-1">
<button
type="button"
onClick={onClick}
className={classNames(
'w-full grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2 items-center text-left',
'px-1.5 h-xs font-mono text-editor cursor-default group focus:outline-none focus:text-text rounded',
isActive && '!bg-surface-active !text-text',
'text-text-subtle hover:text',
)}
>
{icon}
<div className="w-full truncate">{content}</div>
<div className="opacity-50">{format(`${timestamp}Z`, 'HH:mm:ss.SSS')}</div>
</button>
</div>
);
}

View File

@@ -5,15 +5,13 @@ import { Fragment, useMemo, useState } from 'react';
import { useFormatText } from '../../hooks/useFormatText';
import { useResponseBodyEventSource } from '../../hooks/useResponseBodyEventSource';
import { isJSON } from '../../lib/contentType';
import { AutoScroller } from '../core/AutoScroller';
import { Banner } from '../core/Banner';
import { Button } from '../core/Button';
import type { EditorProps } from '../core/Editor/Editor';
import { Editor } from '../core/Editor/LazyEditor';
import { EventDetailHeader, EventViewer } from '../core/EventViewer';
import { EventViewerRow } from '../core/EventViewerRow';
import { Icon } from '../core/Icon';
import { InlineCode } from '../core/InlineCode';
import { Separator } from '../core/Separator';
import { SplitLayout } from '../core/SplitLayout';
import { HStack, VStack } from '../core/Stacks';
interface Props {
@@ -33,134 +31,97 @@ export function EventStreamViewer({ response }: Props) {
function ActualEventStreamViewer({ response }: Props) {
const [showLarge, setShowLarge] = useState<boolean>(false);
const [showingLarge, setShowingLarge] = useState<boolean>(false);
const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null);
const events = useResponseBodyEventSource(response);
const activeEvent = useMemo(
() => (activeEventIndex == null ? null : events.data?.[activeEventIndex]),
[activeEventIndex, events],
);
const language = useMemo<'text' | 'json'>(() => {
if (!activeEvent?.data) return 'text';
return isJSON(activeEvent?.data) ? 'json' : 'text';
}, [activeEvent?.data]);
return (
<SplitLayout
layout="vertical"
name="grpc_events"
<EventViewer
events={events.data ?? []}
getEventKey={(_, index) => String(index)}
error={events.error ? String(events.error) : null}
splitLayoutName="sse_events"
defaultRatio={0.4}
minHeightPx={20}
firstSlot={() => (
<AutoScroller
data={events.data ?? []}
header={
events.error && (
<Banner color="danger" className="m-3">
{String(events.error)}
</Banner>
)
renderRow={({ event, index, isActive, onClick }) => (
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon color="info" title="Server Message" icon="arrow_big_down_dash" />}
content={
<HStack space={2} className="items-center">
<EventLabels event={event} index={index} isActive={isActive} />
<span className="truncate text-xs">{event.data.slice(0, 1000)}</span>
</HStack>
}
render={(event, i) => (
<EventRow
event={event}
isActive={i === activeEventIndex}
index={i}
onClick={() => {
if (i === activeEventIndex) setActiveEventIndex(null);
else setActiveEventIndex(i);
}}
/>
)}
timestamp={new Date().toISOString().slice(0, -1)} // SSE events don't have timestamps
/>
)}
renderDetail={({ event }) => (
<EventDetail
event={event}
showLarge={showLarge}
showingLarge={showingLarge}
setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge}
/>
)}
secondSlot={
activeEvent
? () => (
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
<div className="pb-3 px-2">
<Separator />
</div>
<div className="flex flex-col pl-2">
<HStack space={1.5} className="mb-2 font-semibold">
<EventLabels
className="text-sm"
event={activeEvent}
index={activeEventIndex ?? 0}
/>
Message Received
</HStack>
{!showLarge && activeEvent.data.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden
<div>
<Button
onClick={() => {
setShowingLarge(true);
setTimeout(() => {
setShowLarge(true);
setShowingLarge(false);
}, 500);
}}
isLoading={showingLarge}
color="secondary"
variant="border"
size="xs"
>
Try Showing
</Button>
</div>
</VStack>
) : (
<FormattedEditor language={language} text={activeEvent.data} />
)}
</div>
</div>
)
: null
}
/>
);
}
function EventDetail({
event,
showLarge,
showingLarge,
setShowLarge,
setShowingLarge,
}: {
event: ServerSentEvent;
showLarge: boolean;
showingLarge: boolean;
setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void;
}) {
const language = useMemo<'text' | 'json'>(() => {
if (!event?.data) return 'text';
return isJSON(event?.data) ? 'json' : 'text';
}, [event?.data]);
return (
<div className="flex flex-col h-full">
<EventDetailHeader title="Message Received" />
{!showLarge && event.data.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden
<div>
<Button
onClick={() => {
setShowingLarge(true);
setTimeout(() => {
setShowLarge(true);
setShowingLarge(false);
}, 500);
}}
isLoading={showingLarge}
color="secondary"
variant="border"
size="xs"
>
Try Showing
</Button>
</div>
</VStack>
) : (
<FormattedEditor language={language} text={event.data} />
)}
</div>
);
}
function FormattedEditor({ text, language }: { text: string; language: EditorProps['language'] }) {
const formatted = useFormatText({ text, language, pretty: true });
if (formatted == null) return null;
return <Editor readOnly defaultValue={formatted} language={language} stateKey={null} />;
}
function EventRow({
onClick,
isActive,
event,
className,
index,
}: {
onClick: () => void;
isActive: boolean;
event: ServerSentEvent;
className?: string;
index: number;
}) {
return (
<button
type="button"
onClick={onClick}
className={classNames(
className,
'w-full grid grid-cols-[auto_auto_minmax(0,3fr)] gap-2 items-center text-left',
'-mx-1.5 px-1.5 h-xs font-mono group focus:outline-none focus:text-text rounded',
isActive && '!bg-surface-active !text-text',
'text-text-subtle hover:text',
)}
>
<Icon color="info" title="Server Message" icon="arrow_big_down_dash" />
<EventLabels className="text-sm" event={event} isActive={isActive} index={index} />
<div className={classNames('w-full truncate text-xs')}>{event.data.slice(0, 1000)}</div>
</button>
);
}
function EventLabels({
className,
event,
@@ -169,7 +130,7 @@ function EventLabels({
}: {
event: ServerSentEvent;
index: number;
className: string;
className?: string;
isActive?: boolean;
}) {
return (

View File

@@ -0,0 +1,70 @@
import type { Virtualizer } from '@tanstack/react-virtual';
import { useCallback } from 'react';
import { useKey } from 'react-use';
interface UseEventViewerKeyboardProps {
totalCount: number;
activeIndex: number | null;
setActiveIndex: (index: number | null) => void;
virtualizer?: Virtualizer<HTMLDivElement, Element> | null;
isContainerFocused: () => boolean;
enabled?: boolean;
}
export function useEventViewerKeyboard({
totalCount,
activeIndex,
setActiveIndex,
virtualizer,
isContainerFocused,
enabled = true,
}: UseEventViewerKeyboardProps) {
const selectPrev = useCallback(() => {
if (totalCount === 0) return;
const newIndex = activeIndex == null ? 0 : Math.max(0, activeIndex - 1);
setActiveIndex(newIndex);
virtualizer?.scrollToIndex(newIndex, { align: 'auto' });
}, [activeIndex, setActiveIndex, totalCount, virtualizer]);
const selectNext = useCallback(() => {
if (totalCount === 0) return;
const newIndex = activeIndex == null ? 0 : Math.min(totalCount - 1, activeIndex + 1);
setActiveIndex(newIndex);
virtualizer?.scrollToIndex(newIndex, { align: 'auto' });
}, [activeIndex, setActiveIndex, totalCount, virtualizer]);
useKey(
(e) => e.key === 'ArrowUp' || e.key === 'k',
(e) => {
if (!enabled || !isContainerFocused()) return;
e.preventDefault();
selectPrev();
},
undefined,
[enabled, isContainerFocused, selectPrev],
);
useKey(
(e) => e.key === 'ArrowDown' || e.key === 'j',
(e) => {
if (!enabled || !isContainerFocused()) return;
e.preventDefault();
selectNext();
},
undefined,
[enabled, isContainerFocused, selectNext],
);
useKey(
(e) => e.key === 'Escape',
(e) => {
if (!enabled || !isContainerFocused()) return;
e.preventDefault();
setActiveIndex(null);
},
undefined,
[enabled, isContainerFocused, setActiveIndex],
);
}

View File

@@ -1,6 +1,10 @@
import { invoke } from '@tauri-apps/api/core';
import type { HttpResponse, HttpResponseEvent } from '@yaakapp-internal/models';
import { httpResponseEventsAtom, replaceModelsInStore } from '@yaakapp-internal/models';
import {
httpResponseEventsAtom,
mergeModelsInStore,
replaceModelsInStore,
} from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import { useEffect, useMemo } from 'react';
@@ -13,8 +17,10 @@ export function useHttpResponseEvents(response: HttpResponse | null) {
return;
}
// Use merge instead of replace to preserve events that came in via model_write
// while we were fetching from the database
invoke<HttpResponseEvent[]>('cmd_get_http_response_events', { responseId: response.id }).then(
(events) => replaceModelsInStore('http_response_event', events),
(events) => mergeModelsInStore('http_response_event', events),
);
}, [response?.id]);

View File

@@ -3,6 +3,7 @@ import type { GrpcConnection, GrpcEvent } from '@yaakapp-internal/models';
import {
grpcConnectionsAtom,
grpcEventsAtom,
mergeModelsInStore,
replaceModelsInStore,
} from '@yaakapp-internal/models';
import { atom, useAtomValue } from 'jotai';
@@ -67,8 +68,10 @@ export function useGrpcEvents(connectionId: string | null) {
return;
}
// Use merge instead of replace to preserve events that came in via model_write
// while we were fetching from the database
invoke<GrpcEvent[]>('models_grpc_events', { connectionId }).then((events) => {
replaceModelsInStore('grpc_event', events);
mergeModelsInStore('grpc_event', events);
});
}, [connectionId]);

View File

@@ -1,6 +1,7 @@
import { invoke } from '@tauri-apps/api/core';
import type { WebsocketConnection, WebsocketEvent } from '@yaakapp-internal/models';
import {
mergeModelsInStore,
replaceModelsInStore,
websocketConnectionsAtom,
websocketEventsAtom,
@@ -54,8 +55,10 @@ export function useWebsocketEvents(connectionId: string | null) {
return;
}
// Use merge instead of replace to preserve events that came in via model_write
// while we were fetching from the database
invoke<WebsocketEvent[]>('models_websocket_events', { connectionId }).then(
(events) => replaceModelsInStore('websocket_event', events),
(events) => mergeModelsInStore('websocket_event', events),
);
}, [connectionId]);

View File

@@ -21,6 +21,7 @@ import { stringToColor } from './color';
import { generateId } from './generateId';
import { jotaiStore } from './jotai';
import { showPrompt } from './prompt';
import { showPromptForm } from './prompt-form';
import { invokeCmd } from './tauri';
import { showToast } from './toast';
@@ -47,6 +48,27 @@ export function initGlobalListeners() {
},
};
await emit(event.id, result);
} else if (event.payload.type === 'prompt_form_request') {
const values = await showPromptForm({
id: event.payload.id,
title: event.payload.title,
description: event.payload.description,
inputs: event.payload.inputs,
confirmText: event.payload.confirmText,
cancelText: event.payload.cancelText,
});
const result: InternalEvent = {
id: generateId(),
replyId: event.id,
pluginName: event.pluginName,
pluginRefId: event.pluginRefId,
context: event.context,
payload: {
type: 'prompt_form_response',
values,
},
};
await emit(event.id, result);
}
});

View File

@@ -0,0 +1,95 @@
import type { HttpResponseEvent } from '@yaakapp-internal/models';
import { describe, expect, test } from 'vitest';
import { getCookieCounts } from './model_util';
function makeEvent(
type: string,
name: string,
value: string,
): HttpResponseEvent {
return {
id: 'test',
model: 'http_response_event',
responseId: 'resp',
workspaceId: 'ws',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
event: { type, name, value } as HttpResponseEvent['event'],
};
}
describe('getCookieCounts', () => {
test('returns zeros for undefined events', () => {
expect(getCookieCounts(undefined)).toEqual({ sent: 0, received: 0 });
});
test('returns zeros for empty events', () => {
expect(getCookieCounts([])).toEqual({ sent: 0, received: 0 });
});
test('counts single sent cookie', () => {
const events = [makeEvent('header_up', 'Cookie', 'session=abc123')];
expect(getCookieCounts(events)).toEqual({ sent: 1, received: 0 });
});
test('counts multiple sent cookies in one header', () => {
const events = [makeEvent('header_up', 'Cookie', 'a=1; b=2; c=3')];
expect(getCookieCounts(events)).toEqual({ sent: 3, received: 0 });
});
test('counts single received cookie', () => {
const events = [makeEvent('header_down', 'Set-Cookie', 'session=abc123; Path=/')];
expect(getCookieCounts(events)).toEqual({ sent: 0, received: 1 });
});
test('counts multiple received cookies from multiple headers', () => {
const events = [
makeEvent('header_down', 'Set-Cookie', 'a=1; Path=/'),
makeEvent('header_down', 'Set-Cookie', 'b=2; HttpOnly'),
makeEvent('header_down', 'Set-Cookie', 'c=3; Secure'),
];
expect(getCookieCounts(events)).toEqual({ sent: 0, received: 3 });
});
test('deduplicates sent cookies by name', () => {
const events = [
makeEvent('header_up', 'Cookie', 'session=old'),
makeEvent('header_up', 'Cookie', 'session=new'),
];
expect(getCookieCounts(events)).toEqual({ sent: 1, received: 0 });
});
test('deduplicates received cookies by name', () => {
const events = [
makeEvent('header_down', 'Set-Cookie', 'token=abc; Path=/'),
makeEvent('header_down', 'Set-Cookie', 'token=xyz; Path=/'),
];
expect(getCookieCounts(events)).toEqual({ sent: 0, received: 1 });
});
test('counts both sent and received cookies', () => {
const events = [
makeEvent('header_up', 'Cookie', 'a=1; b=2; c=3'),
makeEvent('header_down', 'Set-Cookie', 'x=10; Path=/'),
makeEvent('header_down', 'Set-Cookie', 'y=20; Path=/'),
makeEvent('header_down', 'Set-Cookie', 'z=30; Path=/'),
];
expect(getCookieCounts(events)).toEqual({ sent: 3, received: 3 });
});
test('ignores non-cookie headers', () => {
const events = [
makeEvent('header_up', 'Content-Type', 'application/json'),
makeEvent('header_down', 'Content-Length', '123'),
];
expect(getCookieCounts(events)).toEqual({ sent: 0, received: 0 });
});
test('handles case-insensitive header names', () => {
const events = [
makeEvent('header_up', 'COOKIE', 'a=1'),
makeEvent('header_down', 'SET-COOKIE', 'b=2; Path=/'),
];
expect(getCookieCounts(events)).toEqual({ sent: 1, received: 1 });
});
});

View File

@@ -1,4 +1,10 @@
import type { AnyModel, Cookie, Environment, HttpResponseHeader } from '@yaakapp-internal/models';
import type {
AnyModel,
Cookie,
Environment,
HttpResponseEvent,
HttpResponseHeader,
} from '@yaakapp-internal/models';
import { getMimeTypeFromContentType } from './contentType';
export const BODY_TYPE_NONE = null;
@@ -59,3 +65,30 @@ export function isSubEnvironment(environment: Environment): boolean {
export function isFolderEnvironment(environment: Environment): boolean {
return environment.parentModel === 'folder';
}
export function getCookieCounts(
events: HttpResponseEvent[] | undefined,
): { sent: number; received: number } {
if (!events) return { sent: 0, received: 0 };
// Use Sets to deduplicate by cookie name
const sentNames = new Set<string>();
const receivedNames = new Set<string>();
for (const event of events) {
const e = event.event;
if (e.type === 'header_up' && e.name.toLowerCase() === 'cookie') {
// Parse "Cookie: name=value; name2=value2" format
for (const pair of e.value.split(';')) {
const name = pair.split('=')[0]?.trim();
if (name) sentNames.add(name);
}
} else if (e.type === 'header_down' && e.name.toLowerCase() === 'set-cookie') {
// Parse "Set-Cookie: name=value; ..." - first part before ; is name=value
const name = e.value.split(';')[0]?.split('=')[0]?.trim();
if (name) receivedNames.add(name);
}
}
return { sent: sentNames.size, received: receivedNames.size };
}