Compare commits

..

2 Commits

Author SHA1 Message Date
Gregory Schier
0a52032988 Merge branch 'main' into omnara/premium-deviator 2026-01-09 20:23:20 -08:00
Gregory Schier
4b7497a908 feat: implement layered settings system for HTTP requests and folders
Add support for settings overrides at folder and HTTP request levels. Introduces nullable settings columns to database tables and implements resolution logic to merge workspace, folder, and request-level settings with proper precedence.
2026-01-09 20:22:53 -08:00
53 changed files with 1049 additions and 1488 deletions

View File

@@ -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
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()

View File

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

View File

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

View File

@@ -6,4 +6,3 @@ publish = false
[dependencies] [dependencies]
serde_json = { workspace = true } serde_json = { workspace = true }
tokio = { workspace = true, features = ["process"] }

View File

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

View File

@@ -1,3 +1,2 @@
pub mod command;
pub mod platform; pub mod platform;
pub mod serde; pub mod serde;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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());

View File

@@ -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!(

View File

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

View File

@@ -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') {

View File

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

View File

@@ -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")?,
}) })
} }
} }

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

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

View File

@@ -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 = {

View File

@@ -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>&bull;</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>&bull;</span>
<span>{events.length} Messages</span>
</HStack>
<HStack space={0.5} className="ml-auto">
<RecentWebsocketConnectionsDropdown
connections={connections}
activeConnection={activeConnection}
onPinnedConnectionId={setPinnedWebsocketConnectionId}
/>
</HStack>
</HStack>
<ErrorBoundary name="Websocket Events">
<AutoScroller
data={events}
header={
activeConnection.error && (
<Banner color="danger" className="m-3">
{activeConnection.error}
</Banner>
)
}
render={(event) => (
<EventRow
key={event.id}
event={event}
isActive={event.id === activeEventId}
onClick={() => {
if (event.id === activeEventId) setActiveEventId(null);
else setActiveEventId(event.id);
}}
/>
)}
/>
</ErrorBoundary>
</div>
)
}
secondSlot={
activeEvent != null && activeConnection != null
? () => (
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
<div className="pb-3 px-2">
<Separator />
</div>
<div className="mx-2 overflow-y-auto grid grid-rows-[auto_minmax(0,1fr)]">
<div className="h-xs mb-2 grid grid-cols-[minmax(0,1fr)_auto] items-center">
<div className="font-semibold">
{activeEvent.messageType === 'close'
? 'Connection Closed'
: activeEvent.messageType === 'open'
? 'Connection open'
: `Message ${activeEvent.isServer ? 'Received' : 'Sent'}`}
</div>
{message !== '' && (
<HStack space={1}>
<Button
variant="border"
size="xs"
onClick={() => {
if (activeEventId == null) return;
setHexDumps({ ...hexDumps, [activeEventId]: !hexDump });
}}
>
{hexDump ? 'Show Message' : 'Show Hexdump'}
</Button>
<IconButton
title="Copy message"
icon="copy"
size="xs"
onClick={() => copyToClipboard(formattedMessage ?? '')}
/>
</HStack>
)}
</div>
{!showLarge && activeEvent.message.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden
<div>
<Button
onClick={() => {
setShowingLarge(true);
setTimeout(() => {
setShowLarge(true);
setShowingLarge(false);
}, 500);
}}
isLoading={showingLarge}
color="secondary"
variant="border"
size="xs"
>
Try Showing
</Button>
</div>
</VStack>
) : activeEvent.message.length === 0 ? (
<EmptyStateText>No Content</EmptyStateText>
) : (
<Editor
language={language}
defaultValue={formattedMessage ?? ''}
wrapLines={false}
readOnly={true}
stateKey={null}
/>
)}
</div>
</div>
)
: null
}
/>
);
}
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>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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