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
35 changed files with 972 additions and 1367 deletions

View File

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

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())?;
let request = render_http_request(&resolved, env_chain, &cb, &RenderOptions::throw()).await?;
// Resolve inherited settings for this request
let resolved_settings = window.db().resolve_settings_for_http_request(&resolved)?;
// Build the sendable request using the new SendableHttpRequest type
let options = SendableHttpRequestOptions {
follow_redirects: workspace.setting_follow_redirects,
timeout: if workspace.setting_request_timeout > 0 {
Some(Duration::from_millis(workspace.setting_request_timeout.unsigned_abs() as u64))
follow_redirects: resolved_settings.follow_redirects,
timeout: if resolved_settings.request_timeout > 0 {
Some(Duration::from_millis(resolved_settings.request_timeout.unsigned_abs() as u64))
} else {
None
},
@@ -231,7 +234,7 @@ async fn send_http_request_inner<R: Runtime>(
let client = connection_manager
.get_client(&HttpConnectionOptions {
id: plugin_context.id.clone(),
validate_certificates: workspace.setting_validate_certificates,
validate_certificates: resolved_settings.validate_certificates,
proxy: proxy_setting,
client_certificate,
})

View File

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

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)?;
Ok(call_frontend(&window, event).await)
}
InternalEventPayload::PromptFormRequest(_) => {
let window = get_window_from_plugin_context(app_handle, &plugin_context)?;
Ok(call_frontend(&window, event).await)
}
InternalEventPayload::FindHttpResponsesRequest(req) => {
let http_responses = app_handle
.db()

View File

@@ -355,7 +355,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
url.as_str(),
headers,
receive_tx,
workspace.setting_validate_certificates,
workspace.setting_validate_certificates.unwrap_or(true),
client_cert,
)
.await

View File

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

View File

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

View File

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

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) {
// Never ignore updates from non-user sources
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::models::HttpRequestIden::{
Authentication, AuthenticationType, Body, BodyType, CreatedAt, Description, FolderId, Headers,
Method, Name, SortPriority, UpdatedAt, Url, UrlParameters, WorkspaceId,
};
use crate::util::{UpdateSource, generate_prefixed_id};
use chrono::{NaiveDateTime, Utc};
use rusqlite::Row;
@@ -115,6 +111,36 @@ impl Default for EditorKeymap {
}
}
/// Settings that can be inherited at workspace → folder → request level.
/// All fields optional - None means "inherit from parent" (or use default if at root).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct HttpRequestSettingsOverride {
pub setting_validate_certificates: Option<bool>,
pub setting_follow_redirects: Option<bool>,
pub setting_request_timeout: Option<i32>,
}
/// Resolved settings with concrete values (after inheritance + defaults applied)
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedHttpRequestSettings {
pub validate_certificates: bool,
pub follow_redirects: bool,
pub request_timeout: i32,
}
impl ResolvedHttpRequestSettings {
/// Default values when nothing is set in the inheritance chain
pub fn defaults() -> Self {
Self {
validate_certificates: true,
follow_redirects: true,
request_timeout: 0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
@@ -297,12 +323,10 @@ pub struct Workspace {
pub name: String,
pub encryption_key_challenge: Option<String>,
// Settings
#[serde(default = "default_true")]
pub setting_validate_certificates: bool,
#[serde(default = "default_true")]
pub setting_follow_redirects: bool,
pub setting_request_timeout: i32,
// Inheritable settings (Option = can be null, defaults applied at resolution time)
pub setting_validate_certificates: Option<bool>,
pub setting_follow_redirects: Option<bool>,
pub setting_request_timeout: Option<i32>,
}
impl UpsertModelInfo for Workspace {
@@ -726,6 +750,11 @@ pub struct Folder {
pub headers: Vec<HttpRequestHeader>,
pub name: String,
pub sort_priority: f64,
// Inheritable settings (Option = null means inherit from parent)
pub setting_validate_certificates: Option<bool>,
pub setting_follow_redirects: Option<bool>,
pub setting_request_timeout: Option<i32>,
}
impl UpsertModelInfo for Folder {
@@ -765,6 +794,9 @@ impl UpsertModelInfo for Folder {
(Description, self.description.into()),
(Name, self.name.trim().into()),
(SortPriority, self.sort_priority.into()),
(SettingValidateCertificates, self.setting_validate_certificates.into()),
(SettingFollowRedirects, self.setting_follow_redirects.into()),
(SettingRequestTimeout, self.setting_request_timeout.into()),
])
}
@@ -778,6 +810,9 @@ impl UpsertModelInfo for Folder {
FolderIden::Description,
FolderIden::FolderId,
FolderIden::SortPriority,
FolderIden::SettingValidateCertificates,
FolderIden::SettingFollowRedirects,
FolderIden::SettingRequestTimeout,
]
}
@@ -800,6 +835,9 @@ impl UpsertModelInfo for Folder {
headers: serde_json::from_str(&headers).unwrap_or_default(),
authentication_type: row.get("authentication_type")?,
authentication: serde_json::from_str(&authentication).unwrap_or_default(),
setting_validate_certificates: row.get("setting_validate_certificates")?,
setting_follow_redirects: row.get("setting_follow_redirects")?,
setting_request_timeout: row.get("setting_request_timeout")?,
})
}
}
@@ -857,6 +895,11 @@ pub struct HttpRequest {
pub sort_priority: f64,
pub url: String,
pub url_parameters: Vec<HttpUrlParameter>,
// Inheritable settings (Option = null means inherit from parent)
pub setting_validate_certificates: Option<bool>,
pub setting_follow_redirects: Option<bool>,
pub setting_request_timeout: Option<i32>,
}
impl UpsertModelInfo for HttpRequest {
@@ -884,6 +927,7 @@ impl UpsertModelInfo for HttpRequest {
self,
source: &UpdateSource,
) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {
use HttpRequestIden::*;
Ok(vec![
(CreatedAt, upsert_date(source, self.created_at)),
(UpdatedAt, upsert_date(source, self.updated_at)),
@@ -900,10 +944,14 @@ impl UpsertModelInfo for HttpRequest {
(AuthenticationType, self.authentication_type.into()),
(Headers, serde_json::to_string(&self.headers)?.into()),
(SortPriority, self.sort_priority.into()),
(SettingValidateCertificates, self.setting_validate_certificates.into()),
(SettingFollowRedirects, self.setting_follow_redirects.into()),
(SettingRequestTimeout, self.setting_request_timeout.into()),
])
}
fn update_columns() -> Vec<impl IntoIden> {
use HttpRequestIden::*;
vec![
UpdatedAt,
WorkspaceId,
@@ -919,6 +967,9 @@ impl UpsertModelInfo for HttpRequest {
Url,
UrlParameters,
SortPriority,
SettingValidateCertificates,
SettingFollowRedirects,
SettingRequestTimeout,
]
}
@@ -945,6 +996,9 @@ impl UpsertModelInfo for HttpRequest {
sort_priority: row.get("sort_priority")?,
url: row.get("url")?,
url_parameters: serde_json::from_str(url_parameters.as_str()).unwrap_or_default(),
setting_validate_certificates: row.get("setting_validate_certificates")?,
setting_follow_redirects: row.get("setting_follow_redirects")?,
setting_request_timeout: row.get("setting_request_timeout")?,
})
}
}

View File

@@ -1,6 +1,6 @@
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{Folder, FolderIden, HttpRequest, HttpRequestHeader, HttpRequestIden};
use crate::models::{Folder, FolderIden, HttpRequest, HttpRequestHeader, HttpRequestIden, ResolvedHttpRequestSettings};
use crate::util::UpdateSource;
use serde_json::Value;
use std::collections::BTreeMap;
@@ -103,4 +103,79 @@ impl<'a> DbContext<'a> {
}
Ok(children)
}
/// Resolve settings for an HTTP request by walking the inheritance chain:
/// Workspace → Folder(s) → Request
/// Last non-None value wins, then defaults are applied.
pub fn resolve_settings_for_http_request(
&self,
http_request: &HttpRequest,
) -> Result<ResolvedHttpRequestSettings> {
let workspace = self.get_workspace(&http_request.workspace_id)?;
// Start with None for all settings
let mut validate_certs: Option<bool> = None;
let mut follow_redirects: Option<bool> = None;
let mut timeout: Option<i32> = None;
// Apply workspace settings
if workspace.setting_validate_certificates.is_some() {
validate_certs = workspace.setting_validate_certificates;
}
if workspace.setting_follow_redirects.is_some() {
follow_redirects = workspace.setting_follow_redirects;
}
if workspace.setting_request_timeout.is_some() {
timeout = workspace.setting_request_timeout;
}
// Apply folder chain settings (root first, immediate parent last)
if let Some(folder_id) = &http_request.folder_id {
let folders = self.get_folder_ancestors(folder_id)?;
for folder in folders {
if folder.setting_validate_certificates.is_some() {
validate_certs = folder.setting_validate_certificates;
}
if folder.setting_follow_redirects.is_some() {
follow_redirects = folder.setting_follow_redirects;
}
if folder.setting_request_timeout.is_some() {
timeout = folder.setting_request_timeout;
}
}
}
// Apply request-level settings (highest priority)
if http_request.setting_validate_certificates.is_some() {
validate_certs = http_request.setting_validate_certificates;
}
if http_request.setting_follow_redirects.is_some() {
follow_redirects = http_request.setting_follow_redirects;
}
if http_request.setting_request_timeout.is_some() {
timeout = http_request.setting_request_timeout;
}
// Apply defaults for anything still None
Ok(ResolvedHttpRequestSettings {
validate_certificates: validate_certs.unwrap_or(true),
follow_redirects: follow_redirects.unwrap_or(true),
request_timeout: timeout.unwrap_or(0),
})
}
/// Get folder ancestors in order from root to immediate parent
fn get_folder_ancestors(&self, folder_id: &str) -> Result<Vec<Folder>> {
let mut ancestors = Vec::new();
let mut current_id = Some(folder_id.to_string());
while let Some(id) = current_id {
let folder = self.get_folder(&id)?;
current_id = folder.folder_id.clone();
ancestors.push(folder);
}
ancestors.reverse(); // Root first, immediate parent last
Ok(ancestors)
}
}

View File

@@ -20,8 +20,8 @@ impl<'a> DbContext<'a> {
workspaces.push(self.upsert_workspace(
&Workspace {
name: "Yaak".to_string(),
setting_follow_redirects: true,
setting_validate_certificates: true,
setting_follow_redirects: Some(true),
setting_validate_certificates: Some(true),
..Default::default()
},
&UpdateSource::Background,

View File

File diff suppressed because one or more lines are too long

View File

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

View File

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

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

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