mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-01-11 15:59:31 -05:00
Compare commits
2 Commits
main
...
omnara/pre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a52032988 | ||
|
|
4b7497a908 |
94
.github/workflows/release.yml
vendored
94
.github/workflows/release.yml
vendored
@@ -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,7 +88,6 @@ jobs:
|
||||
& $exe --version
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run bootstrap
|
||||
- run: npm run lint
|
||||
- name: Run JS Tests
|
||||
run: npm test
|
||||
@@ -100,29 +99,6 @@ 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 }}
|
||||
@@ -145,9 +121,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'
|
||||
|
||||
@@ -178,11 +178,14 @@ async fn send_http_request_inner<R: Runtime>(
|
||||
window.db().resolve_environments(&workspace.id, folder_id, environment_id.as_deref())?;
|
||||
let request = render_http_request(&resolved, env_chain, &cb, &RenderOptions::throw()).await?;
|
||||
|
||||
// Resolve inherited settings for this request
|
||||
let resolved_settings = window.db().resolve_settings_for_http_request(&resolved)?;
|
||||
|
||||
// Build the sendable request using the new SendableHttpRequest type
|
||||
let options = SendableHttpRequestOptions {
|
||||
follow_redirects: workspace.setting_follow_redirects,
|
||||
timeout: if workspace.setting_request_timeout > 0 {
|
||||
Some(Duration::from_millis(workspace.setting_request_timeout.unsigned_abs() as u64))
|
||||
follow_redirects: resolved_settings.follow_redirects,
|
||||
timeout: if resolved_settings.request_timeout > 0 {
|
||||
Some(Duration::from_millis(resolved_settings.request_timeout.unsigned_abs() as u64))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
@@ -231,7 +234,7 @@ async fn send_http_request_inner<R: Runtime>(
|
||||
let client = connection_manager
|
||||
.get_client(&HttpConnectionOptions {
|
||||
id: plugin_context.id.clone(),
|
||||
validate_certificates: workspace.setting_validate_certificates,
|
||||
validate_certificates: resolved_settings.validate_certificates,
|
||||
proxy: proxy_setting,
|
||||
client_certificate,
|
||||
})
|
||||
|
||||
@@ -233,7 +233,7 @@ async fn cmd_grpc_reflect<R: Runtime>(
|
||||
&uri,
|
||||
&proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect(),
|
||||
&metadata,
|
||||
workspace.setting_validate_certificates,
|
||||
workspace.setting_validate_certificates.unwrap_or(true),
|
||||
client_certificate,
|
||||
skip_cache.unwrap_or(false),
|
||||
)
|
||||
@@ -327,7 +327,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
uri.as_str(),
|
||||
&proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect(),
|
||||
&metadata,
|
||||
workspace.setting_validate_certificates,
|
||||
workspace.setting_validate_certificates.unwrap_or(true),
|
||||
client_cert.clone(),
|
||||
)
|
||||
.await;
|
||||
@@ -360,8 +360,10 @@ 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();
|
||||
|
||||
@@ -383,12 +385,14 @@ 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 {
|
||||
let result = render_template(
|
||||
render_template(
|
||||
msg.as_str(),
|
||||
environment_chain,
|
||||
&PluginTemplateCallback::new(
|
||||
@@ -402,11 +406,24 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
),
|
||||
&RenderOptions { error_behavior: RenderErrorBehavior::Throw },
|
||||
)
|
||||
.await;
|
||||
result.expect("Failed to render template")
|
||||
.await
|
||||
.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();
|
||||
@@ -453,48 +470,12 @@ 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,
|
||||
on_message.clone(),
|
||||
)
|
||||
.streaming(&service, &method, in_msg_stream, &metadata, client_cert)
|
||||
.await,
|
||||
),
|
||||
None,
|
||||
@@ -509,7 +490,6 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
in_msg_stream,
|
||||
&metadata,
|
||||
client_cert,
|
||||
on_message.clone(),
|
||||
)
|
||||
.await,
|
||||
),
|
||||
|
||||
@@ -57,10 +57,6 @@ 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()
|
||||
|
||||
@@ -355,7 +355,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
||||
url.as_str(),
|
||||
headers,
|
||||
receive_tx,
|
||||
workspace.setting_validate_certificates,
|
||||
workspace.setting_validate_certificates.unwrap_or(true),
|
||||
client_cert,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
"vendored/protoc/include",
|
||||
"vendored/plugins",
|
||||
"vendored/plugin-runtime",
|
||||
"vendored/node/yaaknode*",
|
||||
"vendored/protoc/yaakprotoc*"
|
||||
"vendored/node/yaaknode",
|
||||
"vendored/protoc/yaakprotoc"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,18 +115,14 @@ impl GrpcConnection {
|
||||
Ok(client.unary(req, path, codec).await?)
|
||||
}
|
||||
|
||||
pub async fn streaming<F>(
|
||||
pub async fn streaming(
|
||||
&self,
|
||||
service: &str,
|
||||
method: &str,
|
||||
stream: ReceiverStream<String>,
|
||||
metadata: &BTreeMap<String, String>,
|
||||
client_cert: Option<ClientCertificateConfig>,
|
||||
on_message: F,
|
||||
) -> Result<Response<Streaming<DynamicMessage>>>
|
||||
where
|
||||
F: Fn(std::result::Result<String, String>) + Send + Sync + Clone + 'static,
|
||||
{
|
||||
) -> Result<Response<Streaming<DynamicMessage>>> {
|
||||
let method = &self.method(&service, &method).await?;
|
||||
let mapped_stream = {
|
||||
let input_message = method.input();
|
||||
@@ -135,39 +131,31 @@ impl GrpcConnection {
|
||||
let md = metadata.clone();
|
||||
let use_reflection = self.use_reflection.clone();
|
||||
let client_cert = client_cert.clone();
|
||||
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}");
|
||||
}
|
||||
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}");
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter_map(|x| x)
|
||||
})
|
||||
};
|
||||
|
||||
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
|
||||
@@ -181,18 +169,14 @@ impl GrpcConnection {
|
||||
Ok(client.streaming(req, path, codec).await?)
|
||||
}
|
||||
|
||||
pub async fn client_streaming<F>(
|
||||
pub async fn client_streaming(
|
||||
&self,
|
||||
service: &str,
|
||||
method: &str,
|
||||
stream: ReceiverStream<String>,
|
||||
metadata: &BTreeMap<String, String>,
|
||||
client_cert: Option<ClientCertificateConfig>,
|
||||
on_message: F,
|
||||
) -> Result<Response<DynamicMessage>>
|
||||
where
|
||||
F: Fn(std::result::Result<String, String>) + Send + Sync + Clone + 'static,
|
||||
{
|
||||
) -> Result<Response<DynamicMessage>> {
|
||||
let method = &self.method(&service, &method).await?;
|
||||
let mapped_stream = {
|
||||
let input_message = method.input();
|
||||
@@ -201,39 +185,31 @@ impl GrpcConnection {
|
||||
let md = metadata.clone();
|
||||
let use_reflection = self.use_reflection.clone();
|
||||
let client_cert = client_cert.clone();
|
||||
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}");
|
||||
}
|
||||
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}");
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter_map(|x| x)
|
||||
})
|
||||
};
|
||||
|
||||
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
|
||||
|
||||
@@ -342,8 +342,7 @@ 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![] },
|
||||
@@ -374,8 +373,7 @@ 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)
|
||||
@@ -527,8 +525,7 @@ 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![]));
|
||||
@@ -587,10 +584,7 @@ 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>> =
|
||||
|
||||
@@ -206,22 +206,6 @@ 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') {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Add nullable settings columns to folders (NULL = inherit from parent)
|
||||
ALTER TABLE folders ADD COLUMN setting_request_timeout INTEGER DEFAULT NULL;
|
||||
ALTER TABLE folders ADD COLUMN setting_validate_certificates BOOLEAN DEFAULT NULL;
|
||||
ALTER TABLE folders ADD COLUMN setting_follow_redirects BOOLEAN DEFAULT NULL;
|
||||
|
||||
-- Add nullable settings columns to http_requests (NULL = inherit from parent)
|
||||
ALTER TABLE http_requests ADD COLUMN setting_request_timeout INTEGER DEFAULT NULL;
|
||||
ALTER TABLE http_requests ADD COLUMN setting_validate_certificates BOOLEAN DEFAULT NULL;
|
||||
ALTER TABLE http_requests ADD COLUMN setting_follow_redirects BOOLEAN DEFAULT NULL;
|
||||
@@ -1,8 +1,4 @@
|
||||
use crate::error::Result;
|
||||
use crate::models::HttpRequestIden::{
|
||||
Authentication, AuthenticationType, Body, BodyType, CreatedAt, Description, FolderId, Headers,
|
||||
Method, Name, SortPriority, UpdatedAt, Url, UrlParameters, WorkspaceId,
|
||||
};
|
||||
use crate::util::{UpdateSource, generate_prefixed_id};
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use rusqlite::Row;
|
||||
@@ -115,6 +111,36 @@ impl Default for EditorKeymap {
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings that can be inherited at workspace → folder → request level.
|
||||
/// All fields optional - None means "inherit from parent" (or use default if at root).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
pub struct HttpRequestSettingsOverride {
|
||||
pub setting_validate_certificates: Option<bool>,
|
||||
pub setting_follow_redirects: Option<bool>,
|
||||
pub setting_request_timeout: Option<i32>,
|
||||
}
|
||||
|
||||
/// Resolved settings with concrete values (after inheritance + defaults applied)
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ResolvedHttpRequestSettings {
|
||||
pub validate_certificates: bool,
|
||||
pub follow_redirects: bool,
|
||||
pub request_timeout: i32,
|
||||
}
|
||||
|
||||
impl ResolvedHttpRequestSettings {
|
||||
/// Default values when nothing is set in the inheritance chain
|
||||
pub fn defaults() -> Self {
|
||||
Self {
|
||||
validate_certificates: true,
|
||||
follow_redirects: true,
|
||||
request_timeout: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
@@ -297,12 +323,10 @@ pub struct Workspace {
|
||||
pub name: String,
|
||||
pub encryption_key_challenge: Option<String>,
|
||||
|
||||
// Settings
|
||||
#[serde(default = "default_true")]
|
||||
pub setting_validate_certificates: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub setting_follow_redirects: bool,
|
||||
pub setting_request_timeout: i32,
|
||||
// Inheritable settings (Option = can be null, defaults applied at resolution time)
|
||||
pub setting_validate_certificates: Option<bool>,
|
||||
pub setting_follow_redirects: Option<bool>,
|
||||
pub setting_request_timeout: Option<i32>,
|
||||
}
|
||||
|
||||
impl UpsertModelInfo for Workspace {
|
||||
@@ -726,6 +750,11 @@ pub struct Folder {
|
||||
pub headers: Vec<HttpRequestHeader>,
|
||||
pub name: String,
|
||||
pub sort_priority: f64,
|
||||
|
||||
// Inheritable settings (Option = null means inherit from parent)
|
||||
pub setting_validate_certificates: Option<bool>,
|
||||
pub setting_follow_redirects: Option<bool>,
|
||||
pub setting_request_timeout: Option<i32>,
|
||||
}
|
||||
|
||||
impl UpsertModelInfo for Folder {
|
||||
@@ -765,6 +794,9 @@ impl UpsertModelInfo for Folder {
|
||||
(Description, self.description.into()),
|
||||
(Name, self.name.trim().into()),
|
||||
(SortPriority, self.sort_priority.into()),
|
||||
(SettingValidateCertificates, self.setting_validate_certificates.into()),
|
||||
(SettingFollowRedirects, self.setting_follow_redirects.into()),
|
||||
(SettingRequestTimeout, self.setting_request_timeout.into()),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -778,6 +810,9 @@ impl UpsertModelInfo for Folder {
|
||||
FolderIden::Description,
|
||||
FolderIden::FolderId,
|
||||
FolderIden::SortPriority,
|
||||
FolderIden::SettingValidateCertificates,
|
||||
FolderIden::SettingFollowRedirects,
|
||||
FolderIden::SettingRequestTimeout,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -800,6 +835,9 @@ impl UpsertModelInfo for Folder {
|
||||
headers: serde_json::from_str(&headers).unwrap_or_default(),
|
||||
authentication_type: row.get("authentication_type")?,
|
||||
authentication: serde_json::from_str(&authentication).unwrap_or_default(),
|
||||
setting_validate_certificates: row.get("setting_validate_certificates")?,
|
||||
setting_follow_redirects: row.get("setting_follow_redirects")?,
|
||||
setting_request_timeout: row.get("setting_request_timeout")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -857,6 +895,11 @@ pub struct HttpRequest {
|
||||
pub sort_priority: f64,
|
||||
pub url: String,
|
||||
pub url_parameters: Vec<HttpUrlParameter>,
|
||||
|
||||
// Inheritable settings (Option = null means inherit from parent)
|
||||
pub setting_validate_certificates: Option<bool>,
|
||||
pub setting_follow_redirects: Option<bool>,
|
||||
pub setting_request_timeout: Option<i32>,
|
||||
}
|
||||
|
||||
impl UpsertModelInfo for HttpRequest {
|
||||
@@ -884,6 +927,7 @@ impl UpsertModelInfo for HttpRequest {
|
||||
self,
|
||||
source: &UpdateSource,
|
||||
) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {
|
||||
use HttpRequestIden::*;
|
||||
Ok(vec![
|
||||
(CreatedAt, upsert_date(source, self.created_at)),
|
||||
(UpdatedAt, upsert_date(source, self.updated_at)),
|
||||
@@ -900,10 +944,14 @@ impl UpsertModelInfo for HttpRequest {
|
||||
(AuthenticationType, self.authentication_type.into()),
|
||||
(Headers, serde_json::to_string(&self.headers)?.into()),
|
||||
(SortPriority, self.sort_priority.into()),
|
||||
(SettingValidateCertificates, self.setting_validate_certificates.into()),
|
||||
(SettingFollowRedirects, self.setting_follow_redirects.into()),
|
||||
(SettingRequestTimeout, self.setting_request_timeout.into()),
|
||||
])
|
||||
}
|
||||
|
||||
fn update_columns() -> Vec<impl IntoIden> {
|
||||
use HttpRequestIden::*;
|
||||
vec![
|
||||
UpdatedAt,
|
||||
WorkspaceId,
|
||||
@@ -919,6 +967,9 @@ impl UpsertModelInfo for HttpRequest {
|
||||
Url,
|
||||
UrlParameters,
|
||||
SortPriority,
|
||||
SettingValidateCertificates,
|
||||
SettingFollowRedirects,
|
||||
SettingRequestTimeout,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -945,6 +996,9 @@ impl UpsertModelInfo for HttpRequest {
|
||||
sort_priority: row.get("sort_priority")?,
|
||||
url: row.get("url")?,
|
||||
url_parameters: serde_json::from_str(url_parameters.as_str()).unwrap_or_default(),
|
||||
setting_validate_certificates: row.get("setting_validate_certificates")?,
|
||||
setting_follow_redirects: row.get("setting_follow_redirects")?,
|
||||
setting_request_timeout: row.get("setting_request_timeout")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::db_context::DbContext;
|
||||
use crate::error::Result;
|
||||
use crate::models::{Folder, FolderIden, HttpRequest, HttpRequestHeader, HttpRequestIden};
|
||||
use crate::models::{Folder, FolderIden, HttpRequest, HttpRequestHeader, HttpRequestIden, ResolvedHttpRequestSettings};
|
||||
use crate::util::UpdateSource;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -103,4 +103,79 @@ impl<'a> DbContext<'a> {
|
||||
}
|
||||
Ok(children)
|
||||
}
|
||||
|
||||
/// Resolve settings for an HTTP request by walking the inheritance chain:
|
||||
/// Workspace → Folder(s) → Request
|
||||
/// Last non-None value wins, then defaults are applied.
|
||||
pub fn resolve_settings_for_http_request(
|
||||
&self,
|
||||
http_request: &HttpRequest,
|
||||
) -> Result<ResolvedHttpRequestSettings> {
|
||||
let workspace = self.get_workspace(&http_request.workspace_id)?;
|
||||
|
||||
// Start with None for all settings
|
||||
let mut validate_certs: Option<bool> = None;
|
||||
let mut follow_redirects: Option<bool> = None;
|
||||
let mut timeout: Option<i32> = None;
|
||||
|
||||
// Apply workspace settings
|
||||
if workspace.setting_validate_certificates.is_some() {
|
||||
validate_certs = workspace.setting_validate_certificates;
|
||||
}
|
||||
if workspace.setting_follow_redirects.is_some() {
|
||||
follow_redirects = workspace.setting_follow_redirects;
|
||||
}
|
||||
if workspace.setting_request_timeout.is_some() {
|
||||
timeout = workspace.setting_request_timeout;
|
||||
}
|
||||
|
||||
// Apply folder chain settings (root first, immediate parent last)
|
||||
if let Some(folder_id) = &http_request.folder_id {
|
||||
let folders = self.get_folder_ancestors(folder_id)?;
|
||||
for folder in folders {
|
||||
if folder.setting_validate_certificates.is_some() {
|
||||
validate_certs = folder.setting_validate_certificates;
|
||||
}
|
||||
if folder.setting_follow_redirects.is_some() {
|
||||
follow_redirects = folder.setting_follow_redirects;
|
||||
}
|
||||
if folder.setting_request_timeout.is_some() {
|
||||
timeout = folder.setting_request_timeout;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply request-level settings (highest priority)
|
||||
if http_request.setting_validate_certificates.is_some() {
|
||||
validate_certs = http_request.setting_validate_certificates;
|
||||
}
|
||||
if http_request.setting_follow_redirects.is_some() {
|
||||
follow_redirects = http_request.setting_follow_redirects;
|
||||
}
|
||||
if http_request.setting_request_timeout.is_some() {
|
||||
timeout = http_request.setting_request_timeout;
|
||||
}
|
||||
|
||||
// Apply defaults for anything still None
|
||||
Ok(ResolvedHttpRequestSettings {
|
||||
validate_certificates: validate_certs.unwrap_or(true),
|
||||
follow_redirects: follow_redirects.unwrap_or(true),
|
||||
request_timeout: timeout.unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get folder ancestors in order from root to immediate parent
|
||||
fn get_folder_ancestors(&self, folder_id: &str) -> Result<Vec<Folder>> {
|
||||
let mut ancestors = Vec::new();
|
||||
let mut current_id = Some(folder_id.to_string());
|
||||
|
||||
while let Some(id) = current_id {
|
||||
let folder = self.get_folder(&id)?;
|
||||
current_id = folder.folder_id.clone();
|
||||
ancestors.push(folder);
|
||||
}
|
||||
|
||||
ancestors.reverse(); // Root first, immediate parent last
|
||||
Ok(ancestors)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ impl<'a> DbContext<'a> {
|
||||
workspaces.push(self.upsert_workspace(
|
||||
&Workspace {
|
||||
name: "Yaak".to_string(),
|
||||
setting_follow_redirects: true,
|
||||
setting_validate_certificates: true,
|
||||
setting_follow_redirects: Some(true),
|
||||
setting_validate_certificates: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
|
||||
6
crates/yaak-plugins/bindings/gen_events.ts
generated
6
crates/yaak-plugins/bindings/gen_events.ts
generated
File diff suppressed because one or more lines are too long
@@ -157,9 +157,6 @@ pub enum InternalEventPayload {
|
||||
PromptTextRequest(PromptTextRequest),
|
||||
PromptTextResponse(PromptTextResponse),
|
||||
|
||||
PromptFormRequest(PromptFormRequest),
|
||||
PromptFormResponse(PromptFormResponse),
|
||||
|
||||
WindowInfoRequest(WindowInfoRequest),
|
||||
WindowInfoResponse(WindowInfoResponse),
|
||||
|
||||
@@ -574,28 +571,6 @@ 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
@@ -11,8 +11,6 @@ import type {
|
||||
ListHttpRequestsRequest,
|
||||
ListHttpRequestsResponse,
|
||||
OpenWindowRequest,
|
||||
PromptFormRequest,
|
||||
PromptFormResponse,
|
||||
PromptTextRequest,
|
||||
PromptTextResponse,
|
||||
RenderGrpcRequestRequest,
|
||||
@@ -39,7 +37,6 @@ 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>;
|
||||
|
||||
@@ -28,7 +28,6 @@ import type {
|
||||
ListHttpRequestsResponse,
|
||||
ListWorkspacesResponse,
|
||||
PluginContext,
|
||||
PromptFormResponse,
|
||||
PromptTextResponse,
|
||||
RenderGrpcRequestResponse,
|
||||
RenderHttpRequestResponse,
|
||||
@@ -662,13 +661,6 @@ 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) => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
stateExtensions,
|
||||
updateSchema,
|
||||
} from 'codemirror-json-schema';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } 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 [editorView, setEditorView] = useState<EditorView | null>(null);
|
||||
const editorViewRef = useRef<EditorView>(null);
|
||||
const handleInitEditorViewRef = useCallback((h: EditorView | null) => {
|
||||
setEditorView(h);
|
||||
editorViewRef.current = h;
|
||||
}, []);
|
||||
|
||||
// Find the schema for the selected service and method and update the editor
|
||||
useEffect(() => {
|
||||
if (
|
||||
editorView == null ||
|
||||
editorViewRef.current == null ||
|
||||
services === null ||
|
||||
request.service === null ||
|
||||
request.method === null
|
||||
@@ -91,7 +91,7 @@ export function GrpcEditor({
|
||||
}
|
||||
|
||||
try {
|
||||
updateSchema(editorView, JSON.parse(schema));
|
||||
updateSchema(editorViewRef.current, JSON.parse(schema));
|
||||
} catch (err) {
|
||||
showAlert({
|
||||
id: 'grpc-parse-schema-error',
|
||||
@@ -107,7 +107,7 @@ export function GrpcEditor({
|
||||
),
|
||||
});
|
||||
}
|
||||
}, [editorView, services, request.method, request.service]);
|
||||
}, [services, request.method, request.service]);
|
||||
|
||||
const extraExtensions = useMemo(
|
||||
() => [
|
||||
@@ -118,7 +118,7 @@ export function GrpcEditor({
|
||||
jsonLanguage.data.of({
|
||||
autocomplete: jsonCompletion(),
|
||||
}),
|
||||
stateExtensions({}),
|
||||
stateExtensions(/** Init with empty schema **/),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
activeGrpcConnectionAtom,
|
||||
activeGrpcConnections,
|
||||
@@ -9,14 +11,18 @@ 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, type IconProps } from './core/Icon';
|
||||
import { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
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';
|
||||
@@ -36,7 +42,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
|
||||
const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null);
|
||||
const [activeEventId, setActiveEventId] = useState<string | null>(null);
|
||||
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
|
||||
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
||||
const connections = useAtomValue(activeGrpcConnections);
|
||||
@@ -45,8 +51,8 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
|
||||
const setPinnedGrpcConnectionId = useSetAtom(pinnedGrpcConnectionIdAtom);
|
||||
|
||||
const activeEvent = useMemo(
|
||||
() => (activeEventIndex != null ? events[activeEventIndex] : null),
|
||||
[activeEventIndex, events],
|
||||
() => events.find((m) => m.id === activeEventId) ?? null,
|
||||
[activeEventId, events],
|
||||
);
|
||||
|
||||
// Set the active message to the first message received if unary
|
||||
@@ -55,188 +61,223 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
|
||||
if (events.length === 0 || activeEvent != null || methodType !== 'unary') {
|
||||
return;
|
||||
}
|
||||
const firstServerMessageIndex = events.findIndex((m) => m.eventType === 'server_message');
|
||||
if (firstServerMessageIndex !== -1) {
|
||||
setActiveEventIndex(firstServerMessageIndex);
|
||||
}
|
||||
setActiveEventId(events.find((m) => m.eventType === 'server_message')?.id ?? null);
|
||||
}, [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 (
|
||||
<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>
|
||||
<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
|
||||
}
|
||||
timestamp={event.createdAt}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GrpcEventDetail({
|
||||
function EventRow({
|
||||
onClick,
|
||||
isActive,
|
||||
event,
|
||||
showLarge,
|
||||
showingLarge,
|
||||
setShowLarge,
|
||||
setShowingLarge,
|
||||
}: {
|
||||
onClick?: () => void;
|
||||
isActive?: boolean;
|
||||
event: GrpcEvent;
|
||||
showLarge: boolean;
|
||||
showingLarge: boolean;
|
||||
setShowLarge: (v: boolean) => void;
|
||||
setShowingLarge: (v: boolean) => void;
|
||||
}) {
|
||||
if (event.eventType === 'client_message' || event.eventType === 'server_message') {
|
||||
const title = `Message ${event.eventType === 'client_message' ? 'Sent' : 'Received'}`;
|
||||
const { eventType, status, createdAt, content, error } = event;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</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="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 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',
|
||||
)}
|
||||
</div>
|
||||
>
|
||||
<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>
|
||||
<div className={classNames('opacity-50 text-xs')}>
|
||||
{format(`${createdAt}Z`, 'HH:mm:ss.SSS')}
|
||||
</div>
|
||||
</button>
|
||||
</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' };
|
||||
}
|
||||
|
||||
@@ -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 { getCookieCounts, getContentTypeFromHeaders } from '../lib/model_util';
|
||||
import { getContentTypeFromHeaders } from '../lib/model_util';
|
||||
import { ConfirmLargeResponse } from './ConfirmLargeResponse';
|
||||
import { ConfirmLargeResponseRequest } from './ConfirmLargeResponseRequest';
|
||||
import { Banner } from './core/Banner';
|
||||
@@ -67,10 +67,20 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
|
||||
const responseEvents = useHttpResponseEvents(activeResponse);
|
||||
|
||||
const cookieCounts = useMemo(
|
||||
() => getCookieCounts(responseEvents.data),
|
||||
[responseEvents.data],
|
||||
);
|
||||
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 tabs = useMemo<TabItem[]>(
|
||||
() => [
|
||||
@@ -97,19 +107,15 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
label: 'Headers',
|
||||
rightSlot: (
|
||||
<CountBadge
|
||||
count={activeResponse?.requestHeaders.length ?? 0}
|
||||
count2={activeResponse?.headers.length ?? 0}
|
||||
showZero
|
||||
count={activeResponse?.requestHeaders.length ?? 0}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: TAB_COOKIES,
|
||||
label: 'Cookies',
|
||||
rightSlot:
|
||||
cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
|
||||
<CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />
|
||||
) : null,
|
||||
rightSlot: cookieCount > 0 ? <CountBadge count={cookieCount} /> : null,
|
||||
},
|
||||
{
|
||||
value: TAB_TIMELINE,
|
||||
@@ -121,8 +127,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
activeResponse?.headers,
|
||||
activeResponse?.requestContentLength,
|
||||
activeResponse?.requestHeaders.length,
|
||||
cookieCounts.sent,
|
||||
cookieCounts.received,
|
||||
cookieCount,
|
||||
mimeType,
|
||||
responseEvents.data?.length,
|
||||
setViewMode,
|
||||
|
||||
@@ -3,15 +3,18 @@ import type {
|
||||
HttpResponseEvent,
|
||||
HttpResponseEventData,
|
||||
} from '@yaakapp-internal/models';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { format } from 'date-fns';
|
||||
import { type ReactNode, useMemo, useState } from 'react';
|
||||
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
|
||||
import { Editor } from './core/Editor/LazyEditor';
|
||||
import { EventDetailHeader, EventViewer, type EventDetailAction } from './core/EventViewer';
|
||||
import { EventViewerRow } from './core/EventViewerRow';
|
||||
import { AutoScroller } from './core/AutoScroller';
|
||||
import { Banner } from './core/Banner';
|
||||
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;
|
||||
@@ -22,88 +25,121 @@ export function HttpResponseTimeline({ response }: Props) {
|
||||
}
|
||||
|
||||
function Inner({ response }: Props) {
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null);
|
||||
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 (
|
||||
<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"
|
||||
<SplitLayout
|
||||
layout="vertical"
|
||||
name="http_response_events"
|
||||
defaultRatio={0.25}
|
||||
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} />
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
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,
|
||||
showRaw,
|
||||
setShowRaw,
|
||||
}: {
|
||||
event: HttpResponseEvent;
|
||||
showRaw: boolean;
|
||||
setShowRaw: (v: boolean) => void;
|
||||
}) {
|
||||
function EventDetails({ event }: { event: HttpResponseEvent }) {
|
||||
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">
|
||||
<EventDetailHeader title={title} timestamp={event.createdAt} actions={actions} />
|
||||
<DetailHeader
|
||||
title={e.type === 'header_down' ? 'Header Received' : 'Header Sent'}
|
||||
timestamp={timestamp}
|
||||
/>
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Header">{e.name}</KeyValueRow>
|
||||
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
||||
@@ -116,7 +152,7 @@ function EventDetails({
|
||||
if (e.type === 'send_url') {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<EventDetailHeader title="Request" timestamp={event.createdAt} actions={actions} />
|
||||
<DetailHeader title="Request" timestamp={timestamp} />
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Method">
|
||||
<HttpMethodTagRaw forceColor method={e.method} />
|
||||
@@ -131,7 +167,7 @@ function EventDetails({
|
||||
if (e.type === 'receive_url') {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<EventDetailHeader title="Response" timestamp={event.createdAt} actions={actions} />
|
||||
<DetailHeader title="Response" timestamp={timestamp} />
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="HTTP Version">{e.version}</KeyValueRow>
|
||||
<KeyValueRow label="Status">
|
||||
@@ -146,7 +182,7 @@ function EventDetails({
|
||||
if (e.type === 'redirect') {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<EventDetailHeader title="Redirect" timestamp={event.createdAt} actions={actions} />
|
||||
<DetailHeader title="Redirect" timestamp={timestamp} />
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Status">
|
||||
<HttpStatusTagRaw status={e.status} />
|
||||
@@ -164,7 +200,7 @@ function EventDetails({
|
||||
if (e.type === 'setting') {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<EventDetailHeader title="Apply Setting" timestamp={event.createdAt} actions={actions} />
|
||||
<DetailHeader title="Apply Setting" timestamp={timestamp} />
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Setting">{e.name}</KeyValueRow>
|
||||
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
||||
@@ -178,11 +214,7 @@ function EventDetails({
|
||||
const direction = e.type === 'chunk_sent' ? 'Sent' : 'Received';
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<EventDetailHeader
|
||||
title={`Data ${direction}`}
|
||||
timestamp={event.createdAt}
|
||||
actions={actions}
|
||||
/>
|
||||
<DetailHeader title={`Data ${direction}`} timestamp={timestamp} />
|
||||
<div className="font-mono text-editor">{formatBytes(e.bytes)}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -192,36 +224,19 @@ function EventDetails({
|
||||
const { summary } = getEventDisplay(event.event);
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<EventDetailHeader title={label} timestamp={event.createdAt} actions={actions} />
|
||||
<DetailHeader title={label} timestamp={timestamp} />
|
||||
<div className="font-mono text-editor">{summary}</div>
|
||||
</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]';
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
type EventDisplay = {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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, useState } from 'react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useFormatText } from '../hooks/useFormatText';
|
||||
import {
|
||||
activeWebsocketConnectionAtom,
|
||||
@@ -11,13 +13,17 @@ 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';
|
||||
@@ -29,199 +35,227 @@ 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<number, boolean>>({});
|
||||
const [hexDumps, setHexDumps] = useState<Record<string, boolean>>({});
|
||||
|
||||
const activeConnection = useAtomValue(activeWebsocketConnectionAtom);
|
||||
const connections = useAtomValue(activeWebsocketConnectionsAtom);
|
||||
const events = useWebsocketEvents(activeConnection?.id ?? null);
|
||||
|
||||
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>•</span>
|
||||
<span>{events.length} Messages</span>
|
||||
</HStack>
|
||||
<HStack space={0.5} className="ml-auto">
|
||||
<RecentWebsocketConnectionsDropdown
|
||||
connections={connections}
|
||||
activeConnection={activeConnection}
|
||||
onPinnedConnectionId={setPinnedWebsocketConnectionId}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
const activeEvent = useMemo(
|
||||
() => events.find((m) => m.id === activeEventId) ?? null,
|
||||
[activeEventId, events],
|
||||
);
|
||||
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
const hexDump = hexDumps[activeEventId ?? 'n/a'] ?? activeEvent?.messageType === 'binary';
|
||||
|
||||
function WebsocketEventRow({
|
||||
event,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
event: WebsocketEvent;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
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 (
|
||||
<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 activeEvent?.message ? hexy(activeEvent?.message) : '';
|
||||
}
|
||||
return event.message ? new TextDecoder('utf-8').decode(Uint8Array.from(event.message)) : '';
|
||||
}, [event.message, hexDump]);
|
||||
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 });
|
||||
|
||||
const title =
|
||||
event.messageType === 'close'
|
||||
? 'Connection Closed'
|
||||
: event.messageType === 'open'
|
||||
? 'Connection Open'
|
||||
: `Message ${event.isServer ? 'Received' : 'Sent'}`;
|
||||
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']}
|
||||
/>
|
||||
) : (
|
||||
<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>•</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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const actions: EventDetailAction[] =
|
||||
message !== ''
|
||||
? [
|
||||
{
|
||||
key: 'toggle-hexdump',
|
||||
label: hexDump ? 'Show Message' : 'Show Hexdump',
|
||||
onClick: () => setHexDump(!hexDump),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
function EventRow({
|
||||
onClick,
|
||||
isActive,
|
||||
event,
|
||||
}: {
|
||||
onClick?: () => void;
|
||||
isActive?: boolean;
|
||||
event: WebsocketEvent;
|
||||
}) {
|
||||
const { createdAt, message: messageBytes, isServer, messageType } = event;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const message = messageBytes
|
||||
? new TextDecoder('utf-8').decode(Uint8Array.from(messageBytes))
|
||||
: '';
|
||||
|
||||
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="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'
|
||||
}
|
||||
/>
|
||||
{!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 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import type { ReactElement, ReactNode, UIEvent } from 'react';
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { IconButton } from './IconButton';
|
||||
@@ -7,19 +7,9 @@ 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,
|
||||
focusable = false,
|
||||
onVirtualizerReady,
|
||||
}: Props<T>) {
|
||||
export function AutoScroller<T>({ data, render, header }: Props<T>) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [autoScroll, setAutoScroll] = useState<boolean>(true);
|
||||
|
||||
@@ -30,11 +20,6 @@ export function AutoScroller<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>) => {
|
||||
@@ -63,7 +48,7 @@ export function AutoScroller<T>({
|
||||
}, [autoScroll, data.length]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full relative grid grid-rows-[auto_minmax(0,1fr)]">
|
||||
<div className="h-full w-full relative grid grid-rows-[minmax(0,auto)_minmax(0,1fr)]">
|
||||
{!autoScroll && (
|
||||
<div className="absolute bottom-0 right-0 m-2">
|
||||
<IconButton
|
||||
@@ -78,12 +63,7 @@ export function AutoScroller<T>({
|
||||
</div>
|
||||
)}
|
||||
{header ?? <span aria-hidden />}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full overflow-y-auto"
|
||||
onScroll={handleScroll}
|
||||
tabIndex={focusable ? 0 : undefined}
|
||||
>
|
||||
<div ref={containerRef} className="h-full w-full overflow-y-auto" onScroll={handleScroll}>
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
|
||||
@@ -766,24 +766,8 @@ 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 <>{hotKeyElements}</>;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isSubmenu) {
|
||||
@@ -792,7 +776,20 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
||||
|
||||
return (
|
||||
<>
|
||||
{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}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
<Overlay noBackdrop open={isOpen} portalName="dropdown-menu">
|
||||
{menuContent}
|
||||
</Overlay>
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -5,13 +5,15 @@ 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 {
|
||||
@@ -31,88 +33,93 @@ 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);
|
||||
|
||||
return (
|
||||
<EventViewer
|
||||
events={events.data ?? []}
|
||||
getEventKey={(_, index) => String(index)}
|
||||
error={events.error ? String(events.error) : null}
|
||||
splitLayoutName="sse_events"
|
||||
defaultRatio={0.4}
|
||||
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>
|
||||
}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
const activeEvent = useMemo(
|
||||
() => (activeEventIndex == null ? null : events.data?.[activeEventIndex]),
|
||||
[activeEventIndex, events],
|
||||
);
|
||||
}
|
||||
|
||||
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]);
|
||||
if (!activeEvent?.data) return 'text';
|
||||
return isJSON(activeEvent?.data) ? 'json' : 'text';
|
||||
}, [activeEvent?.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
|
||||
<SplitLayout
|
||||
layout="vertical"
|
||||
name="grpc_events"
|
||||
defaultRatio={0.4}
|
||||
minHeightPx={20}
|
||||
firstSlot={() => (
|
||||
<AutoScroller
|
||||
data={events.data ?? []}
|
||||
header={
|
||||
events.error && (
|
||||
<Banner color="danger" className="m-3">
|
||||
{String(events.error)}
|
||||
</Banner>
|
||||
)
|
||||
}
|
||||
render={(event, i) => (
|
||||
<EventRow
|
||||
event={event}
|
||||
isActive={i === activeEventIndex}
|
||||
index={i}
|
||||
onClick={() => {
|
||||
setShowingLarge(true);
|
||||
setTimeout(() => {
|
||||
setShowLarge(true);
|
||||
setShowingLarge(false);
|
||||
}, 500);
|
||||
if (i === activeEventIndex) setActiveEventIndex(null);
|
||||
else setActiveEventIndex(i);
|
||||
}}
|
||||
isLoading={showingLarge}
|
||||
color="secondary"
|
||||
variant="border"
|
||||
size="xs"
|
||||
>
|
||||
Try Showing
|
||||
</Button>
|
||||
</div>
|
||||
</VStack>
|
||||
) : (
|
||||
<FormattedEditor language={language} text={event.data} />
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,6 +129,38 @@ function FormattedEditor({ text, language }: { text: string; language: EditorPro
|
||||
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,
|
||||
@@ -130,7 +169,7 @@ function EventLabels({
|
||||
}: {
|
||||
event: ServerSentEvent;
|
||||
index: number;
|
||||
className?: string;
|
||||
className: string;
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
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],
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { HttpResponse, HttpResponseEvent } from '@yaakapp-internal/models';
|
||||
import {
|
||||
httpResponseEventsAtom,
|
||||
mergeModelsInStore,
|
||||
replaceModelsInStore,
|
||||
} from '@yaakapp-internal/models';
|
||||
import { httpResponseEventsAtom, replaceModelsInStore } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
@@ -17,10 +13,8 @@ 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) => mergeModelsInStore('http_response_event', events),
|
||||
(events) => replaceModelsInStore('http_response_event', events),
|
||||
);
|
||||
}, [response?.id]);
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { GrpcConnection, GrpcEvent } from '@yaakapp-internal/models';
|
||||
import {
|
||||
grpcConnectionsAtom,
|
||||
grpcEventsAtom,
|
||||
mergeModelsInStore,
|
||||
replaceModelsInStore,
|
||||
} from '@yaakapp-internal/models';
|
||||
import { atom, useAtomValue } from 'jotai';
|
||||
@@ -68,10 +67,8 @@ 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) => {
|
||||
mergeModelsInStore('grpc_event', events);
|
||||
replaceModelsInStore('grpc_event', events);
|
||||
});
|
||||
}, [connectionId]);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { WebsocketConnection, WebsocketEvent } from '@yaakapp-internal/models';
|
||||
import {
|
||||
mergeModelsInStore,
|
||||
replaceModelsInStore,
|
||||
websocketConnectionsAtom,
|
||||
websocketEventsAtom,
|
||||
@@ -55,10 +54,8 @@ 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) => mergeModelsInStore('websocket_event', events),
|
||||
(events) => replaceModelsInStore('websocket_event', events),
|
||||
);
|
||||
}, [connectionId]);
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ 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';
|
||||
|
||||
@@ -48,27 +47,6 @@ 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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,4 @@
|
||||
import type {
|
||||
AnyModel,
|
||||
Cookie,
|
||||
Environment,
|
||||
HttpResponseEvent,
|
||||
HttpResponseHeader,
|
||||
} from '@yaakapp-internal/models';
|
||||
import type { AnyModel, Cookie, Environment, HttpResponseHeader } from '@yaakapp-internal/models';
|
||||
import { getMimeTypeFromContentType } from './contentType';
|
||||
|
||||
export const BODY_TYPE_NONE = null;
|
||||
@@ -65,30 +59,3 @@ 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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user