mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-19 15:21:23 +02:00
Compare commits
2 Commits
v2025.10.0
...
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
|
name: Generate Artifacts
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: [v*]
|
tags: [ v* ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-artifacts:
|
build-artifacts:
|
||||||
@@ -13,37 +13,37 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- platform: "macos-latest" # for Arm-based Macs (M1 and above).
|
- platform: 'macos-latest' # for Arm-based Macs (M1 and above).
|
||||||
args: "--target aarch64-apple-darwin"
|
args: '--target aarch64-apple-darwin'
|
||||||
yaak_arch: "arm64"
|
yaak_arch: 'arm64'
|
||||||
os: "macos"
|
os: 'macos'
|
||||||
targets: "aarch64-apple-darwin"
|
targets: 'aarch64-apple-darwin'
|
||||||
- platform: "macos-latest" # for Intel-based Macs.
|
- platform: 'macos-latest' # for Intel-based Macs.
|
||||||
args: "--target x86_64-apple-darwin"
|
args: '--target x86_64-apple-darwin'
|
||||||
yaak_arch: "x64"
|
yaak_arch: 'x64'
|
||||||
os: "macos"
|
os: 'macos'
|
||||||
targets: "x86_64-apple-darwin"
|
targets: 'x86_64-apple-darwin'
|
||||||
- platform: "ubuntu-22.04"
|
- platform: 'ubuntu-22.04'
|
||||||
args: ""
|
args: ''
|
||||||
yaak_arch: "x64"
|
yaak_arch: 'x64'
|
||||||
os: "ubuntu"
|
os: 'ubuntu'
|
||||||
targets: ""
|
targets: ''
|
||||||
- platform: "ubuntu-22.04-arm"
|
- platform: 'ubuntu-22.04-arm'
|
||||||
args: ""
|
args: ''
|
||||||
yaak_arch: "arm64"
|
yaak_arch: 'arm64'
|
||||||
os: "ubuntu"
|
os: 'ubuntu'
|
||||||
targets: ""
|
targets: ''
|
||||||
- platform: "windows-latest"
|
- platform: 'windows-latest'
|
||||||
args: ""
|
args: ''
|
||||||
yaak_arch: "x64"
|
yaak_arch: 'x64'
|
||||||
os: "windows"
|
os: 'windows'
|
||||||
targets: ""
|
targets: ''
|
||||||
# Windows ARM64
|
# Windows ARM64
|
||||||
- platform: "windows-latest"
|
- platform: 'windows-latest'
|
||||||
args: "--target aarch64-pc-windows-msvc"
|
args: '--target aarch64-pc-windows-msvc'
|
||||||
yaak_arch: "arm64"
|
yaak_arch: 'arm64'
|
||||||
os: "windows"
|
os: 'windows'
|
||||||
targets: "aarch64-pc-windows-msvc"
|
targets: 'aarch64-pc-windows-msvc'
|
||||||
runs-on: ${{ matrix.platform }}
|
runs-on: ${{ matrix.platform }}
|
||||||
timeout-minutes: 40
|
timeout-minutes: 40
|
||||||
steps:
|
steps:
|
||||||
@@ -88,7 +88,6 @@ jobs:
|
|||||||
& $exe --version
|
& $exe --version
|
||||||
|
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run bootstrap
|
|
||||||
- run: npm run lint
|
- run: npm run lint
|
||||||
- name: Run JS Tests
|
- name: Run JS Tests
|
||||||
run: npm test
|
run: npm test
|
||||||
@@ -100,29 +99,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
YAAK_VERSION: ${{ github.ref_name }}
|
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 and their specific entitlements
|
|
||||||
codesign --force --options runtime --entitlements crates-tauri/yaak-app/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app/vendored/protoc/yaakprotoc || true
|
|
||||||
codesign --force --options runtime --entitlements crates-tauri/yaak-app/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app/vendored/node/yaaknode || true
|
|
||||||
|
|
||||||
- uses: tauri-apps/tauri-action@v0
|
- uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
|
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
|
||||||
@@ -145,9 +121,9 @@ jobs:
|
|||||||
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
|
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
|
||||||
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
|
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
|
||||||
with:
|
with:
|
||||||
tagName: "v__VERSION__"
|
tagName: 'v__VERSION__'
|
||||||
releaseName: "Release __VERSION__"
|
releaseName: 'Release __VERSION__'
|
||||||
releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)"
|
releaseBody: '[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)'
|
||||||
releaseDraft: true
|
releaseDraft: true
|
||||||
prerelease: 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'
|
||||||
|
|||||||
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -8075,7 +8075,6 @@ name = "yaak-common"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8122,10 +8121,8 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"tokio",
|
|
||||||
"ts-rs",
|
"ts-rs",
|
||||||
"url",
|
"url",
|
||||||
"yaak-common",
|
|
||||||
"yaak-models",
|
"yaak-models",
|
||||||
"yaak-sync",
|
"yaak-sync",
|
||||||
]
|
]
|
||||||
@@ -8152,7 +8149,6 @@ dependencies = [
|
|||||||
"tonic",
|
"tonic",
|
||||||
"tonic-reflection",
|
"tonic-reflection",
|
||||||
"uuid",
|
"uuid",
|
||||||
"yaak-common",
|
|
||||||
"yaak-tls",
|
"yaak-tls",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<!-- Enable for NodeJS execution -->
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Allow loading 1Password's dylib (signed with different Team ID) -->
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
<!-- Re-enable for sandboxing. Currently disabled because auto-updater doesn't work with sandboxing.-->
|
<!-- Re-enable for sandboxing. Currently disabled because auto-updater doesn't work with sandboxing.-->
|
||||||
<!-- <key>com.apple.security.app-sandbox</key> <true/>-->
|
<!-- <key>com.apple.security.app-sandbox</key> <true/>-->
|
||||||
<!-- <key>com.apple.security.files.user-selected.read-write</key> <true/>-->
|
<!-- <key>com.apple.security.files.user-selected.read-write</key> <true/>-->
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<!-- Enable for NodeJS/V8 JIT compiler -->
|
|
||||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
|
||||||
<true/>
|
|
||||||
|
|
||||||
<!-- Allow loading plugins signed with different Team IDs (e.g., 1Password) -->
|
|
||||||
<key>com.apple.security.cs.disable-library-validation</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -6,10 +6,11 @@ use crate::error::Result;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tauri::command;
|
use tauri::command;
|
||||||
use yaak_git::{
|
use yaak_git::{
|
||||||
GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult, git_add, git_add_credential,
|
GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult,
|
||||||
git_add_remote, git_checkout_branch, git_commit, git_create_branch, git_delete_branch,
|
git_add, git_add_credential, git_add_remote, git_checkout_branch, git_commit,
|
||||||
git_fetch_all, git_init, git_log, git_merge_branch, git_pull, git_push, git_remotes,
|
git_create_branch, git_delete_branch, git_fetch_all, git_init, git_log,
|
||||||
git_rm_remote, git_status, git_unstage,
|
git_merge_branch, git_pull, git_push, git_remotes, git_rm_remote, git_status,
|
||||||
|
git_unstage,
|
||||||
};
|
};
|
||||||
|
|
||||||
// NOTE: All of these commands are async to prevent blocking work from locking up the UI
|
// NOTE: All of these commands are async to prevent blocking work from locking up the UI
|
||||||
@@ -51,22 +52,22 @@ pub async fn cmd_git_initialize(dir: &Path) -> Result<()> {
|
|||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_git_commit(dir: &Path, message: &str) -> Result<()> {
|
pub async fn cmd_git_commit(dir: &Path, message: &str) -> Result<()> {
|
||||||
Ok(git_commit(dir, message).await?)
|
Ok(git_commit(dir, message)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_git_fetch_all(dir: &Path) -> Result<()> {
|
pub async fn cmd_git_fetch_all(dir: &Path) -> Result<()> {
|
||||||
Ok(git_fetch_all(dir).await?)
|
Ok(git_fetch_all(dir)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_git_push(dir: &Path) -> Result<PushResult> {
|
pub async fn cmd_git_push(dir: &Path) -> Result<PushResult> {
|
||||||
Ok(git_push(dir).await?)
|
Ok(git_push(dir)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_git_pull(dir: &Path) -> Result<PullResult> {
|
pub async fn cmd_git_pull(dir: &Path) -> Result<PullResult> {
|
||||||
Ok(git_pull(dir).await?)
|
Ok(git_pull(dir)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
|
|||||||
@@ -178,11 +178,14 @@ async fn send_http_request_inner<R: Runtime>(
|
|||||||
window.db().resolve_environments(&workspace.id, folder_id, environment_id.as_deref())?;
|
window.db().resolve_environments(&workspace.id, folder_id, environment_id.as_deref())?;
|
||||||
let request = render_http_request(&resolved, env_chain, &cb, &RenderOptions::throw()).await?;
|
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
|
// Build the sendable request using the new SendableHttpRequest type
|
||||||
let options = SendableHttpRequestOptions {
|
let options = SendableHttpRequestOptions {
|
||||||
follow_redirects: workspace.setting_follow_redirects,
|
follow_redirects: resolved_settings.follow_redirects,
|
||||||
timeout: if workspace.setting_request_timeout > 0 {
|
timeout: if resolved_settings.request_timeout > 0 {
|
||||||
Some(Duration::from_millis(workspace.setting_request_timeout.unsigned_abs() as u64))
|
Some(Duration::from_millis(resolved_settings.request_timeout.unsigned_abs() as u64))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
@@ -231,7 +234,7 @@ async fn send_http_request_inner<R: Runtime>(
|
|||||||
let client = connection_manager
|
let client = connection_manager
|
||||||
.get_client(&HttpConnectionOptions {
|
.get_client(&HttpConnectionOptions {
|
||||||
id: plugin_context.id.clone(),
|
id: plugin_context.id.clone(),
|
||||||
validate_certificates: workspace.setting_validate_certificates,
|
validate_certificates: resolved_settings.validate_certificates,
|
||||||
proxy: proxy_setting,
|
proxy: proxy_setting,
|
||||||
client_certificate,
|
client_certificate,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ async fn cmd_grpc_reflect<R: Runtime>(
|
|||||||
&uri,
|
&uri,
|
||||||
&proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect(),
|
&proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect(),
|
||||||
&metadata,
|
&metadata,
|
||||||
workspace.setting_validate_certificates,
|
workspace.setting_validate_certificates.unwrap_or(true),
|
||||||
client_certificate,
|
client_certificate,
|
||||||
skip_cache.unwrap_or(false),
|
skip_cache.unwrap_or(false),
|
||||||
)
|
)
|
||||||
@@ -327,7 +327,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
uri.as_str(),
|
uri.as_str(),
|
||||||
&proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect(),
|
&proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect(),
|
||||||
&metadata,
|
&metadata,
|
||||||
workspace.setting_validate_certificates,
|
workspace.setting_validate_certificates.unwrap_or(true),
|
||||||
client_cert.clone(),
|
client_cert.clone(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -360,8 +360,10 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
|
|
||||||
let cb = {
|
let cb = {
|
||||||
let cancelled_rx = cancelled_rx.clone();
|
let cancelled_rx = cancelled_rx.clone();
|
||||||
|
let app_handle = app_handle.clone();
|
||||||
let environment_chain = environment_chain.clone();
|
let environment_chain = environment_chain.clone();
|
||||||
let window = window.clone();
|
let window = window.clone();
|
||||||
|
let base_msg = base_msg.clone();
|
||||||
let plugin_manager = plugin_manager.clone();
|
let plugin_manager = plugin_manager.clone();
|
||||||
let encryption_manager = encryption_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()) {
|
match serde_json::from_str::<IncomingMsg>(ev.payload()) {
|
||||||
Ok(IncomingMsg::Message(msg)) => {
|
Ok(IncomingMsg::Message(msg)) => {
|
||||||
let window = window.clone();
|
let window = window.clone();
|
||||||
|
let app_handle = app_handle.clone();
|
||||||
|
let base_msg = base_msg.clone();
|
||||||
let environment_chain = environment_chain.clone();
|
let environment_chain = environment_chain.clone();
|
||||||
let plugin_manager = plugin_manager.clone();
|
let plugin_manager = plugin_manager.clone();
|
||||||
let encryption_manager = encryption_manager.clone();
|
let encryption_manager = encryption_manager.clone();
|
||||||
let msg = block_in_place(|| {
|
let msg = block_in_place(|| {
|
||||||
tauri::async_runtime::block_on(async {
|
tauri::async_runtime::block_on(async {
|
||||||
let result = render_template(
|
render_template(
|
||||||
msg.as_str(),
|
msg.as_str(),
|
||||||
environment_chain,
|
environment_chain,
|
||||||
&PluginTemplateCallback::new(
|
&PluginTemplateCallback::new(
|
||||||
@@ -402,11 +406,24 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
),
|
),
|
||||||
&RenderOptions { error_behavior: RenderErrorBehavior::Throw },
|
&RenderOptions { error_behavior: RenderErrorBehavior::Throw },
|
||||||
)
|
)
|
||||||
.await;
|
.await
|
||||||
result.expect("Failed to render template")
|
.expect("Failed to render template")
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
in_msg_tx.try_send(msg.clone()).unwrap();
|
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) => {
|
Ok(IncomingMsg::Commit) => {
|
||||||
maybe_in_msg_tx.take();
|
maybe_in_msg_tx.take();
|
||||||
@@ -453,48 +470,12 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
async move {
|
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) =
|
let (maybe_stream, maybe_msg) =
|
||||||
match (method_desc.is_client_streaming(), method_desc.is_server_streaming()) {
|
match (method_desc.is_client_streaming(), method_desc.is_server_streaming()) {
|
||||||
(true, true) => (
|
(true, true) => (
|
||||||
Some(
|
Some(
|
||||||
connection
|
connection
|
||||||
.streaming(
|
.streaming(&service, &method, in_msg_stream, &metadata, client_cert)
|
||||||
&service,
|
|
||||||
&method,
|
|
||||||
in_msg_stream,
|
|
||||||
&metadata,
|
|
||||||
client_cert,
|
|
||||||
on_message.clone(),
|
|
||||||
)
|
|
||||||
.await,
|
.await,
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
@@ -509,7 +490,6 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
in_msg_stream,
|
in_msg_stream,
|
||||||
&metadata,
|
&metadata,
|
||||||
client_cert,
|
client_cert,
|
||||||
on_message.clone(),
|
|
||||||
)
|
)
|
||||||
.await,
|
.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)?;
|
let window = get_window_from_plugin_context(app_handle, &plugin_context)?;
|
||||||
Ok(call_frontend(&window, event).await)
|
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) => {
|
InternalEventPayload::FindHttpResponsesRequest(req) => {
|
||||||
let http_responses = app_handle
|
let http_responses = app_handle
|
||||||
.db()
|
.db()
|
||||||
|
|||||||
@@ -355,7 +355,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
|||||||
url.as_str(),
|
url.as_str(),
|
||||||
headers,
|
headers,
|
||||||
receive_tx,
|
receive_tx,
|
||||||
workspace.setting_validate_certificates,
|
workspace.setting_validate_certificates.unwrap_or(true),
|
||||||
client_cert,
|
client_cert,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -44,8 +44,8 @@
|
|||||||
"vendored/protoc/include",
|
"vendored/protoc/include",
|
||||||
"vendored/plugins",
|
"vendored/plugins",
|
||||||
"vendored/plugin-runtime",
|
"vendored/plugin-runtime",
|
||||||
"vendored/node/yaaknode*",
|
"vendored/node/yaaknode",
|
||||||
"vendored/protoc/yaakprotoc*"
|
"vendored/protoc/yaakprotoc"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,4 +6,3 @@ publish = false
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["process"] }
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
use std::ffi::OsStr;
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
|
||||||
|
|
||||||
/// Creates a new `tokio::process::Command` that won't spawn a console window on Windows.
|
|
||||||
pub fn new_xplatform_command<S: AsRef<OsStr>>(program: S) -> tokio::process::Command {
|
|
||||||
#[allow(unused_mut)]
|
|
||||||
let mut cmd = tokio::process::Command::new(program);
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
use std::os::windows::process::CommandExt;
|
|
||||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
|
||||||
}
|
|
||||||
cmd
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,2 @@
|
|||||||
pub mod command;
|
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
pub mod serde;
|
pub mod serde;
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ serde = { workspace = true, features = ["derive"] }
|
|||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
serde_yaml = "0.9.34"
|
serde_yaml = "0.9.34"
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["io-util"] }
|
|
||||||
ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] }
|
ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] }
|
||||||
url = "2"
|
url = "2"
|
||||||
yaak-common = { workspace = true }
|
|
||||||
yaak-models = { workspace = true }
|
yaak-models = { workspace = true }
|
||||||
yaak-sync = { workspace = true }
|
yaak-sync = { workspace = true }
|
||||||
|
|||||||
@@ -1,24 +1,38 @@
|
|||||||
use crate::error::Error::GitNotFound;
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::Stdio;
|
use std::process::{Command, Stdio};
|
||||||
use tokio::process::Command;
|
|
||||||
use yaak_common::command::new_xplatform_command;
|
|
||||||
|
|
||||||
pub(crate) async fn new_binary_command(dir: &Path) -> Result<Command> {
|
use crate::error::Error::GitNotFound;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||||
|
|
||||||
|
pub(crate) fn new_binary_command(dir: &Path) -> Result<Command> {
|
||||||
// 1. Probe that `git` exists and is runnable
|
// 1. Probe that `git` exists and is runnable
|
||||||
let mut probe = new_xplatform_command("git");
|
let mut probe = Command::new("git");
|
||||||
probe.arg("--version").stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
|
probe.arg("--version").stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
|
||||||
|
|
||||||
let status = probe.status().await.map_err(|_| GitNotFound)?;
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
probe.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = probe.status().map_err(|_| GitNotFound)?;
|
||||||
|
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err(GitNotFound);
|
return Err(GitNotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Build the reusable git command
|
// 2. Build the reusable git command
|
||||||
let mut cmd = new_xplatform_command("git");
|
let mut cmd = Command::new("git");
|
||||||
cmd.arg("-C").arg(dir);
|
cmd.arg("-C").arg(dir);
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(cmd)
|
Ok(cmd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ use crate::error::Error::GenericError;
|
|||||||
use log::info;
|
use log::info;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
pub async fn git_commit(dir: &Path, message: &str) -> crate::error::Result<()> {
|
pub fn git_commit(dir: &Path, message: &str) -> crate::error::Result<()> {
|
||||||
let out =
|
let out = new_binary_command(dir)?.args(["commit", "--message", message]).output()?;
|
||||||
new_binary_command(dir).await?.args(["commit", "--message", message]).output().await?;
|
|
||||||
|
|
||||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use crate::binary::new_binary_command;
|
use crate::binary::new_binary_command;
|
||||||
use crate::error::Error::GenericError;
|
use crate::error::Error::GenericError;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
use std::io::Write;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use tokio::io::AsyncWriteExt;
|
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
pub async fn git_add_credential(
|
pub async fn git_add_credential(
|
||||||
@@ -18,8 +18,7 @@ pub async fn git_add_credential(
|
|||||||
let host = url.host_str().unwrap();
|
let host = url.host_str().unwrap();
|
||||||
let path = Some(url.path());
|
let path = Some(url.path());
|
||||||
|
|
||||||
let mut child = new_binary_command(dir)
|
let mut child = new_binary_command(dir)?
|
||||||
.await?
|
|
||||||
.args(["credential", "approve"])
|
.args(["credential", "approve"])
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
@@ -27,21 +26,19 @@ pub async fn git_add_credential(
|
|||||||
|
|
||||||
{
|
{
|
||||||
let stdin = child.stdin.as_mut().unwrap();
|
let stdin = child.stdin.as_mut().unwrap();
|
||||||
stdin.write_all(format!("protocol={}\n", protocol).as_bytes()).await?;
|
writeln!(stdin, "protocol={}", protocol)?;
|
||||||
stdin.write_all(format!("host={}\n", host).as_bytes()).await?;
|
writeln!(stdin, "host={}", host)?;
|
||||||
if let Some(path) = path {
|
if let Some(path) = path {
|
||||||
if !path.is_empty() {
|
if !path.is_empty() {
|
||||||
stdin
|
writeln!(stdin, "path={}", path.trim_start_matches('/'))?;
|
||||||
.write_all(format!("path={}\n", path.trim_start_matches('/')).as_bytes())
|
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stdin.write_all(format!("username={}\n", username).as_bytes()).await?;
|
writeln!(stdin, "username={}", username)?;
|
||||||
stdin.write_all(format!("password={}\n", password).as_bytes()).await?;
|
writeln!(stdin, "password={}", password)?;
|
||||||
stdin.write_all(b"\n").await?; // blank line terminator
|
writeln!(stdin)?; // blank line terminator
|
||||||
}
|
}
|
||||||
|
|
||||||
let status = child.wait().await?;
|
let status = child.wait()?;
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err(GenericError("Failed to approve git credential".to_string()));
|
return Err(GenericError("Failed to approve git credential".to_string()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,10 @@ use crate::error::Error::GenericError;
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
pub async fn git_fetch_all(dir: &Path) -> Result<()> {
|
pub fn git_fetch_all(dir: &Path) -> Result<()> {
|
||||||
let out = new_binary_command(dir)
|
let out = new_binary_command(dir)?
|
||||||
.await?
|
|
||||||
.args(["fetch", "--all", "--prune", "--tags"])
|
.args(["fetch", "--all", "--prune", "--tags"])
|
||||||
.output()
|
.output()
|
||||||
.await
|
|
||||||
.map_err(|e| GenericError(format!("failed to run git pull: {e}")))?;
|
.map_err(|e| GenericError(format!("failed to run git pull: {e}")))?;
|
||||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
|||||||
@@ -17,25 +17,17 @@ pub enum PullResult {
|
|||||||
NeedsCredentials { url: String, error: Option<String> },
|
NeedsCredentials { url: String, error: Option<String> },
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn git_pull(dir: &Path) -> Result<PullResult> {
|
pub fn git_pull(dir: &Path) -> Result<PullResult> {
|
||||||
// Extract all git2 data before any await points (git2 types are not Send)
|
let repo = open_repo(dir)?;
|
||||||
let (branch_name, remote_name, remote_url) = {
|
let branch_name = get_current_branch_name(&repo)?;
|
||||||
let repo = open_repo(dir)?;
|
let remote = get_default_remote_in_repo(&repo)?;
|
||||||
let branch_name = get_current_branch_name(&repo)?;
|
let remote_name = remote.name().ok_or(GenericError("Failed to get remote name".to_string()))?;
|
||||||
let remote = get_default_remote_in_repo(&repo)?;
|
let remote_url = remote.url().ok_or(GenericError("Failed to get remote url".to_string()))?;
|
||||||
let remote_name =
|
|
||||||
remote.name().ok_or(GenericError("Failed to get remote name".to_string()))?.to_string();
|
|
||||||
let remote_url =
|
|
||||||
remote.url().ok_or(GenericError("Failed to get remote url".to_string()))?.to_string();
|
|
||||||
(branch_name, remote_name, remote_url)
|
|
||||||
};
|
|
||||||
|
|
||||||
let out = new_binary_command(dir)
|
let out = new_binary_command(dir)?
|
||||||
.await?
|
|
||||||
.args(["pull", &remote_name, &branch_name])
|
.args(["pull", &remote_name, &branch_name])
|
||||||
.env("GIT_TERMINAL_PROMPT", "0")
|
.env("GIT_TERMINAL_PROMPT", "0")
|
||||||
.output()
|
.output()
|
||||||
.await
|
|
||||||
.map_err(|e| GenericError(format!("failed to run git pull: {e}")))?;
|
.map_err(|e| GenericError(format!("failed to run git pull: {e}")))?;
|
||||||
|
|
||||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
|||||||
@@ -17,25 +17,17 @@ pub enum PushResult {
|
|||||||
NeedsCredentials { url: String, error: Option<String> },
|
NeedsCredentials { url: String, error: Option<String> },
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn git_push(dir: &Path) -> Result<PushResult> {
|
pub fn git_push(dir: &Path) -> Result<PushResult> {
|
||||||
// Extract all git2 data before any await points (git2 types are not Send)
|
let repo = open_repo(dir)?;
|
||||||
let (branch_name, remote_name, remote_url) = {
|
let branch_name = get_current_branch_name(&repo)?;
|
||||||
let repo = open_repo(dir)?;
|
let remote = get_default_remote_for_push_in_repo(&repo)?;
|
||||||
let branch_name = get_current_branch_name(&repo)?;
|
let remote_name = remote.name().ok_or(GenericError("Failed to get remote name".to_string()))?;
|
||||||
let remote = get_default_remote_for_push_in_repo(&repo)?;
|
let remote_url = remote.url().ok_or(GenericError("Failed to get remote url".to_string()))?;
|
||||||
let remote_name =
|
|
||||||
remote.name().ok_or(GenericError("Failed to get remote name".to_string()))?.to_string();
|
|
||||||
let remote_url =
|
|
||||||
remote.url().ok_or(GenericError("Failed to get remote url".to_string()))?.to_string();
|
|
||||||
(branch_name, remote_name, remote_url)
|
|
||||||
};
|
|
||||||
|
|
||||||
let out = new_binary_command(dir)
|
let out = new_binary_command(dir)?
|
||||||
.await?
|
|
||||||
.args(["push", &remote_name, &branch_name])
|
.args(["push", &remote_name, &branch_name])
|
||||||
.env("GIT_TERMINAL_PROMPT", "0")
|
.env("GIT_TERMINAL_PROMPT", "0")
|
||||||
.output()
|
.output()
|
||||||
.await
|
|
||||||
.map_err(|e| GenericError(format!("failed to run git push: {e}")))?;
|
.map_err(|e| GenericError(format!("failed to run git push: {e}")))?;
|
||||||
|
|
||||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
|||||||
@@ -22,6 +22,5 @@ tokio-stream = "0.1.14"
|
|||||||
tonic = { version = "0.12.3", default-features = false, features = ["transport"] }
|
tonic = { version = "0.12.3", default-features = false, features = ["transport"] }
|
||||||
tonic-reflection = "0.12.3"
|
tonic-reflection = "0.12.3"
|
||||||
uuid = { version = "1.7.0", features = ["v4"] }
|
uuid = { version = "1.7.0", features = ["v4"] }
|
||||||
yaak-common = { workspace = true }
|
|
||||||
yaak-tls = { workspace = true }
|
yaak-tls = { workspace = true }
|
||||||
thiserror = "2.0.17"
|
thiserror = "2.0.17"
|
||||||
|
|||||||
@@ -115,18 +115,14 @@ impl GrpcConnection {
|
|||||||
Ok(client.unary(req, path, codec).await?)
|
Ok(client.unary(req, path, codec).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn streaming<F>(
|
pub async fn streaming(
|
||||||
&self,
|
&self,
|
||||||
service: &str,
|
service: &str,
|
||||||
method: &str,
|
method: &str,
|
||||||
stream: ReceiverStream<String>,
|
stream: ReceiverStream<String>,
|
||||||
metadata: &BTreeMap<String, String>,
|
metadata: &BTreeMap<String, String>,
|
||||||
client_cert: Option<ClientCertificateConfig>,
|
client_cert: Option<ClientCertificateConfig>,
|
||||||
on_message: F,
|
) -> Result<Response<Streaming<DynamicMessage>>> {
|
||||||
) -> Result<Response<Streaming<DynamicMessage>>>
|
|
||||||
where
|
|
||||||
F: Fn(std::result::Result<String, String>) + Send + Sync + Clone + 'static,
|
|
||||||
{
|
|
||||||
let method = &self.method(&service, &method).await?;
|
let method = &self.method(&service, &method).await?;
|
||||||
let mapped_stream = {
|
let mapped_stream = {
|
||||||
let input_message = method.input();
|
let input_message = method.input();
|
||||||
@@ -135,39 +131,31 @@ impl GrpcConnection {
|
|||||||
let md = metadata.clone();
|
let md = metadata.clone();
|
||||||
let use_reflection = self.use_reflection.clone();
|
let use_reflection = self.use_reflection.clone();
|
||||||
let client_cert = client_cert.clone();
|
let client_cert = client_cert.clone();
|
||||||
stream
|
stream.filter_map(move |json| {
|
||||||
.then(move |json| {
|
let pool = pool.clone();
|
||||||
let pool = pool.clone();
|
let uri = uri.clone();
|
||||||
let uri = uri.clone();
|
let input_message = input_message.clone();
|
||||||
let input_message = input_message.clone();
|
let md = md.clone();
|
||||||
let md = md.clone();
|
let use_reflection = use_reflection.clone();
|
||||||
let use_reflection = use_reflection.clone();
|
let client_cert = client_cert.clone();
|
||||||
let client_cert = client_cert.clone();
|
tokio::runtime::Handle::current().block_on(async move {
|
||||||
let on_message = on_message.clone();
|
if use_reflection {
|
||||||
let json_clone = json.clone();
|
if let Err(e) =
|
||||||
async move {
|
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
|
||||||
if use_reflection {
|
{
|
||||||
if let Err(e) =
|
warn!("Failed to resolve Any types: {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) {
|
let mut de = Deserializer::from_str(&json);
|
||||||
Ok(m) => {
|
match DynamicMessage::deserialize(input_message, &mut de) {
|
||||||
on_message(Ok(json_clone));
|
Ok(m) => Some(m),
|
||||||
Some(m)
|
Err(e) => {
|
||||||
}
|
warn!("Failed to deserialize message: {e}");
|
||||||
Err(e) => {
|
None
|
||||||
warn!("Failed to deserialize message: {e}");
|
|
||||||
on_message(Err(e.to_string()));
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter_map(|x| x)
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
|
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?)
|
Ok(client.streaming(req, path, codec).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn client_streaming<F>(
|
pub async fn client_streaming(
|
||||||
&self,
|
&self,
|
||||||
service: &str,
|
service: &str,
|
||||||
method: &str,
|
method: &str,
|
||||||
stream: ReceiverStream<String>,
|
stream: ReceiverStream<String>,
|
||||||
metadata: &BTreeMap<String, String>,
|
metadata: &BTreeMap<String, String>,
|
||||||
client_cert: Option<ClientCertificateConfig>,
|
client_cert: Option<ClientCertificateConfig>,
|
||||||
on_message: F,
|
) -> Result<Response<DynamicMessage>> {
|
||||||
) -> Result<Response<DynamicMessage>>
|
|
||||||
where
|
|
||||||
F: Fn(std::result::Result<String, String>) + Send + Sync + Clone + 'static,
|
|
||||||
{
|
|
||||||
let method = &self.method(&service, &method).await?;
|
let method = &self.method(&service, &method).await?;
|
||||||
let mapped_stream = {
|
let mapped_stream = {
|
||||||
let input_message = method.input();
|
let input_message = method.input();
|
||||||
@@ -201,39 +185,31 @@ impl GrpcConnection {
|
|||||||
let md = metadata.clone();
|
let md = metadata.clone();
|
||||||
let use_reflection = self.use_reflection.clone();
|
let use_reflection = self.use_reflection.clone();
|
||||||
let client_cert = client_cert.clone();
|
let client_cert = client_cert.clone();
|
||||||
stream
|
stream.filter_map(move |json| {
|
||||||
.then(move |json| {
|
let pool = pool.clone();
|
||||||
let pool = pool.clone();
|
let uri = uri.clone();
|
||||||
let uri = uri.clone();
|
let input_message = input_message.clone();
|
||||||
let input_message = input_message.clone();
|
let md = md.clone();
|
||||||
let md = md.clone();
|
let use_reflection = use_reflection.clone();
|
||||||
let use_reflection = use_reflection.clone();
|
let client_cert = client_cert.clone();
|
||||||
let client_cert = client_cert.clone();
|
tokio::runtime::Handle::current().block_on(async move {
|
||||||
let on_message = on_message.clone();
|
if use_reflection {
|
||||||
let json_clone = json.clone();
|
if let Err(e) =
|
||||||
async move {
|
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
|
||||||
if use_reflection {
|
{
|
||||||
if let Err(e) =
|
warn!("Failed to resolve Any types: {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) {
|
let mut de = Deserializer::from_str(&json);
|
||||||
Ok(m) => {
|
match DynamicMessage::deserialize(input_message, &mut de) {
|
||||||
on_message(Ok(json_clone));
|
Ok(m) => Some(m),
|
||||||
Some(m)
|
Err(e) => {
|
||||||
}
|
warn!("Failed to deserialize message: {e}");
|
||||||
Err(e) => {
|
None
|
||||||
warn!("Failed to deserialize message: {e}");
|
|
||||||
on_message(Err(e.to_string()));
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter_map(|x| x)
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
|
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
use tokio::process::Command;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tonic::codegen::http::uri::PathAndQuery;
|
use tonic::codegen::http::uri::PathAndQuery;
|
||||||
use tonic::transport::Uri;
|
use tonic::transport::Uri;
|
||||||
use tonic_reflection::pb::v1::server_reflection_request::MessageRequest;
|
use tonic_reflection::pb::v1::server_reflection_request::MessageRequest;
|
||||||
use tonic_reflection::pb::v1::server_reflection_response::MessageResponse;
|
use tonic_reflection::pb::v1::server_reflection_response::MessageResponse;
|
||||||
use yaak_common::command::new_xplatform_command;
|
|
||||||
use yaak_tls::ClientCertificateConfig;
|
use yaak_tls::ClientCertificateConfig;
|
||||||
|
|
||||||
pub async fn fill_pool_from_files(
|
pub async fn fill_pool_from_files(
|
||||||
@@ -91,11 +91,11 @@ pub async fn fill_pool_from_files(
|
|||||||
|
|
||||||
info!("Invoking protoc with {}", args.join(" "));
|
info!("Invoking protoc with {}", args.join(" "));
|
||||||
|
|
||||||
let mut cmd = new_xplatform_command(&config.protoc_bin_path);
|
let out = Command::new(&config.protoc_bin_path)
|
||||||
cmd.args(&args);
|
.args(&args)
|
||||||
|
.output()
|
||||||
let out =
|
.await
|
||||||
cmd.output().await.map_err(|e| GenericError(format!("Failed to run protoc: {}", e)))?;
|
.map_err(|e| GenericError(format!("Failed to run protoc: {}", e)))?;
|
||||||
|
|
||||||
if !out.status.success() {
|
if !out.status.success() {
|
||||||
return Err(GenericError(format!(
|
return Err(GenericError(format!(
|
||||||
|
|||||||
@@ -342,8 +342,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_transaction_single_redirect() {
|
async fn test_transaction_single_redirect() {
|
||||||
let redirect_headers =
|
let redirect_headers = vec![("Location".to_string(), "https://example.com/new".to_string())];
|
||||||
vec![("Location".to_string(), "https://example.com/new".to_string())];
|
|
||||||
|
|
||||||
let responses = vec![
|
let responses = vec![
|
||||||
MockResponse { status: 302, headers: redirect_headers, body: vec![] },
|
MockResponse { status: 302, headers: redirect_headers, body: vec![] },
|
||||||
@@ -374,8 +373,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_transaction_max_redirects_exceeded() {
|
async fn test_transaction_max_redirects_exceeded() {
|
||||||
let redirect_headers =
|
let redirect_headers = vec![("Location".to_string(), "https://example.com/loop".to_string())];
|
||||||
vec![("Location".to_string(), "https://example.com/loop".to_string())];
|
|
||||||
|
|
||||||
// Create more redirects than allowed
|
// Create more redirects than allowed
|
||||||
let responses: Vec<MockResponse> = (0..12)
|
let responses: Vec<MockResponse> = (0..12)
|
||||||
@@ -527,8 +525,7 @@ mod tests {
|
|||||||
_request: SendableHttpRequest,
|
_request: SendableHttpRequest,
|
||||||
_event_tx: mpsc::Sender<HttpResponseEvent>,
|
_event_tx: mpsc::Sender<HttpResponseEvent>,
|
||||||
) -> Result<HttpResponse> {
|
) -> Result<HttpResponse> {
|
||||||
let headers =
|
let headers = vec![("set-cookie".to_string(), "session=xyz789; Path=/".to_string())];
|
||||||
vec![("set-cookie".to_string(), "session=xyz789; Path=/".to_string())];
|
|
||||||
|
|
||||||
let body_stream: Pin<Box<dyn AsyncRead + Send>> =
|
let body_stream: Pin<Box<dyn AsyncRead + Send>> =
|
||||||
Box::pin(std::io::Cursor::new(vec![]));
|
Box::pin(std::io::Cursor::new(vec![]));
|
||||||
@@ -587,10 +584,7 @@ mod tests {
|
|||||||
let headers = vec![
|
let headers = vec![
|
||||||
("set-cookie".to_string(), "session=abc123; Path=/".to_string()),
|
("set-cookie".to_string(), "session=abc123; Path=/".to_string()),
|
||||||
("set-cookie".to_string(), "user_id=42; 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>> =
|
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) {
|
function shouldIgnoreModel({ model, updateSource }: ModelPayload) {
|
||||||
// Never ignore updates from non-user sources
|
// Never ignore updates from non-user sources
|
||||||
if (updateSource.type !== 'window') {
|
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::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 crate::util::{UpdateSource, generate_prefixed_id};
|
||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
use rusqlite::Row;
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
|
||||||
#[serde(default, rename_all = "camelCase")]
|
#[serde(default, rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = "gen_models.ts")]
|
#[ts(export, export_to = "gen_models.ts")]
|
||||||
@@ -297,12 +323,10 @@ pub struct Workspace {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub encryption_key_challenge: Option<String>,
|
pub encryption_key_challenge: Option<String>,
|
||||||
|
|
||||||
// Settings
|
// Inheritable settings (Option = can be null, defaults applied at resolution time)
|
||||||
#[serde(default = "default_true")]
|
pub setting_validate_certificates: Option<bool>,
|
||||||
pub setting_validate_certificates: bool,
|
pub setting_follow_redirects: Option<bool>,
|
||||||
#[serde(default = "default_true")]
|
pub setting_request_timeout: Option<i32>,
|
||||||
pub setting_follow_redirects: bool,
|
|
||||||
pub setting_request_timeout: i32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UpsertModelInfo for Workspace {
|
impl UpsertModelInfo for Workspace {
|
||||||
@@ -726,6 +750,11 @@ pub struct Folder {
|
|||||||
pub headers: Vec<HttpRequestHeader>,
|
pub headers: Vec<HttpRequestHeader>,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub sort_priority: f64,
|
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 {
|
impl UpsertModelInfo for Folder {
|
||||||
@@ -765,6 +794,9 @@ impl UpsertModelInfo for Folder {
|
|||||||
(Description, self.description.into()),
|
(Description, self.description.into()),
|
||||||
(Name, self.name.trim().into()),
|
(Name, self.name.trim().into()),
|
||||||
(SortPriority, self.sort_priority.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::Description,
|
||||||
FolderIden::FolderId,
|
FolderIden::FolderId,
|
||||||
FolderIden::SortPriority,
|
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(),
|
headers: serde_json::from_str(&headers).unwrap_or_default(),
|
||||||
authentication_type: row.get("authentication_type")?,
|
authentication_type: row.get("authentication_type")?,
|
||||||
authentication: serde_json::from_str(&authentication).unwrap_or_default(),
|
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 sort_priority: f64,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub url_parameters: Vec<HttpUrlParameter>,
|
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 {
|
impl UpsertModelInfo for HttpRequest {
|
||||||
@@ -884,6 +927,7 @@ impl UpsertModelInfo for HttpRequest {
|
|||||||
self,
|
self,
|
||||||
source: &UpdateSource,
|
source: &UpdateSource,
|
||||||
) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {
|
) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {
|
||||||
|
use HttpRequestIden::*;
|
||||||
Ok(vec![
|
Ok(vec![
|
||||||
(CreatedAt, upsert_date(source, self.created_at)),
|
(CreatedAt, upsert_date(source, self.created_at)),
|
||||||
(UpdatedAt, upsert_date(source, self.updated_at)),
|
(UpdatedAt, upsert_date(source, self.updated_at)),
|
||||||
@@ -900,10 +944,14 @@ impl UpsertModelInfo for HttpRequest {
|
|||||||
(AuthenticationType, self.authentication_type.into()),
|
(AuthenticationType, self.authentication_type.into()),
|
||||||
(Headers, serde_json::to_string(&self.headers)?.into()),
|
(Headers, serde_json::to_string(&self.headers)?.into()),
|
||||||
(SortPriority, self.sort_priority.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> {
|
fn update_columns() -> Vec<impl IntoIden> {
|
||||||
|
use HttpRequestIden::*;
|
||||||
vec![
|
vec![
|
||||||
UpdatedAt,
|
UpdatedAt,
|
||||||
WorkspaceId,
|
WorkspaceId,
|
||||||
@@ -919,6 +967,9 @@ impl UpsertModelInfo for HttpRequest {
|
|||||||
Url,
|
Url,
|
||||||
UrlParameters,
|
UrlParameters,
|
||||||
SortPriority,
|
SortPriority,
|
||||||
|
SettingValidateCertificates,
|
||||||
|
SettingFollowRedirects,
|
||||||
|
SettingRequestTimeout,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -945,6 +996,9 @@ impl UpsertModelInfo for HttpRequest {
|
|||||||
sort_priority: row.get("sort_priority")?,
|
sort_priority: row.get("sort_priority")?,
|
||||||
url: row.get("url")?,
|
url: row.get("url")?,
|
||||||
url_parameters: serde_json::from_str(url_parameters.as_str()).unwrap_or_default(),
|
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::db_context::DbContext;
|
||||||
use crate::error::Result;
|
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 crate::util::UpdateSource;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
@@ -103,4 +103,79 @@ impl<'a> DbContext<'a> {
|
|||||||
}
|
}
|
||||||
Ok(children)
|
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(
|
workspaces.push(self.upsert_workspace(
|
||||||
&Workspace {
|
&Workspace {
|
||||||
name: "Yaak".to_string(),
|
name: "Yaak".to_string(),
|
||||||
setting_follow_redirects: true,
|
setting_follow_redirects: Some(true),
|
||||||
setting_validate_certificates: true,
|
setting_validate_certificates: Some(true),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
&UpdateSource::Background,
|
&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),
|
PromptTextRequest(PromptTextRequest),
|
||||||
PromptTextResponse(PromptTextResponse),
|
PromptTextResponse(PromptTextResponse),
|
||||||
|
|
||||||
PromptFormRequest(PromptFormRequest),
|
|
||||||
PromptFormResponse(PromptFormResponse),
|
|
||||||
|
|
||||||
WindowInfoRequest(WindowInfoRequest),
|
WindowInfoRequest(WindowInfoRequest),
|
||||||
WindowInfoResponse(WindowInfoResponse),
|
WindowInfoResponse(WindowInfoResponse),
|
||||||
|
|
||||||
@@ -574,28 +571,6 @@ pub struct PromptTextResponse {
|
|||||||
pub value: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||||
#[serde(default, rename_all = "camelCase")]
|
#[serde(default, rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = "gen_events.ts")]
|
#[ts(export, export_to = "gen_events.ts")]
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ use std::net::SocketAddr;
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
use tokio::process::Command;
|
||||||
use tokio::sync::watch::Receiver;
|
use tokio::sync::watch::Receiver;
|
||||||
use yaak_common::command::new_xplatform_command;
|
|
||||||
|
|
||||||
/// Start the Node.js plugin runtime process.
|
/// Start the Node.js plugin runtime process.
|
||||||
///
|
///
|
||||||
@@ -30,14 +30,13 @@ pub async fn start_nodejs_plugin_runtime(
|
|||||||
plugin_runtime_main_str
|
plugin_runtime_main_str
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut cmd = new_xplatform_command(node_bin_path);
|
let mut child = Command::new(node_bin_path)
|
||||||
cmd.env("HOST", addr.ip().to_string())
|
.env("HOST", addr.ip().to_string())
|
||||||
.env("PORT", addr.port().to_string())
|
.env("PORT", addr.port().to_string())
|
||||||
.arg(&plugin_runtime_main_str)
|
.arg(&plugin_runtime_main_str)
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped());
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()?;
|
||||||
let mut child = cmd.spawn()?;
|
|
||||||
|
|
||||||
info!("Spawned plugin runtime");
|
info!("Spawned plugin runtime");
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -11,8 +11,6 @@ import type {
|
|||||||
ListHttpRequestsRequest,
|
ListHttpRequestsRequest,
|
||||||
ListHttpRequestsResponse,
|
ListHttpRequestsResponse,
|
||||||
OpenWindowRequest,
|
OpenWindowRequest,
|
||||||
PromptFormRequest,
|
|
||||||
PromptFormResponse,
|
|
||||||
PromptTextRequest,
|
PromptTextRequest,
|
||||||
PromptTextResponse,
|
PromptTextResponse,
|
||||||
RenderGrpcRequestRequest,
|
RenderGrpcRequestRequest,
|
||||||
@@ -39,7 +37,6 @@ export interface Context {
|
|||||||
};
|
};
|
||||||
prompt: {
|
prompt: {
|
||||||
text(args: PromptTextRequest): Promise<PromptTextResponse['value']>;
|
text(args: PromptTextRequest): Promise<PromptTextResponse['value']>;
|
||||||
form(args: PromptFormRequest): Promise<PromptFormResponse['values']>;
|
|
||||||
};
|
};
|
||||||
store: {
|
store: {
|
||||||
set<T>(key: string, value: T): Promise<void>;
|
set<T>(key: string, value: T): Promise<void>;
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import type {
|
|||||||
ListHttpRequestsResponse,
|
ListHttpRequestsResponse,
|
||||||
ListWorkspacesResponse,
|
ListWorkspacesResponse,
|
||||||
PluginContext,
|
PluginContext,
|
||||||
PromptFormResponse,
|
|
||||||
PromptTextResponse,
|
PromptTextResponse,
|
||||||
RenderGrpcRequestResponse,
|
RenderGrpcRequestResponse,
|
||||||
RenderHttpRequestResponse,
|
RenderHttpRequestResponse,
|
||||||
@@ -662,13 +661,6 @@ export class PluginInstance {
|
|||||||
});
|
});
|
||||||
return reply.value;
|
return reply.value;
|
||||||
},
|
},
|
||||||
form: async (args) => {
|
|
||||||
const reply: PromptFormResponse = await this.#sendForReply(context, {
|
|
||||||
type: 'prompt_form_request',
|
|
||||||
...args,
|
|
||||||
});
|
|
||||||
return reply.values;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
httpResponse: {
|
httpResponse: {
|
||||||
find: async (args) => {
|
find: async (args) => {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
stateExtensions,
|
stateExtensions,
|
||||||
updateSchema,
|
updateSchema,
|
||||||
} from 'codemirror-json-schema';
|
} 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 type { ReflectResponseService } from '../hooks/useGrpc';
|
||||||
import { showAlert } from '../lib/alert';
|
import { showAlert } from '../lib/alert';
|
||||||
import { showDialog } from '../lib/dialog';
|
import { showDialog } from '../lib/dialog';
|
||||||
@@ -39,15 +39,15 @@ export function GrpcEditor({
|
|||||||
protoFiles,
|
protoFiles,
|
||||||
...extraEditorProps
|
...extraEditorProps
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [editorView, setEditorView] = useState<EditorView | null>(null);
|
const editorViewRef = useRef<EditorView>(null);
|
||||||
const handleInitEditorViewRef = useCallback((h: 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
|
// Find the schema for the selected service and method and update the editor
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
editorView == null ||
|
editorViewRef.current == null ||
|
||||||
services === null ||
|
services === null ||
|
||||||
request.service === null ||
|
request.service === null ||
|
||||||
request.method === null
|
request.method === null
|
||||||
@@ -91,7 +91,7 @@ export function GrpcEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
updateSchema(editorView, JSON.parse(schema));
|
updateSchema(editorViewRef.current, JSON.parse(schema));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showAlert({
|
showAlert({
|
||||||
id: 'grpc-parse-schema-error',
|
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(
|
const extraExtensions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -118,7 +118,7 @@ export function GrpcEditor({
|
|||||||
jsonLanguage.data.of({
|
jsonLanguage.data.of({
|
||||||
autocomplete: jsonCompletion(),
|
autocomplete: jsonCompletion(),
|
||||||
}),
|
}),
|
||||||
stateExtensions({}),
|
stateExtensions(/** Init with empty schema **/),
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { GrpcEvent, GrpcRequest } from '@yaakapp-internal/models';
|
import type { GrpcEvent, GrpcRequest } from '@yaakapp-internal/models';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { format } from 'date-fns';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
activeGrpcConnectionAtom,
|
activeGrpcConnectionAtom,
|
||||||
activeGrpcConnections,
|
activeGrpcConnections,
|
||||||
@@ -9,14 +11,18 @@ import {
|
|||||||
useGrpcEvents,
|
useGrpcEvents,
|
||||||
} from '../hooks/usePinnedGrpcConnection';
|
} from '../hooks/usePinnedGrpcConnection';
|
||||||
import { useStateWithDeps } from '../hooks/useStateWithDeps';
|
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 { Button } from './core/Button';
|
||||||
import { Editor } from './core/Editor/LazyEditor';
|
import { Editor } from './core/Editor/LazyEditor';
|
||||||
import { EventDetailHeader, EventViewer } from './core/EventViewer';
|
|
||||||
import { EventViewerRow } from './core/EventViewerRow';
|
|
||||||
import { HotkeyList } from './core/HotkeyList';
|
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 { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
|
||||||
import { LoadingIcon } from './core/LoadingIcon';
|
import { LoadingIcon } from './core/LoadingIcon';
|
||||||
|
import { Separator } from './core/Separator';
|
||||||
|
import { SplitLayout } from './core/SplitLayout';
|
||||||
import { HStack, VStack } from './core/Stacks';
|
import { HStack, VStack } from './core/Stacks';
|
||||||
import { EmptyStateText } from './EmptyStateText';
|
import { EmptyStateText } from './EmptyStateText';
|
||||||
import { ErrorBoundary } from './ErrorBoundary';
|
import { ErrorBoundary } from './ErrorBoundary';
|
||||||
@@ -36,7 +42,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GrpcResponsePane({ style, methodType, activeRequest }: 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 [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
|
||||||
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
||||||
const connections = useAtomValue(activeGrpcConnections);
|
const connections = useAtomValue(activeGrpcConnections);
|
||||||
@@ -45,8 +51,8 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
|
|||||||
const setPinnedGrpcConnectionId = useSetAtom(pinnedGrpcConnectionIdAtom);
|
const setPinnedGrpcConnectionId = useSetAtom(pinnedGrpcConnectionIdAtom);
|
||||||
|
|
||||||
const activeEvent = useMemo(
|
const activeEvent = useMemo(
|
||||||
() => (activeEventIndex != null ? events[activeEventIndex] : null),
|
() => events.find((m) => m.id === activeEventId) ?? null,
|
||||||
[activeEventIndex, events],
|
[activeEventId, events],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set the active message to the first message received if unary
|
// 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') {
|
if (events.length === 0 || activeEvent != null || methodType !== 'unary') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const firstServerMessageIndex = events.findIndex((m) => m.eventType === 'server_message');
|
setActiveEventId(events.find((m) => m.eventType === 'server_message')?.id ?? null);
|
||||||
if (firstServerMessageIndex !== -1) {
|
|
||||||
setActiveEventIndex(firstServerMessageIndex);
|
|
||||||
}
|
|
||||||
}, [events.length]);
|
}, [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 (
|
return (
|
||||||
<div style={style} className="h-full">
|
<SplitLayout
|
||||||
<ErrorBoundary name="GRPC Events">
|
layout="vertical"
|
||||||
<EventViewer
|
style={style}
|
||||||
events={events}
|
name="grpc_events"
|
||||||
getEventKey={(event) => event.id}
|
defaultRatio={0.4}
|
||||||
error={activeConnection.error}
|
minHeightPx={20}
|
||||||
header={header}
|
firstSlot={() =>
|
||||||
splitLayoutName="grpc_events"
|
activeConnection == null ? (
|
||||||
defaultRatio={0.4}
|
<HotkeyList
|
||||||
renderRow={({ event, isActive, onClick }) => (
|
hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']}
|
||||||
<GrpcEventRow event={event} isActive={isActive} onClick={onClick} />
|
/>
|
||||||
)}
|
) : (
|
||||||
renderDetail={({ event }) => (
|
<div className="w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 items-center">
|
||||||
<GrpcEventDetail
|
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle overflow-x-auto hide-scrollbars">
|
||||||
event={event}
|
<HStack space={2}>
|
||||||
showLarge={showLarge}
|
<span className="whitespace-nowrap">{events.length} Messages</span>
|
||||||
showingLarge={showingLarge}
|
{activeConnection.state !== 'closed' && (
|
||||||
setShowLarge={setShowLarge}
|
<LoadingIcon size="sm" className="text-text-subtlest" />
|
||||||
setShowingLarge={setShowingLarge}
|
)}
|
||||||
/>
|
</HStack>
|
||||||
)}
|
<div className="ml-auto">
|
||||||
/>
|
<RecentGrpcConnectionsDropdown
|
||||||
</ErrorBoundary>
|
connections={connections}
|
||||||
</div>
|
activeConnection={activeConnection}
|
||||||
);
|
onPinnedConnectionId={setPinnedGrpcConnectionId}
|
||||||
}
|
/>
|
||||||
|
</div>
|
||||||
function GrpcEventRow({
|
</HStack>
|
||||||
event,
|
<ErrorBoundary name="GRPC Events">
|
||||||
isActive,
|
<AutoScroller
|
||||||
onClick,
|
data={events}
|
||||||
}: {
|
header={
|
||||||
event: GrpcEvent;
|
activeConnection.error && (
|
||||||
isActive: boolean;
|
<Banner color="danger" className="m-3">
|
||||||
onClick: () => void;
|
{activeConnection.error}
|
||||||
}) {
|
</Banner>
|
||||||
const { eventType, status, content, error } = event;
|
)
|
||||||
const display = getEventDisplay(eventType, status);
|
}
|
||||||
|
render={(event) => (
|
||||||
return (
|
<EventRow
|
||||||
<EventViewerRow
|
key={event.id}
|
||||||
isActive={isActive}
|
event={event}
|
||||||
onClick={onClick}
|
isActive={event.id === activeEventId}
|
||||||
icon={<Icon color={display.color} title={display.title} icon={display.icon} />}
|
onClick={() => {
|
||||||
content={
|
if (event.id === activeEventId) setActiveEventId(null);
|
||||||
<span className="text-xs">
|
else setActiveEventId(event.id);
|
||||||
{content.slice(0, 1000)}
|
}}
|
||||||
{error && <span className="text-warning"> ({error})</span>}
|
/>
|
||||||
</span>
|
)}
|
||||||
|
/>
|
||||||
|
</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,
|
event,
|
||||||
showLarge,
|
|
||||||
showingLarge,
|
|
||||||
setShowLarge,
|
|
||||||
setShowingLarge,
|
|
||||||
}: {
|
}: {
|
||||||
|
onClick?: () => void;
|
||||||
|
isActive?: boolean;
|
||||||
event: GrpcEvent;
|
event: GrpcEvent;
|
||||||
showLarge: boolean;
|
|
||||||
showingLarge: boolean;
|
|
||||||
setShowLarge: (v: boolean) => void;
|
|
||||||
setShowingLarge: (v: boolean) => void;
|
|
||||||
}) {
|
}) {
|
||||||
if (event.eventType === 'client_message' || event.eventType === 'server_message') {
|
const { eventType, status, createdAt, content, error } = event;
|
||||||
const title = `Message ${event.eventType === 'client_message' ? 'Sent' : 'Received'}`;
|
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 (
|
return (
|
||||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
<div className="px-1" ref={ref}>
|
||||||
<EventDetailHeader title={event.content} timestamp={event.createdAt} />
|
<button
|
||||||
{event.error && (
|
type="button"
|
||||||
<div className="select-text cursor-text text-sm font-mono py-1 text-warning">
|
onClick={onClick}
|
||||||
{event.error}
|
className={classNames(
|
||||||
</div>
|
'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',
|
||||||
<div className="py-2 h-full">
|
isActive && '!bg-surface-active !text-text',
|
||||||
{Object.keys(event.metadata).length === 0 ? (
|
'text-text-subtle hover:text',
|
||||||
<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>
|
>
|
||||||
|
<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>
|
</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 { useResponseBodyBytes, useResponseBodyText } from '../hooks/useResponseBodyText';
|
||||||
import { useResponseViewMode } from '../hooks/useResponseViewMode';
|
import { useResponseViewMode } from '../hooks/useResponseViewMode';
|
||||||
import { getMimeTypeFromContentType } from '../lib/contentType';
|
import { getMimeTypeFromContentType } from '../lib/contentType';
|
||||||
import { getCookieCounts, getContentTypeFromHeaders } from '../lib/model_util';
|
import { getContentTypeFromHeaders } from '../lib/model_util';
|
||||||
import { ConfirmLargeResponse } from './ConfirmLargeResponse';
|
import { ConfirmLargeResponse } from './ConfirmLargeResponse';
|
||||||
import { ConfirmLargeResponseRequest } from './ConfirmLargeResponseRequest';
|
import { ConfirmLargeResponseRequest } from './ConfirmLargeResponseRequest';
|
||||||
import { Banner } from './core/Banner';
|
import { Banner } from './core/Banner';
|
||||||
@@ -67,10 +67,20 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
|
|
||||||
const responseEvents = useHttpResponseEvents(activeResponse);
|
const responseEvents = useHttpResponseEvents(activeResponse);
|
||||||
|
|
||||||
const cookieCounts = useMemo(
|
const cookieCount = useMemo(() => {
|
||||||
() => getCookieCounts(responseEvents.data),
|
if (!responseEvents.data) return 0;
|
||||||
[responseEvents.data],
|
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[]>(
|
const tabs = useMemo<TabItem[]>(
|
||||||
() => [
|
() => [
|
||||||
@@ -97,19 +107,15 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
label: 'Headers',
|
label: 'Headers',
|
||||||
rightSlot: (
|
rightSlot: (
|
||||||
<CountBadge
|
<CountBadge
|
||||||
count={activeResponse?.requestHeaders.length ?? 0}
|
|
||||||
count2={activeResponse?.headers.length ?? 0}
|
count2={activeResponse?.headers.length ?? 0}
|
||||||
showZero
|
count={activeResponse?.requestHeaders.length ?? 0}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: TAB_COOKIES,
|
value: TAB_COOKIES,
|
||||||
label: 'Cookies',
|
label: 'Cookies',
|
||||||
rightSlot:
|
rightSlot: cookieCount > 0 ? <CountBadge count={cookieCount} /> : null,
|
||||||
cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
|
|
||||||
<CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />
|
|
||||||
) : null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: TAB_TIMELINE,
|
value: TAB_TIMELINE,
|
||||||
@@ -121,8 +127,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
activeResponse?.headers,
|
activeResponse?.headers,
|
||||||
activeResponse?.requestContentLength,
|
activeResponse?.requestContentLength,
|
||||||
activeResponse?.requestHeaders.length,
|
activeResponse?.requestHeaders.length,
|
||||||
cookieCounts.sent,
|
cookieCount,
|
||||||
cookieCounts.received,
|
|
||||||
mimeType,
|
mimeType,
|
||||||
responseEvents.data?.length,
|
responseEvents.data?.length,
|
||||||
setViewMode,
|
setViewMode,
|
||||||
|
|||||||
@@ -3,15 +3,18 @@ import type {
|
|||||||
HttpResponseEvent,
|
HttpResponseEvent,
|
||||||
HttpResponseEventData,
|
HttpResponseEventData,
|
||||||
} from '@yaakapp-internal/models';
|
} 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 { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
|
||||||
import { Editor } from './core/Editor/LazyEditor';
|
import { AutoScroller } from './core/AutoScroller';
|
||||||
import { EventDetailHeader, EventViewer, type EventDetailAction } from './core/EventViewer';
|
import { Banner } from './core/Banner';
|
||||||
import { EventViewerRow } from './core/EventViewerRow';
|
|
||||||
import { HttpMethodTagRaw } from './core/HttpMethodTag';
|
import { HttpMethodTagRaw } from './core/HttpMethodTag';
|
||||||
import { HttpStatusTagRaw } from './core/HttpStatusTag';
|
import { HttpStatusTagRaw } from './core/HttpStatusTag';
|
||||||
import { Icon, type IconProps } from './core/Icon';
|
import { Icon, type IconProps } from './core/Icon';
|
||||||
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
|
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
|
||||||
|
import { Separator } from './core/Separator';
|
||||||
|
import { SplitLayout } from './core/SplitLayout';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
response: HttpResponse;
|
response: HttpResponse;
|
||||||
@@ -22,88 +25,121 @@ export function HttpResponseTimeline({ response }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Inner({ 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 { 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 (
|
return (
|
||||||
<EventViewer
|
<SplitLayout
|
||||||
events={events ?? []}
|
layout="vertical"
|
||||||
getEventKey={(event) => event.id}
|
name="http_response_events"
|
||||||
error={error ? String(error) : null}
|
|
||||||
isLoading={isLoading}
|
|
||||||
loadingMessage="Loading events..."
|
|
||||||
emptyMessage="No events recorded"
|
|
||||||
splitLayoutName="http_response_events"
|
|
||||||
defaultRatio={0.25}
|
defaultRatio={0.25}
|
||||||
renderRow={({ event, isActive, onClick }) => {
|
minHeightPx={10}
|
||||||
const display = getEventDisplay(event.event);
|
firstSlot={() => (
|
||||||
return (
|
<AutoScroller
|
||||||
<EventViewerRow
|
data={events}
|
||||||
isActive={isActive}
|
render={(event, i) => (
|
||||||
onClick={onClick}
|
<EventRow
|
||||||
icon={<Icon color={display.color} icon={display.icon} size="sm" />}
|
key={event.id}
|
||||||
content={display.summary}
|
event={event}
|
||||||
timestamp={event.createdAt}
|
isActive={i === activeEventIndex}
|
||||||
/>
|
onClick={() => {
|
||||||
);
|
if (i === activeEventIndex) setActiveEventIndex(null);
|
||||||
}}
|
else setActiveEventIndex(i);
|
||||||
renderDetail={({ event }) => (
|
}}
|
||||||
<EventDetails event={event} showRaw={showRaw} setShowRaw={setShowRaw} />
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
secondSlot={
|
||||||
|
activeEvent
|
||||||
|
? () => (
|
||||||
|
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
|
||||||
|
<div className="pb-3 px-2">
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
<div className="mx-2 overflow-y-auto">
|
||||||
|
<EventDetails event={activeEvent} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EventRow({
|
||||||
|
onClick,
|
||||||
|
isActive,
|
||||||
|
event,
|
||||||
|
}: {
|
||||||
|
onClick: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
event: HttpResponseEvent;
|
||||||
|
}) {
|
||||||
|
const display = getEventDisplay(event.event);
|
||||||
|
const { icon, color, summary } = display;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={classNames(
|
||||||
|
'w-full grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2 items-center text-left',
|
||||||
|
'px-1.5 h-xs font-mono text-editor cursor-default group focus:outline-none focus:text-text rounded',
|
||||||
|
isActive && '!bg-surface-active !text-text',
|
||||||
|
'text-text-subtle hover:text',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon color={color} icon={icon} size="sm" />
|
||||||
|
<div className="w-full truncate">{summary}</div>
|
||||||
|
<div className="opacity-50">{format(`${event.createdAt}Z`, 'HH:mm:ss.SSS')}</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventDetails({
|
function EventDetails({ event }: { event: HttpResponseEvent }) {
|
||||||
event,
|
|
||||||
showRaw,
|
|
||||||
setShowRaw,
|
|
||||||
}: {
|
|
||||||
event: HttpResponseEvent;
|
|
||||||
showRaw: boolean;
|
|
||||||
setShowRaw: (v: boolean) => void;
|
|
||||||
}) {
|
|
||||||
const { label } = getEventDisplay(event.event);
|
const { label } = getEventDisplay(event.event);
|
||||||
|
const timestamp = format(new Date(`${event.createdAt}Z`), 'HH:mm:ss.SSS');
|
||||||
const e = event.event;
|
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
|
// Headers - show name and value with Editor for JSON
|
||||||
if (e.type === 'header_up' || e.type === 'header_down') {
|
if (e.type === 'header_up' || e.type === 'header_down') {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 h-full">
|
<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>
|
<KeyValueRows>
|
||||||
<KeyValueRow label="Header">{e.name}</KeyValueRow>
|
<KeyValueRow label="Header">{e.name}</KeyValueRow>
|
||||||
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
||||||
@@ -116,7 +152,7 @@ function EventDetails({
|
|||||||
if (e.type === 'send_url') {
|
if (e.type === 'send_url') {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<EventDetailHeader title="Request" timestamp={event.createdAt} actions={actions} />
|
<DetailHeader title="Request" timestamp={timestamp} />
|
||||||
<KeyValueRows>
|
<KeyValueRows>
|
||||||
<KeyValueRow label="Method">
|
<KeyValueRow label="Method">
|
||||||
<HttpMethodTagRaw forceColor method={e.method} />
|
<HttpMethodTagRaw forceColor method={e.method} />
|
||||||
@@ -131,7 +167,7 @@ function EventDetails({
|
|||||||
if (e.type === 'receive_url') {
|
if (e.type === 'receive_url') {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<EventDetailHeader title="Response" timestamp={event.createdAt} actions={actions} />
|
<DetailHeader title="Response" timestamp={timestamp} />
|
||||||
<KeyValueRows>
|
<KeyValueRows>
|
||||||
<KeyValueRow label="HTTP Version">{e.version}</KeyValueRow>
|
<KeyValueRow label="HTTP Version">{e.version}</KeyValueRow>
|
||||||
<KeyValueRow label="Status">
|
<KeyValueRow label="Status">
|
||||||
@@ -146,7 +182,7 @@ function EventDetails({
|
|||||||
if (e.type === 'redirect') {
|
if (e.type === 'redirect') {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<EventDetailHeader title="Redirect" timestamp={event.createdAt} actions={actions} />
|
<DetailHeader title="Redirect" timestamp={timestamp} />
|
||||||
<KeyValueRows>
|
<KeyValueRows>
|
||||||
<KeyValueRow label="Status">
|
<KeyValueRow label="Status">
|
||||||
<HttpStatusTagRaw status={e.status} />
|
<HttpStatusTagRaw status={e.status} />
|
||||||
@@ -164,7 +200,7 @@ function EventDetails({
|
|||||||
if (e.type === 'setting') {
|
if (e.type === 'setting') {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<EventDetailHeader title="Apply Setting" timestamp={event.createdAt} actions={actions} />
|
<DetailHeader title="Apply Setting" timestamp={timestamp} />
|
||||||
<KeyValueRows>
|
<KeyValueRows>
|
||||||
<KeyValueRow label="Setting">{e.name}</KeyValueRow>
|
<KeyValueRow label="Setting">{e.name}</KeyValueRow>
|
||||||
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
||||||
@@ -178,11 +214,7 @@ function EventDetails({
|
|||||||
const direction = e.type === 'chunk_sent' ? 'Sent' : 'Received';
|
const direction = e.type === 'chunk_sent' ? 'Sent' : 'Received';
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<EventDetailHeader
|
<DetailHeader title={`Data ${direction}`} timestamp={timestamp} />
|
||||||
title={`Data ${direction}`}
|
|
||||||
timestamp={event.createdAt}
|
|
||||||
actions={actions}
|
|
||||||
/>
|
|
||||||
<div className="font-mono text-editor">{formatBytes(e.bytes)}</div>
|
<div className="font-mono text-editor">{formatBytes(e.bytes)}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -192,36 +224,19 @@ function EventDetails({
|
|||||||
const { summary } = getEventDisplay(event.event);
|
const { summary } = getEventDisplay(event.event);
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1">
|
<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 className="font-mono text-editor">{summary}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format event as raw plaintext for debugging */
|
function DetailHeader({ title, timestamp }: { title: string; timestamp: string }) {
|
||||||
function formatEventRaw(event: HttpResponseEventData): string {
|
return (
|
||||||
switch (event.type) {
|
<div className="flex items-center justify-between gap-2">
|
||||||
case 'send_url':
|
<h3 className="font-semibold select-auto cursor-auto">{title}</h3>
|
||||||
return `${event.method} ${event.path}`;
|
<span className="text-text-subtlest font-mono text-editor">{timestamp}</span>
|
||||||
case 'receive_url':
|
</div>
|
||||||
return `${event.version} ${event.status}`;
|
);
|
||||||
case 'header_up':
|
|
||||||
return `${event.name}: ${event.value}`;
|
|
||||||
case 'header_down':
|
|
||||||
return `${event.name}: ${event.value}`;
|
|
||||||
case 'redirect':
|
|
||||||
return `${event.status} Redirect: ${event.url}`;
|
|
||||||
case 'setting':
|
|
||||||
return `${event.name} = ${event.value}`;
|
|
||||||
case 'info':
|
|
||||||
return `${event.message}`;
|
|
||||||
case 'chunk_sent':
|
|
||||||
return `[${formatBytes(event.bytes)} sent]`;
|
|
||||||
case 'chunk_received':
|
|
||||||
return `[${formatBytes(event.bytes)} received]`;
|
|
||||||
default:
|
|
||||||
return '[unknown event]';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventDisplay = {
|
type EventDisplay = {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { WebsocketEvent, WebsocketRequest } from '@yaakapp-internal/models';
|
import type { WebsocketEvent, WebsocketRequest } from '@yaakapp-internal/models';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { format } from 'date-fns';
|
||||||
import { hexy } from 'hexy';
|
import { hexy } from 'hexy';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useRef, useState } from 'react';
|
||||||
import { useFormatText } from '../hooks/useFormatText';
|
import { useFormatText } from '../hooks/useFormatText';
|
||||||
import {
|
import {
|
||||||
activeWebsocketConnectionAtom,
|
activeWebsocketConnectionAtom,
|
||||||
@@ -11,13 +13,17 @@ import {
|
|||||||
} from '../hooks/usePinnedWebsocketConnection';
|
} from '../hooks/usePinnedWebsocketConnection';
|
||||||
import { useStateWithDeps } from '../hooks/useStateWithDeps';
|
import { useStateWithDeps } from '../hooks/useStateWithDeps';
|
||||||
import { languageFromContentType } from '../lib/contentType';
|
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 { Button } from './core/Button';
|
||||||
import { Editor } from './core/Editor/LazyEditor';
|
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 { HotkeyList } from './core/HotkeyList';
|
||||||
import { Icon } from './core/Icon';
|
import { Icon } from './core/Icon';
|
||||||
|
import { IconButton } from './core/IconButton';
|
||||||
import { LoadingIcon } from './core/LoadingIcon';
|
import { LoadingIcon } from './core/LoadingIcon';
|
||||||
|
import { Separator } from './core/Separator';
|
||||||
|
import { SplitLayout } from './core/SplitLayout';
|
||||||
import { HStack, VStack } from './core/Stacks';
|
import { HStack, VStack } from './core/Stacks';
|
||||||
import { WebsocketStatusTag } from './core/WebsocketStatusTag';
|
import { WebsocketStatusTag } from './core/WebsocketStatusTag';
|
||||||
import { EmptyStateText } from './EmptyStateText';
|
import { EmptyStateText } from './EmptyStateText';
|
||||||
@@ -29,199 +35,227 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WebsocketResponsePane({ activeRequest }: Props) {
|
export function WebsocketResponsePane({ activeRequest }: Props) {
|
||||||
|
const [activeEventId, setActiveEventId] = useState<string | null>(null);
|
||||||
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
|
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
|
||||||
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
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 activeConnection = useAtomValue(activeWebsocketConnectionAtom);
|
||||||
const connections = useAtomValue(activeWebsocketConnectionsAtom);
|
const connections = useAtomValue(activeWebsocketConnectionsAtom);
|
||||||
const events = useWebsocketEvents(activeConnection?.id ?? null);
|
const events = useWebsocketEvents(activeConnection?.id ?? null);
|
||||||
|
|
||||||
if (activeConnection == null) {
|
const activeEvent = useMemo(
|
||||||
return (
|
() => events.find((m) => m.id === activeEventId) ?? null,
|
||||||
<HotkeyList hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']} />
|
[activeEventId, events],
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const hexDump = hexDumps[activeEventId ?? 'n/a'] ?? activeEvent?.messageType === 'binary';
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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(() => {
|
const message = useMemo(() => {
|
||||||
if (hexDump) {
|
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)) : '';
|
return activeEvent?.message
|
||||||
}, [event.message, hexDump]);
|
? new TextDecoder('utf-8').decode(Uint8Array.from(activeEvent.message))
|
||||||
|
: '';
|
||||||
|
}, [activeEvent?.message, hexDump]);
|
||||||
|
|
||||||
const language = languageFromContentType(null, message);
|
const language = languageFromContentType(null, message);
|
||||||
const formattedMessage = useFormatText({ language, text: message, pretty: true });
|
const formattedMessage = useFormatText({ language, text: message, pretty: true });
|
||||||
|
|
||||||
const title =
|
return (
|
||||||
event.messageType === 'close'
|
<SplitLayout
|
||||||
? 'Connection Closed'
|
layout="vertical"
|
||||||
: event.messageType === 'open'
|
name="grpc_events"
|
||||||
? 'Connection Open'
|
defaultRatio={0.4}
|
||||||
: `Message ${event.isServer ? 'Received' : 'Sent'}`;
|
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[] =
|
function EventRow({
|
||||||
message !== ''
|
onClick,
|
||||||
? [
|
isActive,
|
||||||
{
|
event,
|
||||||
key: 'toggle-hexdump',
|
}: {
|
||||||
label: hexDump ? 'Show Message' : 'Show Hexdump',
|
onClick?: () => void;
|
||||||
onClick: () => setHexDump(!hexDump),
|
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 (
|
return (
|
||||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
<div className="px-1" ref={ref}>
|
||||||
<EventDetailHeader
|
<button
|
||||||
title={title}
|
type="button"
|
||||||
timestamp={event.createdAt}
|
onClick={onClick}
|
||||||
actions={actions}
|
className={classNames(
|
||||||
copyText={formattedMessage || undefined}
|
'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 ? (
|
<div className={classNames('w-full truncate text-xs')}>
|
||||||
<VStack space={2} className="italic text-text-subtlest">
|
{messageType === 'close' ? (
|
||||||
Message previews larger than 1MB are hidden
|
'Disconnected from server'
|
||||||
<div>
|
) : messageType === 'open' ? (
|
||||||
<Button
|
'Connected to server'
|
||||||
onClick={() => {
|
) : message === '' ? (
|
||||||
setShowingLarge(true);
|
<em className="italic text-text-subtlest">No content</em>
|
||||||
setTimeout(() => {
|
) : (
|
||||||
setShowLarge(true);
|
message.slice(0, 1000)
|
||||||
setShowingLarge(false);
|
)}
|
||||||
}, 500);
|
{/*{error && <span className="text-warning"> ({error})</span>}*/}
|
||||||
}}
|
</div>
|
||||||
isLoading={showingLarge}
|
<div className={classNames('opacity-50 text-xs')}>
|
||||||
color="secondary"
|
{format(`${createdAt}Z`, 'HH:mm:ss.SSS')}
|
||||||
variant="border"
|
</div>
|
||||||
size="xs"
|
</button>
|
||||||
>
|
|
||||||
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>
|
</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 type { ReactElement, ReactNode, UIEvent } from 'react';
|
||||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { IconButton } from './IconButton';
|
import { IconButton } from './IconButton';
|
||||||
@@ -7,19 +7,9 @@ interface Props<T> {
|
|||||||
data: T[];
|
data: T[];
|
||||||
render: (item: T, index: number) => ReactElement<HTMLElement>;
|
render: (item: T, index: number) => ReactElement<HTMLElement>;
|
||||||
header?: ReactNode;
|
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>({
|
export function AutoScroller<T>({ data, render, header }: Props<T>) {
|
||||||
data,
|
|
||||||
render,
|
|
||||||
header,
|
|
||||||
focusable = false,
|
|
||||||
onVirtualizerReady,
|
|
||||||
}: Props<T>) {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [autoScroll, setAutoScroll] = useState<boolean>(true);
|
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
|
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
|
// Scroll to new items
|
||||||
const handleScroll = useCallback(
|
const handleScroll = useCallback(
|
||||||
(e: UIEvent<HTMLDivElement>) => {
|
(e: UIEvent<HTMLDivElement>) => {
|
||||||
@@ -63,7 +48,7 @@ export function AutoScroller<T>({
|
|||||||
}, [autoScroll, data.length]);
|
}, [autoScroll, data.length]);
|
||||||
|
|
||||||
return (
|
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 && (
|
{!autoScroll && (
|
||||||
<div className="absolute bottom-0 right-0 m-2">
|
<div className="absolute bottom-0 right-0 m-2">
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -78,12 +63,7 @@ export function AutoScroller<T>({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{header ?? <span aria-hidden />}
|
{header ?? <span aria-hidden />}
|
||||||
<div
|
<div ref={containerRef} className="h-full w-full overflow-y-auto" onScroll={handleScroll}>
|
||||||
ref={containerRef}
|
|
||||||
className="h-full w-full overflow-y-auto"
|
|
||||||
onScroll={handleScroll}
|
|
||||||
tabIndex={focusable ? 0 : undefined}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||||
|
|||||||
@@ -766,24 +766,8 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
|||||||
</m.div>
|
</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) {
|
if (!isOpen) {
|
||||||
return <>{hotKeyElements}</>;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSubmenu) {
|
if (isSubmenu) {
|
||||||
@@ -792,7 +776,20 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
|||||||
|
|
||||||
return (
|
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">
|
<Overlay noBackdrop open={isOpen} portalName="dropdown-menu">
|
||||||
{menuContent}
|
{menuContent}
|
||||||
</Overlay>
|
</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 { useFormatText } from '../../hooks/useFormatText';
|
||||||
import { useResponseBodyEventSource } from '../../hooks/useResponseBodyEventSource';
|
import { useResponseBodyEventSource } from '../../hooks/useResponseBodyEventSource';
|
||||||
import { isJSON } from '../../lib/contentType';
|
import { isJSON } from '../../lib/contentType';
|
||||||
|
import { AutoScroller } from '../core/AutoScroller';
|
||||||
|
import { Banner } from '../core/Banner';
|
||||||
import { Button } from '../core/Button';
|
import { Button } from '../core/Button';
|
||||||
import type { EditorProps } from '../core/Editor/Editor';
|
import type { EditorProps } from '../core/Editor/Editor';
|
||||||
import { Editor } from '../core/Editor/LazyEditor';
|
import { Editor } from '../core/Editor/LazyEditor';
|
||||||
import { EventDetailHeader, EventViewer } from '../core/EventViewer';
|
|
||||||
import { EventViewerRow } from '../core/EventViewerRow';
|
|
||||||
import { Icon } from '../core/Icon';
|
import { Icon } from '../core/Icon';
|
||||||
import { InlineCode } from '../core/InlineCode';
|
import { InlineCode } from '../core/InlineCode';
|
||||||
|
import { Separator } from '../core/Separator';
|
||||||
|
import { SplitLayout } from '../core/SplitLayout';
|
||||||
import { HStack, VStack } from '../core/Stacks';
|
import { HStack, VStack } from '../core/Stacks';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -31,88 +33,93 @@ export function EventStreamViewer({ response }: Props) {
|
|||||||
function ActualEventStreamViewer({ response }: Props) {
|
function ActualEventStreamViewer({ response }: Props) {
|
||||||
const [showLarge, setShowLarge] = useState<boolean>(false);
|
const [showLarge, setShowLarge] = useState<boolean>(false);
|
||||||
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
||||||
|
const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null);
|
||||||
const events = useResponseBodyEventSource(response);
|
const events = useResponseBodyEventSource(response);
|
||||||
|
const activeEvent = useMemo(
|
||||||
return (
|
() => (activeEventIndex == null ? null : events.data?.[activeEventIndex]),
|
||||||
<EventViewer
|
[activeEventIndex, events],
|
||||||
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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
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'>(() => {
|
const language = useMemo<'text' | 'json'>(() => {
|
||||||
if (!event?.data) return 'text';
|
if (!activeEvent?.data) return 'text';
|
||||||
return isJSON(event?.data) ? 'json' : 'text';
|
return isJSON(activeEvent?.data) ? 'json' : 'text';
|
||||||
}, [event?.data]);
|
}, [activeEvent?.data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<SplitLayout
|
||||||
<EventDetailHeader title="Message Received" />
|
layout="vertical"
|
||||||
{!showLarge && event.data.length > 1000 * 1000 ? (
|
name="grpc_events"
|
||||||
<VStack space={2} className="italic text-text-subtlest">
|
defaultRatio={0.4}
|
||||||
Message previews larger than 1MB are hidden
|
minHeightPx={20}
|
||||||
<div>
|
firstSlot={() => (
|
||||||
<Button
|
<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={() => {
|
onClick={() => {
|
||||||
setShowingLarge(true);
|
if (i === activeEventIndex) setActiveEventIndex(null);
|
||||||
setTimeout(() => {
|
else setActiveEventIndex(i);
|
||||||
setShowLarge(true);
|
|
||||||
setShowingLarge(false);
|
|
||||||
}, 500);
|
|
||||||
}}
|
}}
|
||||||
isLoading={showingLarge}
|
/>
|
||||||
color="secondary"
|
)}
|
||||||
variant="border"
|
/>
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
Try Showing
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</VStack>
|
|
||||||
) : (
|
|
||||||
<FormattedEditor language={language} text={event.data} />
|
|
||||||
)}
|
)}
|
||||||
</div>
|
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} />;
|
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({
|
function EventLabels({
|
||||||
className,
|
className,
|
||||||
event,
|
event,
|
||||||
@@ -130,7 +169,7 @@ function EventLabels({
|
|||||||
}: {
|
}: {
|
||||||
event: ServerSentEvent;
|
event: ServerSentEvent;
|
||||||
index: number;
|
index: number;
|
||||||
className?: string;
|
className: string;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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 { invoke } from '@tauri-apps/api/core';
|
||||||
import type { HttpResponse, HttpResponseEvent } from '@yaakapp-internal/models';
|
import type { HttpResponse, HttpResponseEvent } from '@yaakapp-internal/models';
|
||||||
import {
|
import { httpResponseEventsAtom, replaceModelsInStore } from '@yaakapp-internal/models';
|
||||||
httpResponseEventsAtom,
|
|
||||||
mergeModelsInStore,
|
|
||||||
replaceModelsInStore,
|
|
||||||
} from '@yaakapp-internal/models';
|
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
@@ -17,10 +13,8 @@ export function useHttpResponseEvents(response: HttpResponse | null) {
|
|||||||
return;
|
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(
|
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]);
|
}, [response?.id]);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { GrpcConnection, GrpcEvent } from '@yaakapp-internal/models';
|
|||||||
import {
|
import {
|
||||||
grpcConnectionsAtom,
|
grpcConnectionsAtom,
|
||||||
grpcEventsAtom,
|
grpcEventsAtom,
|
||||||
mergeModelsInStore,
|
|
||||||
replaceModelsInStore,
|
replaceModelsInStore,
|
||||||
} from '@yaakapp-internal/models';
|
} from '@yaakapp-internal/models';
|
||||||
import { atom, useAtomValue } from 'jotai';
|
import { atom, useAtomValue } from 'jotai';
|
||||||
@@ -68,10 +67,8 @@ export function useGrpcEvents(connectionId: string | null) {
|
|||||||
return;
|
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) => {
|
invoke<GrpcEvent[]>('models_grpc_events', { connectionId }).then((events) => {
|
||||||
mergeModelsInStore('grpc_event', events);
|
replaceModelsInStore('grpc_event', events);
|
||||||
});
|
});
|
||||||
}, [connectionId]);
|
}, [connectionId]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import type { WebsocketConnection, WebsocketEvent } from '@yaakapp-internal/models';
|
import type { WebsocketConnection, WebsocketEvent } from '@yaakapp-internal/models';
|
||||||
import {
|
import {
|
||||||
mergeModelsInStore,
|
|
||||||
replaceModelsInStore,
|
replaceModelsInStore,
|
||||||
websocketConnectionsAtom,
|
websocketConnectionsAtom,
|
||||||
websocketEventsAtom,
|
websocketEventsAtom,
|
||||||
@@ -55,10 +54,8 @@ export function useWebsocketEvents(connectionId: string | null) {
|
|||||||
return;
|
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(
|
invoke<WebsocketEvent[]>('models_websocket_events', { connectionId }).then(
|
||||||
(events) => mergeModelsInStore('websocket_event', events),
|
(events) => replaceModelsInStore('websocket_event', events),
|
||||||
);
|
);
|
||||||
}, [connectionId]);
|
}, [connectionId]);
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { stringToColor } from './color';
|
|||||||
import { generateId } from './generateId';
|
import { generateId } from './generateId';
|
||||||
import { jotaiStore } from './jotai';
|
import { jotaiStore } from './jotai';
|
||||||
import { showPrompt } from './prompt';
|
import { showPrompt } from './prompt';
|
||||||
import { showPromptForm } from './prompt-form';
|
|
||||||
import { invokeCmd } from './tauri';
|
import { invokeCmd } from './tauri';
|
||||||
import { showToast } from './toast';
|
import { showToast } from './toast';
|
||||||
|
|
||||||
@@ -48,27 +47,6 @@ export function initGlobalListeners() {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
await emit(event.id, result);
|
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 {
|
import type { AnyModel, Cookie, Environment, HttpResponseHeader } from '@yaakapp-internal/models';
|
||||||
AnyModel,
|
|
||||||
Cookie,
|
|
||||||
Environment,
|
|
||||||
HttpResponseEvent,
|
|
||||||
HttpResponseHeader,
|
|
||||||
} from '@yaakapp-internal/models';
|
|
||||||
import { getMimeTypeFromContentType } from './contentType';
|
import { getMimeTypeFromContentType } from './contentType';
|
||||||
|
|
||||||
export const BODY_TYPE_NONE = null;
|
export const BODY_TYPE_NONE = null;
|
||||||
@@ -65,30 +59,3 @@ export function isSubEnvironment(environment: Environment): boolean {
|
|||||||
export function isFolderEnvironment(environment: Environment): boolean {
|
export function isFolderEnvironment(environment: Environment): boolean {
|
||||||
return environment.parentModel === 'folder';
|
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