Compare commits

..

1 Commits

Author SHA1 Message Date
Gregory Schier
fa3e6e6508 feat: add ctx.prompt.form() API for multi-field form dialogs
Add PromptFormRequest and PromptFormResponse types to enable plugins to
display forms with multiple input fields. Implement the form() method in
the prompt context and wire up frontend event handling to show and collect
form responses from users.
2026-01-09 19:35:47 -08:00
115 changed files with 1328 additions and 2741 deletions

View File

@@ -37,11 +37,3 @@ The skill generates markdown-formatted release notes following this structure:
**IMPORTANT**: Always add a blank lines around the markdown code fence and output the markdown code block last **IMPORTANT**: Always add a blank lines around the markdown code fence and output the markdown code block last
**IMPORTANT**: PRs by `@gschier` should not mention the @username **IMPORTANT**: PRs by `@gschier` should not mention the @username
## After Generating Release Notes
After outputting the release notes, ask the user if they would like to create a draft GitHub release with these notes. If they confirm, create the release using:
```bash
gh release create <tag> --draft --prerelease --title "<tag>" --notes '<release notes>'
```

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

@@ -1,6 +1,6 @@
<p align="center"> <p align="center">
<a href="https://github.com/JamesIves/github-sponsors-readme-action"> <a href="https://github.com/JamesIves/github-sponsors-readme-action">
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/crates-tauri/yaak-app/icons/icon.png"> <img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/src-tauri/icons/icon.png">
</a> </a>
</p> </p>
@@ -64,7 +64,7 @@ visit [`DEVELOPMENT.md`](DEVELOPMENT.md) for tips on setting up your environment
## Useful Resources ## Useful Resources
- [Feedback and Bug Reports](https://feedback.yaak.app) - [Feedback and Bug Reports](https://feedback.yaak.app)
- [Documentation](https://yaak.app/docs) - [Documentation](https://feedback.yaak.app/help)
- [Yaak vs Postman](https://yaak.app/alternatives/postman) - [Yaak vs Postman](https://yaak.app/alternatives/postman)
- [Yaak vs Bruno](https://yaak.app/alternatives/bruno) - [Yaak vs Bruno](https://yaak.app/alternatives/bruno)
- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia) - [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia)

View File

@@ -15,7 +15,7 @@ use yaak_models::util::UpdateSource;
use yaak_plugins::events::{PluginContext, RenderPurpose}; use yaak_plugins::events::{PluginContext, RenderPurpose};
use yaak_plugins::manager::PluginManager; use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback; use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::{RenderOptions, parse_and_render, render_json_value_raw}; use yaak_templates::{parse_and_render, render_json_value_raw, RenderOptions};
#[derive(Parser)] #[derive(Parser)]
#[command(name = "yaakcli")] #[command(name = "yaakcli")]
@@ -149,7 +149,14 @@ async fn render_http_request(
// Apply path placeholders (e.g., /users/:id -> /users/123) // Apply path placeholders (e.g., /users/:id -> /users/123)
let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters); let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters);
Ok(HttpRequest { url, url_parameters, headers, body, authentication, ..r.to_owned() }) Ok(HttpRequest {
url,
url_parameters,
headers,
body,
authentication,
..r.to_owned()
})
} }
#[tokio::main] #[tokio::main]
@@ -162,10 +169,16 @@ async fn main() {
} }
// Use the same app_id for both data directory and keyring // Use the same app_id for both data directory and keyring
let app_id = if cfg!(debug_assertions) { "app.yaak.desktop.dev" } else { "app.yaak.desktop" }; let app_id = if cfg!(debug_assertions) {
"app.yaak.desktop.dev"
} else {
"app.yaak.desktop"
};
let data_dir = cli.data_dir.unwrap_or_else(|| { let data_dir = cli.data_dir.unwrap_or_else(|| {
dirs::data_dir().expect("Could not determine data directory").join(app_id) dirs::data_dir()
.expect("Could not determine data directory")
.join(app_id)
}); });
let db_path = data_dir.join("db.sqlite"); let db_path = data_dir.join("db.sqlite");
@@ -178,7 +191,9 @@ async fn main() {
// Initialize encryption manager for secure() template function // Initialize encryption manager for secure() template function
// Use the same app_id as the Tauri app for keyring access // Use the same app_id as the Tauri app for keyring access
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id)); let encryption_manager = Arc::new(
EncryptionManager::new(query_manager.clone(), app_id),
);
// Initialize plugin manager for template functions // Initialize plugin manager for template functions
let vendored_plugin_dir = data_dir.join("vendored-plugins"); let vendored_plugin_dir = data_dir.join("vendored-plugins");
@@ -188,8 +203,9 @@ async fn main() {
let node_bin_path = PathBuf::from("node"); let node_bin_path = PathBuf::from("node");
// Find the plugin runtime - check YAAK_PLUGIN_RUNTIME env var, then fallback to development path // Find the plugin runtime - check YAAK_PLUGIN_RUNTIME env var, then fallback to development path
let plugin_runtime_main = let plugin_runtime_main = std::env::var("YAAK_PLUGIN_RUNTIME")
std::env::var("YAAK_PLUGIN_RUNTIME").map(PathBuf::from).unwrap_or_else(|_| { .map(PathBuf::from)
.unwrap_or_else(|_| {
// Development fallback: look relative to crate root // Development fallback: look relative to crate root
PathBuf::from(env!("CARGO_MANIFEST_DIR")) PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs") .join("../../crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs")
@@ -210,10 +226,14 @@ async fn main() {
// Initialize plugins from database // Initialize plugins from database
let plugins = db.list_plugins().unwrap_or_default(); let plugins = db.list_plugins().unwrap_or_default();
if !plugins.is_empty() { if !plugins.is_empty() {
let errors = let errors = plugin_manager
plugin_manager.initialize_all_plugins(plugins, &PluginContext::new_empty()).await; .initialize_all_plugins(plugins, &PluginContext::new_empty())
.await;
for (plugin_dir, error_msg) in errors { for (plugin_dir, error_msg) in errors {
eprintln!("Warning: Failed to initialize plugin '{}': {}", plugin_dir, error_msg); eprintln!(
"Warning: Failed to initialize plugin '{}': {}",
plugin_dir, error_msg
);
} }
} }
@@ -229,7 +249,9 @@ async fn main() {
} }
} }
Commands::Requests { workspace_id } => { Commands::Requests { workspace_id } => {
let requests = db.list_http_requests(&workspace_id).expect("Failed to list requests"); let requests = db
.list_http_requests(&workspace_id)
.expect("Failed to list requests");
if requests.is_empty() { if requests.is_empty() {
println!("No requests found in workspace {}", workspace_id); println!("No requests found in workspace {}", workspace_id);
} else { } else {
@@ -239,7 +261,9 @@ async fn main() {
} }
} }
Commands::Send { request_id } => { Commands::Send { request_id } => {
let request = db.get_http_request(&request_id).expect("Failed to get request"); let request = db
.get_http_request(&request_id)
.expect("Failed to get request");
// Resolve environment chain for variable substitution // Resolve environment chain for variable substitution
let environment_chain = db let environment_chain = db
@@ -294,13 +318,18 @@ async fn main() {
})) }))
} else { } else {
// Drain events silently // Drain events silently
tokio::spawn(async move { while event_rx.recv().await.is_some() {} }); tokio::spawn(async move {
while event_rx.recv().await.is_some() {}
});
None None
}; };
// Send the request // Send the request
let sender = ReqwestSender::new().expect("Failed to create HTTP client"); let sender = ReqwestSender::new().expect("Failed to create HTTP client");
let response = sender.send(sendable, event_tx).await.expect("Failed to send request"); let response = sender
.send(sendable, event_tx)
.await
.expect("Failed to send request");
// Wait for event handler to finish // Wait for event handler to finish
if let Some(handle) = verbose_handle { if let Some(handle) = verbose_handle {
@@ -354,13 +383,18 @@ async fn main() {
} }
})) }))
} else { } else {
tokio::spawn(async move { while event_rx.recv().await.is_some() {} }); tokio::spawn(async move {
while event_rx.recv().await.is_some() {}
});
None None
}; };
// Send the request // Send the request
let sender = ReqwestSender::new().expect("Failed to create HTTP client"); let sender = ReqwestSender::new().expect("Failed to create HTTP client");
let response = sender.send(sendable, event_tx).await.expect("Failed to send request"); let response = sender
.send(sendable, event_tx)
.await
.expect("Failed to send request");
if let Some(handle) = verbose_handle { if let Some(handle) = verbose_handle {
let _ = handle.await; let _ = handle.await;
@@ -387,7 +421,12 @@ async fn main() {
let (body, _stats) = response.text().await.expect("Failed to read response body"); let (body, _stats) = response.text().await.expect("Failed to read response body");
println!("{}", body); println!("{}", body);
} }
Commands::Create { workspace_id, name, method, url } => { Commands::Create {
workspace_id,
name,
method,
url,
} => {
let request = HttpRequest { let request = HttpRequest {
workspace_id, workspace_id,
name, name,

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

@@ -1,11 +1,9 @@
use crate::PluginContextExt;
use crate::error::Result; use crate::error::Result;
use crate::PluginContextExt;
use std::sync::Arc; use std::sync::Arc;
use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow, command}; use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow, command};
use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
use yaak_crypto::manager::EncryptionManager; use yaak_crypto::manager::EncryptionManager;
use yaak_models::models::HttpRequestHeader;
use yaak_models::queries::workspaces::default_headers;
use yaak_plugins::events::GetThemesResponse; use yaak_plugins::events::GetThemesResponse;
use yaak_plugins::manager::PluginManager; use yaak_plugins::manager::PluginManager;
use yaak_plugins::native_template_functions::{ use yaak_plugins::native_template_functions::{
@@ -56,12 +54,7 @@ pub(crate) async fn cmd_secure_template<R: Runtime>(
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone()); let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let plugin_context = window.plugin_context(); let plugin_context = window.plugin_context();
Ok(encrypt_secure_template_function( Ok(encrypt_secure_template_function(plugin_manager, encryption_manager, &plugin_context, template)?)
plugin_manager,
encryption_manager,
&plugin_context,
template,
)?)
} }
#[command] #[command]
@@ -99,8 +92,3 @@ pub(crate) async fn cmd_set_workspace_key<R: Runtime>(
window.crypto().set_human_key(workspace_id, key)?; window.crypto().set_human_key(workspace_id, key)?;
Ok(()) Ok(())
} }
#[command]
pub(crate) fn cmd_default_headers() -> Vec<HttpRequestHeader> {
default_headers()
}

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

@@ -1,12 +1,12 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use crate::PluginContextExt;
use crate::error::Result; use crate::error::Result;
use crate::models_ext::QueryManagerExt; use crate::PluginContextExt;
use KeyAndValueRef::{Ascii, Binary}; use KeyAndValueRef::{Ascii, Binary};
use tauri::{Manager, Runtime, WebviewWindow}; use tauri::{Manager, Runtime, WebviewWindow};
use yaak_grpc::{KeyAndValueRef, MetadataMap}; use yaak_grpc::{KeyAndValueRef, MetadataMap};
use yaak_models::models::GrpcRequest; use yaak_models::models::GrpcRequest;
use crate::models_ext::QueryManagerExt;
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader}; use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader};
use yaak_plugins::manager::PluginManager; use yaak_plugins::manager::PluginManager;

View File

@@ -1,8 +1,8 @@
use crate::models_ext::QueryManagerExt;
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, Utc};
use log::debug; use log::debug;
use std::sync::OnceLock; use std::sync::OnceLock;
use tauri::{AppHandle, Runtime}; use tauri::{AppHandle, Runtime};
use crate::models_ext::QueryManagerExt;
use yaak_models::util::UpdateSource; use yaak_models::util::UpdateSource;
const NAMESPACE: &str = "analytics"; const NAMESPACE: &str = "analytics";

View File

@@ -1,13 +1,9 @@
use crate::PluginContextExt;
use crate::error::Error::GenericError; use crate::error::Error::GenericError;
use crate::error::Result; use crate::error::Result;
use crate::models_ext::BlobManagerExt;
use crate::models_ext::QueryManagerExt;
use crate::render::render_http_request; use crate::render::render_http_request;
use log::{debug, warn}; use log::{debug, warn};
use std::pin::Pin; use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicI32, Ordering};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tauri::{AppHandle, Manager, Runtime, WebviewWindow}; use tauri::{AppHandle, Manager, Runtime, WebviewWindow};
use tokio::fs::{File, create_dir_all}; use tokio::fs::{File, create_dir_all};
@@ -19,19 +15,22 @@ use yaak_http::client::{
HttpConnectionOptions, HttpConnectionProxySetting, HttpConnectionProxySettingAuth, HttpConnectionOptions, HttpConnectionProxySetting, HttpConnectionProxySettingAuth,
}; };
use yaak_http::cookies::CookieStore; use yaak_http::cookies::CookieStore;
use yaak_http::manager::{CachedClient, HttpConnectionManager}; use yaak_http::manager::HttpConnectionManager;
use yaak_http::sender::ReqwestSender; use yaak_http::sender::ReqwestSender;
use yaak_http::tee_reader::TeeReader; use yaak_http::tee_reader::TeeReader;
use yaak_http::transaction::HttpTransaction; use yaak_http::transaction::HttpTransaction;
use yaak_http::types::{ use yaak_http::types::{
SendableBody, SendableHttpRequest, SendableHttpRequestOptions, append_query_params, SendableBody, SendableHttpRequest, SendableHttpRequestOptions, append_query_params,
}; };
use crate::models_ext::BlobManagerExt;
use yaak_models::blob_manager::BodyChunk; use yaak_models::blob_manager::BodyChunk;
use yaak_models::models::{ use yaak_models::models::{
CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseHeader, CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseHeader,
HttpResponseState, ProxySetting, ProxySettingAuth, HttpResponseState, ProxySetting, ProxySettingAuth,
}; };
use crate::models_ext::QueryManagerExt;
use yaak_models::util::UpdateSource; use yaak_models::util::UpdateSource;
use crate::PluginContextExt;
use yaak_plugins::events::{ use yaak_plugins::events::{
CallHttpAuthenticationRequest, HttpHeader, PluginContext, RenderPurpose, CallHttpAuthenticationRequest, HttpHeader, PluginContext, RenderPurpose,
}; };
@@ -174,12 +173,7 @@ async fn send_http_request_inner<R: Runtime>(
let environment_id = environment.map(|e| e.id); let environment_id = environment.map(|e| e.id);
let workspace = window.db().get_workspace(workspace_id)?; let workspace = window.db().get_workspace(workspace_id)?;
let (resolved, auth_context_id) = resolve_http_request(window, unrendered_request)?; let (resolved, auth_context_id) = resolve_http_request(window, unrendered_request)?;
let cb = PluginTemplateCallback::new( let cb = PluginTemplateCallback::new(plugin_manager.clone(), encryption_manager.clone(), &plugin_context, RenderPurpose::Send);
plugin_manager.clone(),
encryption_manager.clone(),
&plugin_context,
RenderPurpose::Send,
);
let env_chain = let env_chain =
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?;
@@ -234,13 +228,12 @@ async fn send_http_request_inner<R: Runtime>(
None => None, None => None,
}; };
let cached_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: workspace.setting_validate_certificates,
proxy: proxy_setting, proxy: proxy_setting,
client_certificate, client_certificate,
dns_overrides: workspace.setting_dns_overrides.clone(),
}) })
.await?; .await?;
@@ -257,7 +250,7 @@ async fn send_http_request_inner<R: Runtime>(
let cookie_store = maybe_cookie_store.as_ref().map(|(cs, _)| cs.clone()); let cookie_store = maybe_cookie_store.as_ref().map(|(cs, _)| cs.clone());
let result = execute_transaction( let result = execute_transaction(
cached_client, client,
sendable_request, sendable_request,
response_ctx, response_ctx,
cancelled_rx.clone(), cancelled_rx.clone(),
@@ -317,7 +310,7 @@ pub fn resolve_http_request<R: Runtime>(
} }
async fn execute_transaction<R: Runtime>( async fn execute_transaction<R: Runtime>(
cached_client: CachedClient, client: reqwest::Client,
mut sendable_request: SendableHttpRequest, mut sendable_request: SendableHttpRequest,
response_ctx: &mut ResponseContext<R>, response_ctx: &mut ResponseContext<R>,
mut cancelled_rx: Receiver<bool>, mut cancelled_rx: Receiver<bool>,
@@ -328,10 +321,7 @@ async fn execute_transaction<R: Runtime>(
let workspace_id = response_ctx.response().workspace_id.clone(); let workspace_id = response_ctx.response().workspace_id.clone();
let is_persisted = response_ctx.is_persisted(); let is_persisted = response_ctx.is_persisted();
// Keep a reference to the resolver for DNS timing events let sender = ReqwestSender::with_client(client);
let resolver = cached_client.resolver.clone();
let sender = ReqwestSender::with_client(cached_client.client);
let transaction = match cookie_store { let transaction = match cookie_store {
Some(cs) => HttpTransaction::with_cookie_store(sender, cs), Some(cs) => HttpTransaction::with_cookie_store(sender, cs),
None => HttpTransaction::new(sender), None => HttpTransaction::new(sender),
@@ -356,39 +346,21 @@ async fn execute_transaction<R: Runtime>(
let (event_tx, mut event_rx) = let (event_tx, mut event_rx) =
tokio::sync::mpsc::channel::<yaak_http::sender::HttpResponseEvent>(100); tokio::sync::mpsc::channel::<yaak_http::sender::HttpResponseEvent>(100);
// Set the event sender on the DNS resolver so it can emit DNS timing events
resolver.set_event_sender(Some(event_tx.clone())).await;
// Shared state to capture DNS timing from the event processing task
let dns_elapsed = Arc::new(AtomicI32::new(0));
// Write events to DB in a task (only for persisted responses) // Write events to DB in a task (only for persisted responses)
if is_persisted { if is_persisted {
let response_id = response_id.clone(); let response_id = response_id.clone();
let app_handle = app_handle.clone(); let app_handle = app_handle.clone();
let update_source = response_ctx.update_source.clone(); let update_source = response_ctx.update_source.clone();
let workspace_id = workspace_id.clone(); let workspace_id = workspace_id.clone();
let dns_elapsed = dns_elapsed.clone();
tokio::spawn(async move { tokio::spawn(async move {
while let Some(event) = event_rx.recv().await { while let Some(event) = event_rx.recv().await {
// Capture DNS timing when we see a DNS event
if let yaak_http::sender::HttpResponseEvent::DnsResolved { duration, .. } = &event {
dns_elapsed.store(*duration as i32, Ordering::SeqCst);
}
let db_event = HttpResponseEvent::new(&response_id, &workspace_id, event.into()); let db_event = HttpResponseEvent::new(&response_id, &workspace_id, event.into());
let _ = app_handle.db().upsert_http_response_event(&db_event, &update_source); let _ = app_handle.db().upsert_http_response_event(&db_event, &update_source);
} }
}); });
} else { } else {
// For ephemeral responses, just drain the events but still capture DNS timing // For ephemeral responses, just drain the events
let dns_elapsed = dns_elapsed.clone(); tokio::spawn(async move { while event_rx.recv().await.is_some() {} });
tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
if let yaak_http::sender::HttpResponseEvent::DnsResolved { duration, .. } = &event {
dns_elapsed.store(*duration as i32, Ordering::SeqCst);
}
}
});
}; };
// Capture request body as it's sent (only for persisted responses) // Capture request body as it's sent (only for persisted responses)
@@ -556,14 +528,10 @@ async fn execute_transaction<R: Runtime>(
// Final update with closed state and accurate byte count // Final update with closed state and accurate byte count
response_ctx.update(|r| { response_ctx.update(|r| {
r.elapsed = start.elapsed().as_millis() as i32; r.elapsed = start.elapsed().as_millis() as i32;
r.elapsed_dns = dns_elapsed.load(Ordering::SeqCst);
r.content_length = Some(written_bytes as i32); r.content_length = Some(written_bytes as i32);
r.state = HttpResponseState::Closed; r.state = HttpResponseState::Closed;
})?; })?;
// Clear the event sender from the resolver since this request is done
resolver.set_event_sender(None).await;
Ok((response_ctx.response().clone(), maybe_blob_write_handle)) Ok((response_ctx.response().clone(), maybe_blob_write_handle))
} }

View File

@@ -1,17 +1,17 @@
use crate::PluginContextExt;
use crate::error::Result; use crate::error::Result;
use crate::models_ext::QueryManagerExt; use crate::models_ext::QueryManagerExt;
use crate::PluginContextExt;
use log::info; use log::info;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fs::read_to_string; use std::fs::read_to_string;
use tauri::{Manager, Runtime, WebviewWindow}; use tauri::{Manager, Runtime, WebviewWindow};
use yaak_tauri_utils::window::WorkspaceWindowTrait;
use yaak_core::WorkspaceContext; use yaak_core::WorkspaceContext;
use yaak_models::models::{ use yaak_models::models::{
Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace, Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace,
}; };
use yaak_models::util::{BatchUpsertResult, UpdateSource, maybe_gen_id, maybe_gen_id_opt}; use yaak_models::util::{BatchUpsertResult, UpdateSource, maybe_gen_id, maybe_gen_id_opt};
use yaak_plugins::manager::PluginManager; use yaak_plugins::manager::PluginManager;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
pub(crate) async fn import_data<R: Runtime>( pub(crate) async fn import_data<R: Runtime>(
window: &WebviewWindow<R>, window: &WebviewWindow<R>,

View File

@@ -7,7 +7,7 @@ use crate::http_request::{resolve_http_request, send_http_request};
use crate::import::import_data; use crate::import::import_data;
use crate::models_ext::{BlobManagerExt, QueryManagerExt}; use crate::models_ext::{BlobManagerExt, QueryManagerExt};
use crate::notifications::YaakNotifier; use crate::notifications::YaakNotifier;
use crate::render::{render_grpc_request, render_json_value, render_template}; use crate::render::{render_grpc_request, render_template};
use crate::updates::{UpdateMode, UpdateTrigger, YaakUpdater}; use crate::updates::{UpdateMode, UpdateTrigger, YaakUpdater};
use crate::uri_scheme::handle_deep_link; use crate::uri_scheme::handle_deep_link;
use error::Result as YaakResult; use error::Result as YaakResult;
@@ -189,6 +189,7 @@ async fn cmd_grpc_reflect<R: Runtime>(
request_id: &str, request_id: &str,
environment_id: Option<&str>, environment_id: Option<&str>,
proto_files: Vec<String>, proto_files: Vec<String>,
skip_cache: Option<bool>,
window: WebviewWindow<R>, window: WebviewWindow<R>,
app_handle: AppHandle<R>, app_handle: AppHandle<R>,
grpc_handle: State<'_, Mutex<GrpcHandle>>, grpc_handle: State<'_, Mutex<GrpcHandle>>,
@@ -223,21 +224,18 @@ async fn cmd_grpc_reflect<R: Runtime>(
let settings = window.db().get_settings(); let settings = window.db().get_settings();
let client_certificate = let client_certificate =
find_client_certificate(req.url.as_str(), &settings.client_certificates); find_client_certificate(req.url.as_str(), &settings.client_certificates);
let proto_files: Vec<PathBuf> =
proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect();
// Always invalidate cached pool when this command is called, to force re-reflection Ok(grpc_handle
let mut handle = grpc_handle.lock().await; .lock()
handle.invalidate_pool(&req.id, &uri, &proto_files); .await
Ok(handle
.services( .services(
&req.id, &req.id,
&uri, &uri,
&proto_files, &proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect(),
&metadata, &metadata,
workspace.setting_validate_certificates, workspace.setting_validate_certificates,
client_certificate, client_certificate,
skip_cache.unwrap_or(false),
) )
.await .await
.map_err(|e| GenericError(e.to_string()))?) .map_err(|e| GenericError(e.to_string()))?)
@@ -362,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();
@@ -385,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(
@@ -404,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();
@@ -455,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,
@@ -511,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,
), ),
@@ -1057,54 +1035,14 @@ async fn cmd_get_http_authentication_summaries<R: Runtime>(
#[tauri::command] #[tauri::command]
async fn cmd_get_http_authentication_config<R: Runtime>( async fn cmd_get_http_authentication_config<R: Runtime>(
window: WebviewWindow<R>, window: WebviewWindow<R>,
app_handle: AppHandle<R>,
plugin_manager: State<'_, PluginManager>, plugin_manager: State<'_, PluginManager>,
encryption_manager: State<'_, EncryptionManager>,
auth_name: &str, auth_name: &str,
values: HashMap<String, JsonPrimitive>, values: HashMap<String, JsonPrimitive>,
model: AnyModel, model: AnyModel,
environment_id: Option<&str>, _environment_id: Option<&str>,
) -> YaakResult<GetHttpAuthenticationConfigResponse> { ) -> YaakResult<GetHttpAuthenticationConfigResponse> {
// Extract workspace_id and folder_id from the model to resolve the environment chain
let (workspace_id, folder_id) = match &model {
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
AnyModel::Workspace(w) => (w.id.clone(), None),
_ => return Err(GenericError("Unsupported model type for authentication config".into())),
};
// Resolve environment chain and render the values for token lookup
let environment_chain = app_handle.db().resolve_environments(
&workspace_id,
folder_id.as_deref(),
environment_id,
)?;
let plugin_manager_arc = Arc::new((*plugin_manager).clone());
let encryption_manager_arc = Arc::new((*encryption_manager).clone());
let cb = PluginTemplateCallback::new(
plugin_manager_arc,
encryption_manager_arc,
&window.plugin_context(),
RenderPurpose::Preview,
);
// Convert HashMap<String, JsonPrimitive> to serde_json::Value for rendering
let values_json: serde_json::Value = serde_json::to_value(&values)?;
let rendered_json =
render_json_value(values_json, environment_chain, &cb, &RenderOptions::throw()).await?;
// Convert back to HashMap<String, JsonPrimitive>
let rendered_values: HashMap<String, JsonPrimitive> = serde_json::from_value(rendered_json)?;
Ok(plugin_manager Ok(plugin_manager
.get_http_authentication_config( .get_http_authentication_config(&window.plugin_context(), auth_name, values, model.id())
&window.plugin_context(),
auth_name,
rendered_values,
model.id(),
)
.await?) .await?)
} }
@@ -1151,54 +1089,19 @@ async fn cmd_call_grpc_request_action<R: Runtime>(
#[tauri::command] #[tauri::command]
async fn cmd_call_http_authentication_action<R: Runtime>( async fn cmd_call_http_authentication_action<R: Runtime>(
window: WebviewWindow<R>, window: WebviewWindow<R>,
app_handle: AppHandle<R>,
plugin_manager: State<'_, PluginManager>, plugin_manager: State<'_, PluginManager>,
encryption_manager: State<'_, EncryptionManager>,
auth_name: &str, auth_name: &str,
action_index: i32, action_index: i32,
values: HashMap<String, JsonPrimitive>, values: HashMap<String, JsonPrimitive>,
model: AnyModel, model: AnyModel,
environment_id: Option<&str>, _environment_id: Option<&str>,
) -> YaakResult<()> { ) -> YaakResult<()> {
// Extract workspace_id and folder_id from the model to resolve the environment chain
let (workspace_id, folder_id) = match &model {
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
AnyModel::Workspace(w) => (w.id.clone(), None),
_ => return Err(GenericError("Unsupported model type for authentication action".into())),
};
// Resolve environment chain and render the values
let environment_chain = app_handle.db().resolve_environments(
&workspace_id,
folder_id.as_deref(),
environment_id,
)?;
let plugin_manager_arc = Arc::new((*plugin_manager).clone());
let encryption_manager_arc = Arc::new((*encryption_manager).clone());
let cb = PluginTemplateCallback::new(
plugin_manager_arc,
encryption_manager_arc,
&window.plugin_context(),
RenderPurpose::Send,
);
// Convert HashMap<String, JsonPrimitive> to serde_json::Value for rendering
let values_json: serde_json::Value = serde_json::to_value(&values)?;
let rendered_json =
render_json_value(values_json, environment_chain, &cb, &RenderOptions::throw()).await?;
// Convert back to HashMap<String, JsonPrimitive>
let rendered_values: HashMap<String, JsonPrimitive> = serde_json::from_value(rendered_json)?;
Ok(plugin_manager Ok(plugin_manager
.call_http_authentication_action( .call_http_authentication_action(
&window.plugin_context(), &window.plugin_context(),
auth_name, auth_name,
action_index, action_index,
rendered_values, values,
&model.id(), &model.id(),
) )
.await?) .await?)
@@ -1718,7 +1621,6 @@ pub fn run() {
// //
// Migrated commands // Migrated commands
crate::commands::cmd_decrypt_template, crate::commands::cmd_decrypt_template,
crate::commands::cmd_default_headers,
crate::commands::cmd_enable_encryption, crate::commands::cmd_enable_encryption,
crate::commands::cmd_get_themes, crate::commands::cmd_get_themes,
crate::commands::cmd_reveal_workspace_key, crate::commands::cmd_reveal_workspace_key,
@@ -1762,13 +1664,6 @@ pub fn run() {
git_ext::cmd_git_add_remote, git_ext::cmd_git_add_remote,
git_ext::cmd_git_rm_remote, git_ext::cmd_git_rm_remote,
// //
// Plugin commands
plugins_ext::cmd_plugins_search,
plugins_ext::cmd_plugins_install,
plugins_ext::cmd_plugins_uninstall,
plugins_ext::cmd_plugins_updates,
plugins_ext::cmd_plugins_update_all,
//
// WebSocket commands // WebSocket commands
ws_ext::cmd_ws_upsert_request, ws_ext::cmd_ws_upsert_request,
ws_ext::cmd_ws_duplicate_request, ws_ext::cmd_ws_duplicate_request,

View File

@@ -1,6 +1,5 @@
use crate::error::Result; use crate::error::Result;
use crate::history::get_or_upsert_launch_info; use crate::history::get_or_upsert_launch_info;
use crate::models_ext::QueryManagerExt;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use log::{debug, info}; use log::{debug, info};
use reqwest::Method; use reqwest::Method;
@@ -9,8 +8,9 @@ use std::time::Instant;
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow}; use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow};
use ts_rs::TS; use ts_rs::TS;
use yaak_common::platform::get_os_str; use yaak_common::platform::get_os_str;
use yaak_models::util::UpdateSource;
use yaak_tauri_utils::api_client::yaak_api_client; use yaak_tauri_utils::api_client::yaak_api_client;
use crate::models_ext::QueryManagerExt;
use yaak_models::util::UpdateSource;
// Check for updates every hour // Check for updates every hour
const MAX_UPDATE_CHECK_SECONDS: u64 = 60 * 60; const MAX_UPDATE_CHECK_SECONDS: u64 = 60 * 60;

View File

@@ -1,7 +1,5 @@
use crate::error::Result; use crate::error::Result;
use crate::http_request::send_http_request_with_context; use crate::http_request::send_http_request_with_context;
use crate::models_ext::BlobManagerExt;
use crate::models_ext::QueryManagerExt;
use crate::render::{render_grpc_request, render_http_request, render_json_value}; use crate::render::{render_grpc_request, render_http_request, render_json_value};
use crate::window::{CreateWindowConfig, create_window}; use crate::window::{CreateWindowConfig, create_window};
use crate::{ use crate::{
@@ -16,8 +14,11 @@ use tauri::{AppHandle, Emitter, Manager, Runtime};
use tauri_plugin_clipboard_manager::ClipboardExt; use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
use yaak_crypto::manager::EncryptionManager; use yaak_crypto::manager::EncryptionManager;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
use crate::models_ext::BlobManagerExt;
use yaak_models::models::{AnyModel, HttpResponse, Plugin}; use yaak_models::models::{AnyModel, HttpResponse, Plugin};
use yaak_models::queries::any_request::AnyRequest; use yaak_models::queries::any_request::AnyRequest;
use crate::models_ext::QueryManagerExt;
use yaak_models::util::UpdateSource; use yaak_models::util::UpdateSource;
use yaak_plugins::error::Error::PluginErr; use yaak_plugins::error::Error::PluginErr;
use yaak_plugins::events::{ use yaak_plugins::events::{
@@ -31,7 +32,6 @@ use yaak_plugins::events::{
use yaak_plugins::manager::PluginManager; use yaak_plugins::manager::PluginManager;
use yaak_plugins::plugin_handle::PluginHandle; use yaak_plugins::plugin_handle::PluginHandle;
use yaak_plugins::template_callback::PluginTemplateCallback; use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
use yaak_templates::{RenderErrorBehavior, RenderOptions}; use yaak_templates::{RenderErrorBehavior, RenderOptions};
pub(crate) async fn handle_plugin_event<R: Runtime>( pub(crate) async fn handle_plugin_event<R: Runtime>(
@@ -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()
@@ -170,12 +166,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
)?; )?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone()); let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let cb = PluginTemplateCallback::new( let cb = PluginTemplateCallback::new(plugin_manager, encryption_manager, &plugin_context, req.purpose);
plugin_manager,
encryption_manager,
&plugin_context,
req.purpose,
);
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw }; let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
let grpc_request = let grpc_request =
render_grpc_request(&req.grpc_request, environment_chain, &cb, &opt).await?; render_grpc_request(&req.grpc_request, environment_chain, &cb, &opt).await?;
@@ -196,12 +187,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
)?; )?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone()); let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let cb = PluginTemplateCallback::new( let cb = PluginTemplateCallback::new(plugin_manager, encryption_manager, &plugin_context, req.purpose);
plugin_manager,
encryption_manager,
&plugin_context,
req.purpose,
);
let opt = &RenderOptions { error_behavior: RenderErrorBehavior::Throw }; let opt = &RenderOptions { error_behavior: RenderErrorBehavior::Throw };
let http_request = let http_request =
render_http_request(&req.http_request, environment_chain, &cb, &opt).await?; render_http_request(&req.http_request, environment_chain, &cb, &opt).await?;
@@ -232,12 +218,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
)?; )?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone()); let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let cb = PluginTemplateCallback::new( let cb = PluginTemplateCallback::new(plugin_manager, encryption_manager, &plugin_context, req.purpose);
plugin_manager,
encryption_manager,
&plugin_context,
req.purpose,
);
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw }; let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
let data = render_json_value(req.data, environment_chain, &cb, &opt).await?; let data = render_json_value(req.data, environment_chain, &cb, &opt).await?;
Ok(Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse { data }))) Ok(Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse { data })))

View File

@@ -17,7 +17,7 @@ use tauri::path::BaseDirectory;
use tauri::plugin::{Builder, TauriPlugin}; use tauri::plugin::{Builder, TauriPlugin};
use tauri::{ use tauri::{
AppHandle, Emitter, Manager, RunEvent, Runtime, State, WebviewWindow, WindowEvent, command, AppHandle, Emitter, Manager, RunEvent, Runtime, State, WebviewWindow, WindowEvent, command,
is_dev, generate_handler, is_dev,
}; };
use tokio::sync::Mutex; use tokio::sync::Mutex;
use ts_rs::TS; use ts_rs::TS;
@@ -132,7 +132,7 @@ impl PluginUpdater {
// ============================================================================ // ============================================================================
#[command] #[command]
pub async fn cmd_plugins_search<R: Runtime>( pub(crate) async fn cmd_plugins_search<R: Runtime>(
app_handle: AppHandle<R>, app_handle: AppHandle<R>,
query: &str, query: &str,
) -> Result<PluginSearchResponse> { ) -> Result<PluginSearchResponse> {
@@ -141,7 +141,7 @@ pub async fn cmd_plugins_search<R: Runtime>(
} }
#[command] #[command]
pub async fn cmd_plugins_install<R: Runtime>( pub(crate) async fn cmd_plugins_install<R: Runtime>(
window: WebviewWindow<R>, window: WebviewWindow<R>,
name: &str, name: &str,
version: Option<String>, version: Option<String>,
@@ -163,7 +163,7 @@ pub async fn cmd_plugins_install<R: Runtime>(
} }
#[command] #[command]
pub async fn cmd_plugins_uninstall<R: Runtime>( pub(crate) async fn cmd_plugins_uninstall<R: Runtime>(
plugin_id: &str, plugin_id: &str,
window: WebviewWindow<R>, window: WebviewWindow<R>,
) -> Result<Plugin> { ) -> Result<Plugin> {
@@ -174,7 +174,7 @@ pub async fn cmd_plugins_uninstall<R: Runtime>(
} }
#[command] #[command]
pub async fn cmd_plugins_updates<R: Runtime>( pub(crate) async fn cmd_plugins_updates<R: Runtime>(
app_handle: AppHandle<R>, app_handle: AppHandle<R>,
) -> Result<PluginUpdatesResponse> { ) -> Result<PluginUpdatesResponse> {
let http_client = yaak_api_client(&app_handle)?; let http_client = yaak_api_client(&app_handle)?;
@@ -183,7 +183,7 @@ pub async fn cmd_plugins_updates<R: Runtime>(
} }
#[command] #[command]
pub async fn cmd_plugins_update_all<R: Runtime>( pub(crate) async fn cmd_plugins_update_all<R: Runtime>(
window: WebviewWindow<R>, window: WebviewWindow<R>,
) -> Result<Vec<PluginNameVersion>> { ) -> Result<Vec<PluginNameVersion>> {
let http_client = yaak_api_client(window.app_handle())?; let http_client = yaak_api_client(window.app_handle())?;
@@ -233,6 +233,13 @@ pub async fn cmd_plugins_update_all<R: Runtime>(
pub fn init<R: Runtime>() -> TauriPlugin<R> { pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("yaak-plugins") Builder::new("yaak-plugins")
.invoke_handler(generate_handler![
cmd_plugins_search,
cmd_plugins_install,
cmd_plugins_uninstall,
cmd_plugins_updates,
cmd_plugins_update_all
])
.setup(|app_handle, _| { .setup(|app_handle, _| {
// Resolve paths for plugin manager // Resolve paths for plugin manager
let vendored_plugin_dir = app_handle let vendored_plugin_dir = app_handle

View File

@@ -3,7 +3,6 @@ use std::path::PathBuf;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crate::error::Result; use crate::error::Result;
use crate::models_ext::QueryManagerExt;
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::{Emitter, Listener, Manager, Runtime, WebviewWindow}; use tauri::{Emitter, Listener, Manager, Runtime, WebviewWindow};
@@ -12,6 +11,7 @@ use tauri_plugin_updater::{Update, UpdaterExt};
use tokio::task::block_in_place; use tokio::task::block_in_place;
use tokio::time::sleep; use tokio::time::sleep;
use ts_rs::TS; use ts_rs::TS;
use crate::models_ext::QueryManagerExt;
use yaak_models::util::generate_id; use yaak_models::util::generate_id;
use yaak_plugins::manager::PluginManager; use yaak_plugins::manager::PluginManager;

View File

@@ -1,18 +1,18 @@
use crate::PluginContextExt;
use crate::error::Result; use crate::error::Result;
use crate::import::import_data; use crate::import::import_data;
use crate::models_ext::QueryManagerExt; use crate::models_ext::QueryManagerExt;
use crate::PluginContextExt;
use log::{info, warn}; use log::{info, warn};
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
use std::sync::Arc; use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager, Runtime, Url}; use tauri::{AppHandle, Emitter, Manager, Runtime, Url};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use yaak_tauri_utils::api_client::yaak_api_client;
use yaak_models::util::generate_id; use yaak_models::util::generate_id;
use yaak_plugins::events::{Color, ShowToastRequest}; use yaak_plugins::events::{Color, ShowToastRequest};
use yaak_plugins::install::download_and_install; use yaak_plugins::install::download_and_install;
use yaak_plugins::manager::PluginManager; use yaak_plugins::manager::PluginManager;
use yaak_tauri_utils::api_client::yaak_api_client;
pub(crate) async fn handle_deep_link<R: Runtime>( pub(crate) async fn handle_deep_link<R: Runtime>(
app_handle: &AppHandle<R>, app_handle: &AppHandle<R>,
@@ -55,8 +55,7 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
&plugin_context, &plugin_context,
name, name,
version, version,
) ).await?;
.await?;
app_handle.emit( app_handle.emit(
"show_toast", "show_toast",
ShowToastRequest { ShowToastRequest {

View File

@@ -1,5 +1,4 @@
use crate::error::Result; use crate::error::Result;
use crate::models_ext::QueryManagerExt;
use crate::window_menu::app_menu; use crate::window_menu::app_menu;
use log::{info, warn}; use log::{info, warn};
use rand::random; use rand::random;
@@ -9,6 +8,7 @@ use tauri::{
}; };
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::models_ext::QueryManagerExt;
const DEFAULT_WINDOW_WIDTH: f64 = 1100.0; const DEFAULT_WINDOW_WIDTH: f64 = 1100.0;
const DEFAULT_WINDOW_HEIGHT: f64 = 600.0; const DEFAULT_WINDOW_HEIGHT: f64 = 600.0;

View File

@@ -1,9 +1,9 @@
//! WebSocket Tauri command wrappers //! WebSocket Tauri command wrappers
//! These wrap the core yaak-ws functionality for Tauri IPC. //! These wrap the core yaak-ws functionality for Tauri IPC.
use crate::PluginContextExt;
use crate::error::Result; use crate::error::Result;
use crate::models_ext::QueryManagerExt; use crate::models_ext::QueryManagerExt;
use crate::PluginContextExt;
use http::HeaderMap; use http::HeaderMap;
use log::{debug, info, warn}; use log::{debug, info, warn};
use std::str::FromStr; use std::str::FromStr;
@@ -56,10 +56,9 @@ pub async fn cmd_ws_delete_request<R: Runtime>(
app_handle: AppHandle<R>, app_handle: AppHandle<R>,
window: WebviewWindow<R>, window: WebviewWindow<R>,
) -> Result<WebsocketRequest> { ) -> Result<WebsocketRequest> {
Ok(app_handle.db().delete_websocket_request_by_id( Ok(app_handle
request_id, .db()
&UpdateSource::from_window_label(window.label()), .delete_websocket_request_by_id(request_id, &UpdateSource::from_window_label(window.label()))?)
)?)
} }
#[command] #[command]
@@ -68,10 +67,12 @@ pub async fn cmd_ws_delete_connection<R: Runtime>(
app_handle: AppHandle<R>, app_handle: AppHandle<R>,
window: WebviewWindow<R>, window: WebviewWindow<R>,
) -> Result<WebsocketConnection> { ) -> Result<WebsocketConnection> {
Ok(app_handle.db().delete_websocket_connection_by_id( Ok(app_handle
connection_id, .db()
&UpdateSource::from_window_label(window.label()), .delete_websocket_connection_by_id(
)?) connection_id,
&UpdateSource::from_window_label(window.label()),
)?)
} }
#[command] #[command]
@@ -295,10 +296,8 @@ pub async fn cmd_ws_connect<R: Runtime>(
) )
.await?; .await?;
for header in plugin_result.set_headers.unwrap_or_default() { for header in plugin_result.set_headers.unwrap_or_default() {
match ( match (http::HeaderName::from_str(&header.name), HeaderValue::from_str(&header.value))
http::HeaderName::from_str(&header.name), {
HeaderValue::from_str(&header.value),
) {
(Ok(name), Ok(value)) => { (Ok(name), Ok(value)) => {
headers.insert(name, value); headers.insert(name, value);
} }

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

@@ -8,10 +8,10 @@ use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow, is_dev}; use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow, is_dev};
use ts_rs::TS; use ts_rs::TS;
use yaak_common::platform::get_os_str; use yaak_common::platform::get_os_str;
use yaak_tauri_utils::api_client::yaak_api_client;
use yaak_models::db_context::DbContext; use yaak_models::db_context::DbContext;
use yaak_models::query_manager::QueryManager; use yaak_models::query_manager::QueryManager;
use yaak_models::util::UpdateSource; use yaak_models::util::UpdateSource;
use yaak_tauri_utils::api_client::yaak_api_client;
/// Extension trait for accessing the QueryManager from Tauri Manager types. /// Extension trait for accessing the QueryManager from Tauri Manager types.
/// This is needed temporarily until all crates are refactored to not use Tauri. /// This is needed temporarily until all crates are refactored to not use Tauri.

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,7 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, }; export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
@@ -20,4 +18,4 @@ export type SyncModel = { "type": "workspace" } & Workspace | { "type": "environ
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, }; export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, }; export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };

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());
@@ -340,9 +316,10 @@ impl GrpcHandle {
metadata: &BTreeMap<String, String>, metadata: &BTreeMap<String, String>,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
skip_cache: bool,
) -> Result<Vec<ServiceDefinition>> { ) -> Result<Vec<ServiceDefinition>> {
// Ensure we have a pool; reflect only if missing // Ensure we have a pool; reflect only if missing
if self.get_pool(id, uri, proto_files).is_none() { if skip_cache || self.get_pool(id, uri, proto_files).is_none() {
info!("Reflecting gRPC services for {} at {}", id, uri); info!("Reflecting gRPC services for {} at {}", id, uri);
self.reflect(id, uri, proto_files, metadata, validate_certificates, client_cert) self.reflect(id, uri, proto_files, metadata, validate_certificates, client_cert)
.await?; .await?;

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

@@ -2,8 +2,6 @@ use crate::dns::LocalhostResolver;
use crate::error::Result; use crate::error::Result;
use log::{debug, info, warn}; use log::{debug, info, warn};
use reqwest::{Client, Proxy, redirect}; use reqwest::{Client, Proxy, redirect};
use std::sync::Arc;
use yaak_models::models::DnsOverride;
use yaak_tls::{ClientCertificateConfig, get_tls_config}; use yaak_tls::{ClientCertificateConfig, get_tls_config};
#[derive(Clone)] #[derive(Clone)]
@@ -30,14 +28,10 @@ pub struct HttpConnectionOptions {
pub validate_certificates: bool, pub validate_certificates: bool,
pub proxy: HttpConnectionProxySetting, pub proxy: HttpConnectionProxySetting,
pub client_certificate: Option<ClientCertificateConfig>, pub client_certificate: Option<ClientCertificateConfig>,
pub dns_overrides: Vec<DnsOverride>,
} }
impl HttpConnectionOptions { impl HttpConnectionOptions {
/// Build a reqwest Client and return it along with the DNS resolver. pub(crate) fn build_client(&self) -> Result<Client> {
/// The resolver is returned separately so it can be configured per-request
/// to emit DNS timing events to the appropriate channel.
pub(crate) fn build_client(&self) -> Result<(Client, Arc<LocalhostResolver>)> {
let mut client = Client::builder() let mut client = Client::builder()
.connection_verbose(true) .connection_verbose(true)
.redirect(redirect::Policy::none()) .redirect(redirect::Policy::none())
@@ -46,19 +40,15 @@ impl HttpConnectionOptions {
.no_brotli() .no_brotli()
.no_deflate() .no_deflate()
.referer(false) .referer(false)
.tls_info(true) .tls_info(true);
// Disable connection pooling to ensure DNS resolution happens on each request
// This is needed so we can emit DNS timing events for each request
.pool_max_idle_per_host(0);
// Configure TLS with optional client certificate // Configure TLS with optional client certificate
let config = let config =
get_tls_config(self.validate_certificates, true, self.client_certificate.clone())?; get_tls_config(self.validate_certificates, true, self.client_certificate.clone())?;
client = client.use_preconfigured_tls(config); client = client.use_preconfigured_tls(config);
// Configure DNS resolver - keep a reference to configure per-request // Configure DNS resolver
let resolver = LocalhostResolver::new(self.dns_overrides.clone()); client = client.dns_resolver(LocalhostResolver::new());
client = client.dns_resolver(resolver.clone());
// Configure proxy // Configure proxy
match self.proxy.clone() { match self.proxy.clone() {
@@ -79,7 +69,7 @@ impl HttpConnectionOptions {
self.client_certificate.is_some() self.client_certificate.is_some()
); );
Ok((client.build()?, resolver)) Ok(client.build()?)
} }
} }

View File

@@ -1,185 +1,53 @@
use crate::sender::HttpResponseEvent;
use hyper_util::client::legacy::connect::dns::{ use hyper_util::client::legacy::connect::dns::{
GaiResolver as HyperGaiResolver, Name as HyperName, GaiResolver as HyperGaiResolver, Name as HyperName,
}; };
use log::info;
use reqwest::dns::{Addrs, Name, Resolve, Resolving}; use reqwest::dns::{Addrs, Name, Resolve, Resolving};
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::str::FromStr; use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{RwLock, mpsc};
use tower_service::Service; use tower_service::Service;
use yaak_models::models::DnsOverride;
/// Stores resolved addresses for a hostname override
#[derive(Clone)]
pub struct ResolvedOverride {
pub ipv4: Vec<Ipv4Addr>,
pub ipv6: Vec<Ipv6Addr>,
}
#[derive(Clone)] #[derive(Clone)]
pub struct LocalhostResolver { pub struct LocalhostResolver {
fallback: HyperGaiResolver, fallback: HyperGaiResolver,
event_tx: Arc<RwLock<Option<mpsc::Sender<HttpResponseEvent>>>>,
overrides: Arc<HashMap<String, ResolvedOverride>>,
} }
impl LocalhostResolver { impl LocalhostResolver {
pub fn new(dns_overrides: Vec<DnsOverride>) -> Arc<Self> { pub fn new() -> Arc<Self> {
let resolver = HyperGaiResolver::new(); let resolver = HyperGaiResolver::new();
Arc::new(Self { fallback: resolver })
// Pre-parse DNS overrides into a lookup map
let mut overrides = HashMap::new();
for o in dns_overrides {
if !o.enabled {
continue;
}
let hostname = o.hostname.to_lowercase();
let ipv4: Vec<Ipv4Addr> =
o.ipv4.iter().filter_map(|s| s.parse::<Ipv4Addr>().ok()).collect();
let ipv6: Vec<Ipv6Addr> =
o.ipv6.iter().filter_map(|s| s.parse::<Ipv6Addr>().ok()).collect();
// Only add if at least one address is valid
if !ipv4.is_empty() || !ipv6.is_empty() {
overrides.insert(hostname, ResolvedOverride { ipv4, ipv6 });
}
}
Arc::new(Self {
fallback: resolver,
event_tx: Arc::new(RwLock::new(None)),
overrides: Arc::new(overrides),
})
}
/// Set the event sender for the current request.
/// This should be called before each request to direct DNS events
/// to the appropriate channel.
pub async fn set_event_sender(&self, tx: Option<mpsc::Sender<HttpResponseEvent>>) {
let mut guard = self.event_tx.write().await;
*guard = tx;
} }
} }
impl Resolve for LocalhostResolver { impl Resolve for LocalhostResolver {
fn resolve(&self, name: Name) -> Resolving { fn resolve(&self, name: Name) -> Resolving {
let host = name.as_str().to_lowercase(); let host = name.as_str().to_lowercase();
let event_tx = self.event_tx.clone();
let overrides = self.overrides.clone();
info!("DNS resolve called for: {}", host);
// Check for DNS override first
if let Some(resolved) = overrides.get(&host) {
log::debug!("DNS override found for: {}", host);
let hostname = host.clone();
let mut addrs: Vec<SocketAddr> = Vec::new();
// Add IPv4 addresses
for ip in &resolved.ipv4 {
addrs.push(SocketAddr::new(IpAddr::V4(*ip), 0));
}
// Add IPv6 addresses
for ip in &resolved.ipv6 {
addrs.push(SocketAddr::new(IpAddr::V6(*ip), 0));
}
let addresses: Vec<String> = addrs.iter().map(|a| a.ip().to_string()).collect();
return Box::pin(async move {
// Emit DNS event for override
let guard = event_tx.read().await;
if let Some(tx) = guard.as_ref() {
let _ = tx
.send(HttpResponseEvent::DnsResolved {
hostname,
addresses,
duration: 0,
overridden: true,
})
.await;
}
Ok::<Addrs, Box<dyn std::error::Error + Send + Sync>>(Box::new(addrs.into_iter()))
});
}
// Check for .localhost suffix
let is_localhost = host.ends_with(".localhost"); let is_localhost = host.ends_with(".localhost");
if is_localhost { if is_localhost {
let hostname = host.clone();
// Port 0 is fine; reqwest replaces it with the URL's explicit // Port 0 is fine; reqwest replaces it with the URL's explicit
// port or the scheme's default (80/443, etc.). // port or the schemes default (80/443, etc.).
// (See docs note below.)
let addrs: Vec<SocketAddr> = vec![ let addrs: Vec<SocketAddr> = vec![
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0), SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0), SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0),
]; ];
let addresses: Vec<String> = addrs.iter().map(|a| a.ip().to_string()).collect();
return Box::pin(async move { return Box::pin(async move {
// Emit DNS event for localhost resolution
let guard = event_tx.read().await;
if let Some(tx) = guard.as_ref() {
let _ = tx
.send(HttpResponseEvent::DnsResolved {
hostname,
addresses,
duration: 0,
overridden: false,
})
.await;
}
Ok::<Addrs, Box<dyn std::error::Error + Send + Sync>>(Box::new(addrs.into_iter())) Ok::<Addrs, Box<dyn std::error::Error + Send + Sync>>(Box::new(addrs.into_iter()))
}); });
} }
// Fall back to system DNS
let mut fallback = self.fallback.clone(); let mut fallback = self.fallback.clone();
let name_str = name.as_str().to_string(); let name_str = name.as_str().to_string();
let hostname = host.clone();
Box::pin(async move { Box::pin(async move {
let start = Instant::now(); match HyperName::from_str(&name_str) {
Ok(n) => fallback
let result = match HyperName::from_str(&name_str) { .call(n)
Ok(n) => fallback.call(n).await, .await
Err(e) => return Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>), .map(|addrs| Box::new(addrs) as Addrs)
}; .map_err(|err| Box::new(err) as Box<dyn std::error::Error + Send + Sync>),
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>),
let duration = start.elapsed().as_millis() as u64;
match result {
Ok(addrs) => {
// Collect addresses for event emission
let addr_vec: Vec<SocketAddr> = addrs.collect();
let addresses: Vec<String> =
addr_vec.iter().map(|a| a.ip().to_string()).collect();
// Emit DNS event
let guard = event_tx.read().await;
if let Some(tx) = guard.as_ref() {
let _ = tx
.send(HttpResponseEvent::DnsResolved {
hostname,
addresses,
duration,
overridden: false,
})
.await;
}
Ok(Box::new(addr_vec.into_iter()) as Addrs)
}
Err(err) => Err(Box::new(err) as Box<dyn std::error::Error + Send + Sync>),
} }
}) })
} }

View File

@@ -1,5 +1,4 @@
use crate::client::HttpConnectionOptions; use crate::client::HttpConnectionOptions;
use crate::dns::LocalhostResolver;
use crate::error::Result; use crate::error::Result;
use log::info; use log::info;
use reqwest::Client; use reqwest::Client;
@@ -8,15 +7,8 @@ use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio::sync::RwLock; use tokio::sync::RwLock;
/// A cached HTTP client along with its DNS resolver.
/// The resolver is needed to set the event sender per-request.
pub struct CachedClient {
pub client: Client,
pub resolver: Arc<LocalhostResolver>,
}
pub struct HttpConnectionManager { pub struct HttpConnectionManager {
connections: Arc<RwLock<BTreeMap<String, (CachedClient, Instant)>>>, connections: Arc<RwLock<BTreeMap<String, (Client, Instant)>>>,
ttl: Duration, ttl: Duration,
} }
@@ -28,26 +20,21 @@ impl HttpConnectionManager {
} }
} }
pub async fn get_client(&self, opt: &HttpConnectionOptions) -> Result<CachedClient> { pub async fn get_client(&self, opt: &HttpConnectionOptions) -> Result<Client> {
let mut connections = self.connections.write().await; let mut connections = self.connections.write().await;
let id = opt.id.clone(); let id = opt.id.clone();
// Clean old connections // Clean old connections
connections.retain(|_, (_, last_used)| last_used.elapsed() <= self.ttl); connections.retain(|_, (_, last_used)| last_used.elapsed() <= self.ttl);
if let Some((cached, last_used)) = connections.get_mut(&id) { if let Some((c, last_used)) = connections.get_mut(&id) {
info!("Re-using HTTP client {id}"); info!("Re-using HTTP client {id}");
*last_used = Instant::now(); *last_used = Instant::now();
return Ok(CachedClient { return Ok(c.clone());
client: cached.client.clone(),
resolver: cached.resolver.clone(),
});
} }
let (client, resolver) = opt.build_client()?; let c = opt.build_client()?;
let cached = CachedClient { client: client.clone(), resolver: resolver.clone() }; connections.insert(id.into(), (c.clone(), Instant::now()));
connections.insert(id.into(), (cached, Instant::now())); Ok(c)
Ok(CachedClient { client, resolver })
} }
} }

View File

@@ -45,12 +45,6 @@ pub enum HttpResponseEvent {
ChunkReceived { ChunkReceived {
bytes: usize, bytes: usize,
}, },
DnsResolved {
hostname: String,
addresses: Vec<String>,
duration: u64,
overridden: bool,
},
} }
impl Display for HttpResponseEvent { impl Display for HttpResponseEvent {
@@ -73,19 +67,6 @@ impl Display for HttpResponseEvent {
HttpResponseEvent::HeaderDown(name, value) => write!(f, "< {}: {}", name, value), HttpResponseEvent::HeaderDown(name, value) => write!(f, "< {}: {}", name, value),
HttpResponseEvent::ChunkSent { bytes } => write!(f, "> [{} bytes sent]", bytes), HttpResponseEvent::ChunkSent { bytes } => write!(f, "> [{} bytes sent]", bytes),
HttpResponseEvent::ChunkReceived { bytes } => write!(f, "< [{} bytes received]", bytes), HttpResponseEvent::ChunkReceived { bytes } => write!(f, "< [{} bytes received]", bytes),
HttpResponseEvent::DnsResolved { hostname, addresses, duration, overridden } => {
if *overridden {
write!(f, "* DNS override {} -> {}", hostname, addresses.join(", "))
} else {
write!(
f,
"* DNS resolved {} to {} ({}ms)",
hostname,
addresses.join(", "),
duration
)
}
}
} }
} }
} }
@@ -112,9 +93,6 @@ impl From<HttpResponseEvent> for yaak_models::models::HttpResponseEventData {
HttpResponseEvent::HeaderDown(name, value) => D::HeaderDown { name, value }, HttpResponseEvent::HeaderDown(name, value) => D::HeaderDown { name, value },
HttpResponseEvent::ChunkSent { bytes } => D::ChunkSent { bytes }, HttpResponseEvent::ChunkSent { bytes } => D::ChunkSent { bytes },
HttpResponseEvent::ChunkReceived { bytes } => D::ChunkReceived { bytes }, HttpResponseEvent::ChunkReceived { bytes } => D::ChunkReceived { bytes },
HttpResponseEvent::DnsResolved { hostname, addresses, duration, overridden } => {
D::DnsResolved { hostname, addresses, duration, overridden }
}
} }
} }
} }
@@ -376,9 +354,6 @@ impl HttpSender for ReqwestSender {
// Add headers // Add headers
for header in request.headers { for header in request.headers {
if header.0.is_empty() {
continue;
}
req_builder = req_builder.header(&header.0, &header.1); req_builder = req_builder.header(&header.0, &header.1);
} }

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

@@ -12,8 +12,6 @@ export type CookieExpires = { "AtUtc": string } | "SessionEnd";
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, }; export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs"; export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
export type EncryptedKey = { encryptedKey: string, }; export type EncryptedKey = { encryptedKey: string, };
@@ -40,7 +38,7 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, }; export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
@@ -49,7 +47,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support. * This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies. * The `From` impl is in yaak-http to avoid circular dependencies.
*/ */
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, }; export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, };
export type HttpResponseHeader = { name: string, value: string, }; export type HttpResponseHeader = { name: string, value: string, };
@@ -93,6 +91,6 @@ export type WebsocketMessageType = "text" | "binary";
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, }; export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, }; export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, }; export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };

View File

@@ -206,34 +206,6 @@ export function replaceModelsInStore<
}); });
} }
export function mergeModelsInStore<
M extends AnyModel['model'],
T extends Extract<AnyModel, { model: M }>,
>(model: M, models: T[], filter?: (model: T) => boolean) {
mustStore().set(modelStoreDataAtom, (prev: ModelStoreData) => {
const existingModels = { ...prev[model] } as Record<string, T>;
// Merge in new models first
for (const m of models) {
existingModels[m.id] = m;
}
// Then filter out unwanted models
if (filter) {
for (const [id, m] of Object.entries(existingModels)) {
if (!filter(m)) {
delete existingModels[id];
}
}
}
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

@@ -1,2 +0,0 @@
-- Add DNS resolution timing to http_responses
ALTER TABLE http_responses ADD COLUMN elapsed_dns INTEGER DEFAULT 0 NOT NULL;

View File

@@ -1,2 +0,0 @@
-- Add DNS overrides setting to workspaces
ALTER TABLE workspaces ADD COLUMN setting_dns_overrides TEXT DEFAULT '[]' NOT NULL;

View File

@@ -1,12 +0,0 @@
-- Filter out headers that match the hardcoded defaults (User-Agent: yaak, Accept: */*),
-- keeping any other custom headers the user may have added.
UPDATE workspaces
SET headers = (
SELECT json_group_array(json(value))
FROM json_each(headers)
WHERE NOT (
(LOWER(json_extract(value, '$.name')) = 'user-agent' AND json_extract(value, '$.value') = 'yaak')
OR (LOWER(json_extract(value, '$.name')) = 'accept' AND json_extract(value, '$.value') = '*/*')
)
)
WHERE json_array_length(headers) > 0;

View File

@@ -73,20 +73,6 @@ pub struct ClientCertificate {
pub enabled: bool, pub enabled: bool,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct DnsOverride {
pub hostname: String,
#[serde(default)]
pub ipv4: Vec<String>,
#[serde(default)]
pub ipv6: Vec<String>,
#[serde(default = "default_true")]
#[ts(optional, as = "Option<bool>")]
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)] #[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_models.ts")] #[ts(export, export_to = "gen_models.ts")]
@@ -317,8 +303,6 @@ pub struct Workspace {
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub setting_follow_redirects: bool, pub setting_follow_redirects: bool,
pub setting_request_timeout: i32, pub setting_request_timeout: i32,
#[serde(default)]
pub setting_dns_overrides: Vec<DnsOverride>,
} }
impl UpsertModelInfo for Workspace { impl UpsertModelInfo for Workspace {
@@ -359,7 +343,6 @@ impl UpsertModelInfo for Workspace {
(SettingFollowRedirects, self.setting_follow_redirects.into()), (SettingFollowRedirects, self.setting_follow_redirects.into()),
(SettingRequestTimeout, self.setting_request_timeout.into()), (SettingRequestTimeout, self.setting_request_timeout.into()),
(SettingValidateCertificates, self.setting_validate_certificates.into()), (SettingValidateCertificates, self.setting_validate_certificates.into()),
(SettingDnsOverrides, serde_json::to_string(&self.setting_dns_overrides)?.into()),
]) ])
} }
@@ -376,7 +359,6 @@ impl UpsertModelInfo for Workspace {
WorkspaceIden::SettingFollowRedirects, WorkspaceIden::SettingFollowRedirects,
WorkspaceIden::SettingRequestTimeout, WorkspaceIden::SettingRequestTimeout,
WorkspaceIden::SettingValidateCertificates, WorkspaceIden::SettingValidateCertificates,
WorkspaceIden::SettingDnsOverrides,
] ]
} }
@@ -386,7 +368,6 @@ impl UpsertModelInfo for Workspace {
{ {
let headers: String = row.get("headers")?; let headers: String = row.get("headers")?;
let authentication: String = row.get("authentication")?; let authentication: String = row.get("authentication")?;
let setting_dns_overrides: String = row.get("setting_dns_overrides")?;
Ok(Self { Ok(Self {
id: row.get("id")?, id: row.get("id")?,
model: row.get("model")?, model: row.get("model")?,
@@ -401,7 +382,6 @@ impl UpsertModelInfo for Workspace {
setting_follow_redirects: row.get("setting_follow_redirects")?, setting_follow_redirects: row.get("setting_follow_redirects")?,
setting_request_timeout: row.get("setting_request_timeout")?, setting_request_timeout: row.get("setting_request_timeout")?,
setting_validate_certificates: row.get("setting_validate_certificates")?, setting_validate_certificates: row.get("setting_validate_certificates")?,
setting_dns_overrides: serde_json::from_str(&setting_dns_overrides).unwrap_or_default(),
}) })
} }
} }
@@ -1353,7 +1333,6 @@ pub struct HttpResponse {
pub content_length_compressed: Option<i32>, pub content_length_compressed: Option<i32>,
pub elapsed: i32, pub elapsed: i32,
pub elapsed_headers: i32, pub elapsed_headers: i32,
pub elapsed_dns: i32,
pub error: Option<String>, pub error: Option<String>,
pub headers: Vec<HttpResponseHeader>, pub headers: Vec<HttpResponseHeader>,
pub remote_addr: Option<String>, pub remote_addr: Option<String>,
@@ -1402,7 +1381,6 @@ impl UpsertModelInfo for HttpResponse {
(ContentLengthCompressed, self.content_length_compressed.into()), (ContentLengthCompressed, self.content_length_compressed.into()),
(Elapsed, self.elapsed.into()), (Elapsed, self.elapsed.into()),
(ElapsedHeaders, self.elapsed_headers.into()), (ElapsedHeaders, self.elapsed_headers.into()),
(ElapsedDns, self.elapsed_dns.into()),
(Error, self.error.into()), (Error, self.error.into()),
(Headers, serde_json::to_string(&self.headers)?.into()), (Headers, serde_json::to_string(&self.headers)?.into()),
(RemoteAddr, self.remote_addr.into()), (RemoteAddr, self.remote_addr.into()),
@@ -1424,7 +1402,6 @@ impl UpsertModelInfo for HttpResponse {
HttpResponseIden::ContentLengthCompressed, HttpResponseIden::ContentLengthCompressed,
HttpResponseIden::Elapsed, HttpResponseIden::Elapsed,
HttpResponseIden::ElapsedHeaders, HttpResponseIden::ElapsedHeaders,
HttpResponseIden::ElapsedDns,
HttpResponseIden::Error, HttpResponseIden::Error,
HttpResponseIden::Headers, HttpResponseIden::Headers,
HttpResponseIden::RemoteAddr, HttpResponseIden::RemoteAddr,
@@ -1458,7 +1435,6 @@ impl UpsertModelInfo for HttpResponse {
version: r.get("version")?, version: r.get("version")?,
elapsed: r.get("elapsed")?, elapsed: r.get("elapsed")?,
elapsed_headers: r.get("elapsed_headers")?, elapsed_headers: r.get("elapsed_headers")?,
elapsed_dns: r.get("elapsed_dns").unwrap_or_default(),
remote_addr: r.get("remote_addr")?, remote_addr: r.get("remote_addr")?,
status: r.get("status")?, status: r.get("status")?,
status_reason: r.get("status_reason")?, status_reason: r.get("status_reason")?,
@@ -1515,12 +1491,6 @@ pub enum HttpResponseEventData {
ChunkReceived { ChunkReceived {
bytes: usize, bytes: usize,
}, },
DnsResolved {
hostname: String,
addresses: Vec<String>,
duration: u64,
overridden: bool,
},
} }
impl Default for HttpResponseEventData { impl Default for HttpResponseEventData {

View File

@@ -1,4 +1,3 @@
use super::dedupe_headers;
use crate::db_context::DbContext; use crate::db_context::DbContext;
use crate::error::Result; use crate::error::Result;
use crate::models::{GrpcRequest, GrpcRequestIden, HttpRequestHeader}; use crate::models::{GrpcRequest, GrpcRequestIden, HttpRequestHeader};
@@ -88,6 +87,6 @@ impl<'a> DbContext<'a> {
metadata.append(&mut grpc_request.metadata.clone()); metadata.append(&mut grpc_request.metadata.clone());
Ok(dedupe_headers(metadata)) Ok(metadata)
} }
} }

View File

@@ -1,4 +1,3 @@
use super::dedupe_headers;
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};
@@ -88,7 +87,7 @@ impl<'a> DbContext<'a> {
headers.append(&mut http_request.headers.clone()); headers.append(&mut http_request.headers.clone());
Ok(dedupe_headers(headers)) Ok(headers)
} }
pub fn list_http_requests_for_folder_recursive( pub fn list_http_requests_for_folder_recursive(

View File

@@ -19,26 +19,6 @@ mod websocket_connections;
mod websocket_events; mod websocket_events;
mod websocket_requests; mod websocket_requests;
mod workspace_metas; mod workspace_metas;
pub mod workspaces; mod workspaces;
const MAX_HISTORY_ITEMS: usize = 20; const MAX_HISTORY_ITEMS: usize = 20;
use crate::models::HttpRequestHeader;
use std::collections::HashMap;
/// Deduplicate headers by name (case-insensitive), keeping the latest (most specific) value.
/// Preserves the order of first occurrence for each header name.
pub(crate) fn dedupe_headers(headers: Vec<HttpRequestHeader>) -> Vec<HttpRequestHeader> {
let mut index_by_name: HashMap<String, usize> = HashMap::new();
let mut deduped: Vec<HttpRequestHeader> = Vec::new();
for header in headers {
let key = header.name.to_lowercase();
if let Some(&idx) = index_by_name.get(&key) {
deduped[idx] = header;
} else {
index_by_name.insert(key, deduped.len());
deduped.push(header);
}
}
deduped
}

View File

@@ -1,4 +1,3 @@
use super::dedupe_headers;
use crate::db_context::DbContext; use crate::db_context::DbContext;
use crate::error::Result; use crate::error::Result;
use crate::models::{HttpRequestHeader, WebsocketRequest, WebsocketRequestIden}; use crate::models::{HttpRequestHeader, WebsocketRequest, WebsocketRequestIden};
@@ -96,6 +95,6 @@ impl<'a> DbContext<'a> {
headers.append(&mut websocket_request.headers.clone()); headers.append(&mut websocket_request.headers.clone());
Ok(dedupe_headers(headers)) Ok(headers)
} }
} }

View File

@@ -80,28 +80,6 @@ impl<'a> DbContext<'a> {
} }
pub fn resolve_headers_for_workspace(&self, workspace: &Workspace) -> Vec<HttpRequestHeader> { pub fn resolve_headers_for_workspace(&self, workspace: &Workspace) -> Vec<HttpRequestHeader> {
let mut headers = default_headers(); workspace.headers.clone()
headers.extend(workspace.headers.clone());
headers
} }
} }
/// Global default headers that are always sent with requests unless overridden.
/// These are prepended to the inheritance chain so workspace/folder/request headers
/// can override or disable them.
pub fn default_headers() -> Vec<HttpRequestHeader> {
vec![
HttpRequestHeader {
enabled: true,
name: "User-Agent".to_string(),
value: "yaak".to_string(),
id: None,
},
HttpRequestHeader {
enabled: true,
name: "Accept".to_string(),
value: "*/*".to_string(),
id: None,
},
]
}

View File

@@ -12,8 +12,6 @@ export type CookieExpires = { "AtUtc": string } | "SessionEnd";
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, }; export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs"; export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
export type EncryptedKey = { encryptedKey: string, }; export type EncryptedKey = { encryptedKey: string, };
@@ -40,7 +38,7 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, }; export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
@@ -49,7 +47,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support. * This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies. * The `From` impl is in yaak-http to avoid circular dependencies.
*/ */
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, }; export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, };
export type HttpResponseHeader = { name: string, value: string, }; export type HttpResponseHeader = { name: string, value: string, };
@@ -79,6 +77,6 @@ export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping"
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, }; export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, }; export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, }; export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };

View File

@@ -80,7 +80,10 @@ pub async fn check_plugin_updates(
} }
/// Search for plugins in the registry. /// Search for plugins in the registry.
pub async fn search_plugins(http_client: &Client, query: &str) -> Result<PluginSearchResponse> { pub async fn search_plugins(
http_client: &Client,
query: &str,
) -> Result<PluginSearchResponse> {
let mut url = build_url("/search"); let mut url = build_url("/search");
{ {
let mut query_pairs = url.query_pairs_mut(); let mut query_pairs = url.query_pairs_mut();

View File

@@ -378,8 +378,7 @@ impl PluginManager {
plugins: Vec<PluginHandle>, plugins: Vec<PluginHandle>,
timeout_duration: Duration, timeout_duration: Duration,
) -> Result<Vec<InternalEvent>> { ) -> Result<Vec<InternalEvent>> {
let event_type = payload.type_name(); let label = format!("wait[{}.{}]", plugins.len(), payload.type_name());
let label = format!("wait[{}.{}]", plugins.len(), event_type);
let (rx_id, mut rx) = self.subscribe(label.as_str()).await; let (rx_id, mut rx) = self.subscribe(label.as_str()).await;
// 1. Build the events with IDs and everything // 1. Build the events with IDs and everything
@@ -413,21 +412,10 @@ impl PluginManager {
// Timeout to prevent hanging forever if plugin doesn't respond // Timeout to prevent hanging forever if plugin doesn't respond
if timeout(timeout_duration, collect_events).await.is_err() { if timeout(timeout_duration, collect_events).await.is_err() {
let responded_ids: Vec<&String> =
found_events.iter().filter_map(|e| e.reply_id.as_ref()).collect();
let non_responding: Vec<&str> = events_to_send
.iter()
.filter(|e| !responded_ids.contains(&&e.id))
.map(|e| e.plugin_name.as_str())
.collect();
warn!( warn!(
"Timeout ({:?}) waiting for {} responses. Got {}/{} responses. \ "Timeout waiting for plugin responses. Got {}/{} responses",
Non-responding plugins: [{}]",
timeout_duration,
event_type,
found_events.len(), found_events.len(),
events_to_send.len(), events_to_send.len()
non_responding.join(", ")
); );
} }

View File

@@ -196,11 +196,7 @@ pub fn decrypt_secure_template_function(
} }
} }
new_tokens.push(Token::Raw { new_tokens.push(Token::Raw {
text: template_function_secure_run( text: template_function_secure_run(encryption_manager, args_map, plugin_context)?,
encryption_manager,
args_map,
plugin_context,
)?,
}); });
} }
t => { t => {
@@ -220,8 +216,7 @@ pub fn encrypt_secure_template_function(
plugin_context: &PluginContext, plugin_context: &PluginContext,
template: &str, template: &str,
) -> Result<String> { ) -> Result<String> {
let decrypted = let decrypted = decrypt_secure_template_function(&encryption_manager, plugin_context, template)?;
decrypt_secure_template_function(&encryption_manager, plugin_context, template)?;
let tokens = Tokens { let tokens = Tokens {
tokens: vec![Token::Tag { tokens: vec![Token::Tag {
val: Val::Fn { val: Val::Fn {
@@ -236,12 +231,7 @@ pub fn encrypt_secure_template_function(
Ok(transform_args( Ok(transform_args(
tokens, tokens,
&PluginTemplateCallback::new( &PluginTemplateCallback::new(plugin_manager, encryption_manager, plugin_context, RenderPurpose::Preview),
plugin_manager,
encryption_manager,
plugin_context,
RenderPurpose::Preview,
),
)? )?
.to_string()) .to_string())
} }

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

View File

@@ -46,11 +46,7 @@ impl TemplateCallback for PluginTemplateCallback {
let fn_name = if fn_name == "Response" { "response" } else { fn_name }; let fn_name = if fn_name == "Response" { "response" } else { fn_name };
if fn_name == "secure" { if fn_name == "secure" {
return template_function_secure_run( return template_function_secure_run(&self.encryption_manager, args, &self.plugin_context);
&self.encryption_manager,
args,
&self.plugin_context,
);
} else if fn_name == "keychain" || fn_name == "keyring" { } else if fn_name == "keychain" || fn_name == "keyring" {
return template_function_keychain_run(args); return template_function_keychain_run(args);
} }
@@ -60,8 +56,7 @@ impl TemplateCallback for PluginTemplateCallback {
primitive_args.insert(key, JsonPrimitive::from(value)); primitive_args.insert(key, JsonPrimitive::from(value));
} }
let resp = self let resp = self.plugin_manager
.plugin_manager
.call_template_function( .call_template_function(
&self.plugin_context, &self.plugin_context,
fn_name, fn_name,

View File

@@ -1,7 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, }; export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
@@ -22,4 +20,4 @@ export type SyncState = { model: "sync_state", id: string, workspaceId: string,
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, }; export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, }; export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };

View File

@@ -296,7 +296,11 @@ pub fn compute_sync_ops(
.collect() .collect()
} }
fn workspace_models(db: &DbContext, version: &str, workspace_id: &str) -> Result<Vec<SyncModel>> { fn workspace_models(
db: &DbContext,
version: &str,
workspace_id: &str,
) -> Result<Vec<SyncModel>> {
// We want to include private environments here so that we can take them into account during // We want to include private environments here so that we can take them into account during
// the sync process. Otherwise, they would be treated as deleted. // the sync process. Otherwise, they would be treated as deleted.
let include_private_environments = true; let include_private_environments = true;

View File

@@ -2,7 +2,6 @@ use crate::connect::ws_connect;
use crate::error::Result; use crate::error::Result;
use futures_util::stream::SplitSink; use futures_util::stream::SplitSink;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use http::HeaderMap;
use log::{debug, info, warn}; use log::{debug, info, warn};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
@@ -11,6 +10,7 @@ use tokio::net::TcpStream;
use tokio::sync::{Mutex, mpsc}; use tokio::sync::{Mutex, mpsc};
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::handshake::client::Response; use tokio_tungstenite::tungstenite::handshake::client::Response;
use http::HeaderMap;
use tokio_tungstenite::tungstenite::http::HeaderValue; use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
use yaak_tls::ClientCertificateConfig; use yaak_tls::ClientCertificateConfig;

8
package-lock.json generated
View File

@@ -7811,9 +7811,9 @@
} }
}, },
"node_modules/hono": { "node_modules/hono": {
"version": "4.11.4", "version": "4.11.3",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.3.tgz",
"integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", "integrity": "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=16.9.0" "node": ">=16.9.0"
@@ -15743,7 +15743,7 @@
"@hono/mcp": "^0.2.3", "@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.7", "@hono/node-server": "^1.19.7",
"@modelcontextprotocol/sdk": "^1.25.2", "@modelcontextprotocol/sdk": "^1.25.2",
"hono": "^4.11.4", "hono": "^4.11.3",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -17,7 +17,7 @@ npx @yaakapp/cli generate
``` ```
For more details on creating plugins, check out For more details on creating plugins, check out
the [Quick Start Guide](https://yaak.app/docs/plugin-development/plugins-quick-start) the [Quick Start Guide](https://feedback.yaak.app/help/articles/6911763-plugins-quick-start)
## Installation ## Installation

View File

@@ -12,8 +12,6 @@ export type CookieExpires = { "AtUtc": string } | "SessionEnd";
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, }; export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs"; export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
export type EncryptedKey = { encryptedKey: string, }; export type EncryptedKey = { encryptedKey: string, };
@@ -40,7 +38,7 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, }; export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
@@ -49,7 +47,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support. * This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies. * The `From` impl is in yaak-http to avoid circular dependencies.
*/ */
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, }; export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, };
export type HttpResponseHeader = { name: string, value: string, }; export type HttpResponseHeader = { name: string, value: string, };
@@ -79,6 +77,6 @@ export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping"
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, }; export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, }; export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, }; export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };

View File

@@ -18,7 +18,7 @@
"@hono/mcp": "^0.2.3", "@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.7", "@hono/node-server": "^1.19.7",
"@modelcontextprotocol/sdk": "^1.25.2", "@modelcontextprotocol/sdk": "^1.25.2",
"hono": "^4.11.4", "hono": "^4.11.3",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,181 +0,0 @@
import type { DnsOverride, Workspace } from '@yaakapp-internal/models';
import { patchModel } from '@yaakapp-internal/models';
import { useCallback, useId, useMemo } from 'react';
import { Button } from './core/Button';
import { Checkbox } from './core/Checkbox';
import { IconButton } from './core/IconButton';
import { PlainInput } from './core/PlainInput';
import { HStack, VStack } from './core/Stacks';
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from './core/Table';
interface Props {
workspace: Workspace;
}
interface DnsOverrideWithId extends DnsOverride {
_id: string;
}
export function DnsOverridesEditor({ workspace }: Props) {
const reactId = useId();
// Ensure each override has an internal ID for React keys
const overridesWithIds = useMemo<DnsOverrideWithId[]>(() => {
return workspace.settingDnsOverrides.map((override, index) => ({
...override,
_id: `${reactId}-${index}`,
}));
}, [workspace.settingDnsOverrides, reactId]);
const handleChange = useCallback(
(overrides: DnsOverride[]) => {
patchModel(workspace, { settingDnsOverrides: overrides });
},
[workspace],
);
const handleAdd = useCallback(() => {
const newOverride: DnsOverride = {
hostname: '',
ipv4: [''],
ipv6: [],
enabled: true,
};
handleChange([...workspace.settingDnsOverrides, newOverride]);
}, [workspace.settingDnsOverrides, handleChange]);
const handleUpdate = useCallback(
(index: number, update: Partial<DnsOverride>) => {
const updated = workspace.settingDnsOverrides.map((o, i) =>
i === index ? { ...o, ...update } : o,
);
handleChange(updated);
},
[workspace.settingDnsOverrides, handleChange],
);
const handleDelete = useCallback(
(index: number) => {
const updated = workspace.settingDnsOverrides.filter((_, i) => i !== index);
handleChange(updated);
},
[workspace.settingDnsOverrides, handleChange],
);
return (
<VStack space={3} className="pb-3">
<div className="text-text-subtle text-sm">
Override DNS resolution for specific hostnames. This works like{' '}
<code className="text-text-subtlest bg-surface-highlight px-1 rounded">/etc/hosts</code>{' '}
but only for requests made from this workspace.
</div>
{overridesWithIds.length > 0 && (
<Table>
<TableHead>
<TableRow>
<TableHeaderCell className="w-8" />
<TableHeaderCell>Hostname</TableHeaderCell>
<TableHeaderCell>IPv4 Address</TableHeaderCell>
<TableHeaderCell>IPv6 Address</TableHeaderCell>
<TableHeaderCell className="w-10" />
</TableRow>
</TableHead>
<TableBody>
{overridesWithIds.map((override, index) => (
<DnsOverrideRow
key={override._id}
override={override}
onUpdate={(update) => handleUpdate(index, update)}
onDelete={() => handleDelete(index)}
/>
))}
</TableBody>
</Table>
)}
<HStack>
<Button size="xs" color="secondary" variant="border" onClick={handleAdd}>
Add DNS Override
</Button>
</HStack>
</VStack>
);
}
interface DnsOverrideRowProps {
override: DnsOverride;
onUpdate: (update: Partial<DnsOverride>) => void;
onDelete: () => void;
}
function DnsOverrideRow({ override, onUpdate, onDelete }: DnsOverrideRowProps) {
const ipv4Value = override.ipv4.join(', ');
const ipv6Value = override.ipv6.join(', ');
return (
<TableRow>
<TableCell>
<Checkbox
hideLabel
title={override.enabled ? 'Disable override' : 'Enable override'}
checked={override.enabled ?? true}
onChange={(enabled) => onUpdate({ enabled })}
/>
</TableCell>
<TableCell>
<PlainInput
size="sm"
hideLabel
label="Hostname"
placeholder="api.example.com"
defaultValue={override.hostname}
onChange={(hostname) => onUpdate({ hostname })}
/>
</TableCell>
<TableCell>
<PlainInput
size="sm"
hideLabel
label="IPv4 addresses"
placeholder="127.0.0.1"
defaultValue={ipv4Value}
onChange={(value) =>
onUpdate({
ipv4: value
.split(',')
.map((s) => s.trim())
.filter(Boolean),
})
}
/>
</TableCell>
<TableCell>
<PlainInput
size="sm"
hideLabel
label="IPv6 addresses"
placeholder="::1"
defaultValue={ipv6Value}
onChange={(value) =>
onUpdate({
ipv6: value
.split(',')
.map((s) => s.trim())
.filter(Boolean),
})
}
/>
</TableCell>
<TableCell>
<IconButton
size="xs"
iconSize="sm"
icon="trash"
title="Delete override"
onClick={onDelete}
/>
</TableCell>
</TableRow>
);
}

View File

@@ -1,6 +1,6 @@
import { createWorkspaceModel, foldersAtom, patchModel } from '@yaakapp-internal/models'; import { createWorkspaceModel, foldersAtom, patchModel } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { useAuthTab } from '../hooks/useAuthTab'; import { useAuthTab } from '../hooks/useAuthTab';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown'; import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { useHeadersTab } from '../hooks/useHeadersTab'; import { useHeadersTab } from '../hooks/useHeadersTab';
@@ -37,6 +37,7 @@ export type FolderSettingsTab =
export function FolderSettingsDialog({ folderId, tab }: Props) { export function FolderSettingsDialog({ folderId, tab }: Props) {
const folders = useAtomValue(foldersAtom); const folders = useAtomValue(foldersAtom);
const folder = folders.find((f) => f.id === folderId) ?? null; const folder = folders.find((f) => f.id === folderId) ?? null;
const [activeTab, setActiveTab] = useState<string>(tab ?? TAB_GENERAL);
const authTab = useAuthTab(TAB_AUTH, folder); const authTab = useAuthTab(TAB_AUTH, folder);
const headersTab = useHeadersTab(TAB_HEADERS, folder); const headersTab = useHeadersTab(TAB_HEADERS, folder);
const inheritedHeaders = useInheritedHeaders(folder); const inheritedHeaders = useInheritedHeaders(folder);
@@ -68,7 +69,8 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
return ( return (
<Tabs <Tabs
defaultValue={tab ?? TAB_GENERAL} value={activeTab}
onChangeValue={setActiveTab}
label="Folder Settings" label="Folder Settings"
className="pt-2 pb-2 pl-3 pr-1" className="pt-2 pb-2 pl-3 pr-1"
layout="horizontal" layout="horizontal"
@@ -111,7 +113,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
<VStack alignItems="center" space={1.5}> <VStack alignItems="center" space={1.5}>
<p> <p>
Override{' '} Override{' '}
<Link href="https://yaak.app/docs/using-yaak/environments-and-variables"> <Link href="https://feedback.yaak.app/help/articles/3284139-environments-and-variables">
Variables Variables
</Link>{' '} </Link>{' '}
for requests within this folder. for requests within this folder.

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

@@ -7,6 +7,7 @@ import { useContainerSize } from '../hooks/useContainerQuery';
import type { ReflectResponseService } from '../hooks/useGrpc'; import type { ReflectResponseService } from '../hooks/useGrpc';
import { useHeadersTab } from '../hooks/useHeadersTab'; import { useHeadersTab } from '../hooks/useHeadersTab';
import { useInheritedHeaders } from '../hooks/useInheritedHeaders'; import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
import { useKeyValue } from '../hooks/useKeyValue';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey'; import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { resolvedModelName } from '../lib/resolvedModelName'; import { resolvedModelName } from '../lib/resolvedModelName';
import { Button } from './core/Button'; import { Button } from './core/Button';
@@ -68,6 +69,11 @@ export function GrpcRequestPane({
const authTab = useAuthTab(TAB_AUTH, activeRequest); const authTab = useAuthTab(TAB_AUTH, activeRequest);
const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, 'Metadata'); const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, 'Metadata');
const inheritedHeaders = useInheritedHeaders(activeRequest); const inheritedHeaders = useInheritedHeaders(activeRequest);
const { value: activeTabs, set: setActiveTabs } = useKeyValue<Record<string, string>>({
namespace: 'no_sync',
key: 'grpcRequestActiveTabs',
fallback: {},
});
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null); const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
const urlContainerEl = useRef<HTMLDivElement>(null); const urlContainerEl = useRef<HTMLDivElement>(null);
@@ -139,6 +145,14 @@ export function GrpcRequestPane({
[activeRequest.description, authTab, metadataTab], [activeRequest.description, authTab, metadataTab],
); );
const activeTab = activeTabs?.[activeRequest.id];
const setActiveTab = useCallback(
async (tab: string) => {
await setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab }));
},
[activeRequest.id, setActiveTabs],
);
const handleMetadataChange = useCallback( const handleMetadataChange = useCallback(
(metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }), (metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }),
[activeRequest], [activeRequest],
@@ -251,11 +265,12 @@ export function GrpcRequestPane({
</HStack> </HStack>
</div> </div>
<Tabs <Tabs
value={activeTab}
label="Request" label="Request"
onChangeValue={setActiveTab}
tabs={tabs} tabs={tabs}
tabListClassName="mt-1 !mb-1.5" tabListClassName="mt-1 !mb-1.5"
storageKey="grpc_request_tabs" storageKey="grpc_request_tabs_order"
activeTabKey={activeRequest.id}
> >
<TabContent value="message"> <TabContent value="message">
<GrpcEditor <GrpcEditor

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

@@ -19,7 +19,6 @@ type Props = {
forceUpdateKey: string; forceUpdateKey: string;
headers: HttpRequestHeader[]; headers: HttpRequestHeader[];
inheritedHeaders?: HttpRequestHeader[]; inheritedHeaders?: HttpRequestHeader[];
inheritedHeadersLabel?: string;
stateKey: string; stateKey: string;
onChange: (headers: HttpRequestHeader[]) => void; onChange: (headers: HttpRequestHeader[]) => void;
label?: string; label?: string;
@@ -29,36 +28,20 @@ export function HeadersEditor({
stateKey, stateKey,
headers, headers,
inheritedHeaders, inheritedHeaders,
inheritedHeadersLabel = 'Inherited',
onChange, onChange,
forceUpdateKey, forceUpdateKey,
}: Props) { }: Props) {
// Get header names defined at current level (case-insensitive)
const currentHeaderNames = new Set(
headers.filter((h) => h.name).map((h) => h.name.toLowerCase()),
);
// Filter inherited headers: must be enabled, have content, and not be overridden by current level
const validInheritedHeaders = const validInheritedHeaders =
inheritedHeaders?.filter( inheritedHeaders?.filter((pair) => pair.enabled && (pair.name || pair.value)) ?? [];
(pair) =>
pair.enabled && (pair.name || pair.value) && !currentHeaderNames.has(pair.name.toLowerCase()),
) ?? [];
const hasInheritedHeaders = validInheritedHeaders.length > 0;
return ( return (
<div <div className="@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5">
className={ {validInheritedHeaders.length > 0 ? (
hasInheritedHeaders
? '@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5'
: '@container w-full h-full'
}
>
{hasInheritedHeaders && (
<DetailsBanner <DetailsBanner
color="secondary" color="secondary"
className="text-sm" className="text-sm"
summary={ summary={
<HStack> <HStack>
{inheritedHeadersLabel} <CountBadge count={validInheritedHeaders.length} /> Inherited <CountBadge count={validInheritedHeaders.length} />
</HStack> </HStack>
} }
> >
@@ -80,6 +63,8 @@ export function HeadersEditor({
))} ))}
</div> </div>
</DetailsBanner> </DetailsBanner>
) : (
<span />
)} )}
<PairOrBulkEditor <PairOrBulkEditor
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}

View File

@@ -62,7 +62,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
<p> <p>
Apply auth to all requests in <strong>{resolvedModelName(model)}</strong> Apply auth to all requests in <strong>{resolvedModelName(model)}</strong>
</p> </p>
<Link href="https://yaak.app/docs/using-yaak/request-inheritance"> <Link href="https://feedback.yaak.app/help/articles/2112119-request-inheritance">
Documentation Documentation
</Link> </Link>
</EmptyStateText> </EmptyStateText>

View File

@@ -4,7 +4,7 @@ import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import classNames from 'classnames'; import classNames from 'classnames';
import { atom, useAtomValue } from 'jotai'; import { atom, useAtomValue } from 'jotai';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from 'react'; import { lazy, Suspense, useCallback, useMemo, useState } from 'react';
import { activeRequestIdAtom } from '../hooks/useActiveRequestId'; import { activeRequestIdAtom } from '../hooks/useActiveRequestId';
import { allRequestsAtom } from '../hooks/useAllRequests'; import { allRequestsAtom } from '../hooks/useAllRequests';
import { useAuthTab } from '../hooks/useAuthTab'; import { useAuthTab } from '../hooks/useAuthTab';
@@ -12,6 +12,7 @@ import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useHeadersTab } from '../hooks/useHeadersTab'; import { useHeadersTab } from '../hooks/useHeadersTab';
import { useImportCurl } from '../hooks/useImportCurl'; import { useImportCurl } from '../hooks/useImportCurl';
import { useInheritedHeaders } from '../hooks/useInheritedHeaders'; import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
import { useKeyValue } from '../hooks/useKeyValue';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse'; import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor'; import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey'; import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
@@ -41,8 +42,8 @@ import { Editor } from './core/Editor/LazyEditor';
import { InlineCode } from './core/InlineCode'; import { InlineCode } from './core/InlineCode';
import type { Pair } from './core/PairEditor'; import type { Pair } from './core/PairEditor';
import { PlainInput } from './core/PlainInput'; import { PlainInput } from './core/PlainInput';
import type { TabItem, TabsRef } from './core/Tabs/Tabs'; import type { TabItem } from './core/Tabs/Tabs';
import { setActiveTab, TabContent, Tabs } from './core/Tabs/Tabs'; import { TabContent, Tabs } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText'; import { EmptyStateText } from './EmptyStateText';
import { FormMultipartEditor } from './FormMultipartEditor'; import { FormMultipartEditor } from './FormMultipartEditor';
import { FormUrlencodedEditor } from './FormUrlencodedEditor'; import { FormUrlencodedEditor } from './FormUrlencodedEditor';
@@ -69,7 +70,6 @@ const TAB_PARAMS = 'params';
const TAB_HEADERS = 'headers'; const TAB_HEADERS = 'headers';
const TAB_AUTH = 'auth'; const TAB_AUTH = 'auth';
const TAB_DESCRIPTION = 'description'; const TAB_DESCRIPTION = 'description';
const TABS_STORAGE_KEY = 'http_request_tabs';
const nonActiveRequestUrlsAtom = atom((get) => { const nonActiveRequestUrlsAtom = atom((get) => {
const activeRequestId = get(activeRequestIdAtom); const activeRequestId = get(activeRequestIdAtom);
@@ -83,20 +83,19 @@ const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) { export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
const activeRequestId = activeRequest.id; const activeRequestId = activeRequest.id;
const tabsRef = useRef<TabsRef>(null); const { value: activeTabs, set: setActiveTabs } = useKeyValue<Record<string, string>>({
namespace: 'no_sync',
key: 'httpRequestActiveTabs',
fallback: {},
});
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0); const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null); const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
const [{ urlKey }, { forceUrlRefresh, forceParamsRefresh }] = useRequestEditor(); const [{ urlKey }, { focusParamsTab, forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
const contentType = getContentTypeFromHeaders(activeRequest.headers); const contentType = getContentTypeFromHeaders(activeRequest.headers);
const authTab = useAuthTab(TAB_AUTH, activeRequest); const authTab = useAuthTab(TAB_AUTH, activeRequest);
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest); const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
const inheritedHeaders = useInheritedHeaders(activeRequest); const inheritedHeaders = useInheritedHeaders(activeRequest);
// Listen for event to focus the params tab (e.g., when clicking a :param in the URL)
useRequestEditorEvent('request_pane.focus_tab', () => {
tabsRef.current?.setActiveTab(TAB_PARAMS);
}, []);
const handleContentTypeChange = useCallback( const handleContentTypeChange = useCallback(
async (contentType: string | null, patch: Partial<Omit<HttpRequest, 'headers'>> = {}) => { async (contentType: string | null, patch: Partial<Omit<HttpRequest, 'headers'>> = {}) => {
if (activeRequest == null) { if (activeRequest == null) {
@@ -261,6 +260,18 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
[activeRequest], [activeRequest],
); );
const activeTab = activeTabs?.[activeRequestId];
const setActiveTab = useCallback(
async (tab: string) => {
await setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab }));
},
[activeRequest.id, setActiveTabs],
);
useRequestEditorEvent('request_pane.focus_tab', async () => {
await setActiveTab(TAB_PARAMS);
});
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom); const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
const autocomplete: GenericCompletionConfig = useMemo( const autocomplete: GenericCompletionConfig = useMemo(
@@ -287,11 +298,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
e.preventDefault(); // Prevent input onChange e.preventDefault(); // Prevent input onChange
await patchModel(activeRequest, patch); await patchModel(activeRequest, patch);
await setActiveTab({ focusParamsTab();
storageKey: TABS_STORAGE_KEY,
activeTabKey: activeRequestId,
value: TAB_PARAMS,
});
// Wait for request to update, then refresh the UI // Wait for request to update, then refresh the UI
// TODO: Somehow make this deterministic // TODO: Somehow make this deterministic
@@ -302,7 +309,14 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
} }
} }
}, },
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh, importCurl], [
activeRequest,
activeRequestId,
focusParamsTab,
forceParamsRefresh,
forceUrlRefresh,
importCurl,
],
); );
const handleSend = useCallback( const handleSend = useCallback(
() => sendRequest(activeRequest.id ?? null), () => sendRequest(activeRequest.id ?? null),
@@ -340,12 +354,12 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
isLoading={activeResponse != null && activeResponse.state !== 'closed'} isLoading={activeResponse != null && activeResponse.state !== 'closed'}
/> />
<Tabs <Tabs
ref={tabsRef} value={activeTab}
label="Request" label="Request"
onChangeValue={setActiveTab}
tabs={tabs} tabs={tabs}
tabListClassName="mt-1 -mb-1.5" tabListClassName="mt-1 mb-1.5"
storageKey={TABS_STORAGE_KEY} storageKey="http_request_tabs_order"
activeTabKey={activeRequestId}
> >
<TabContent value={TAB_AUTH}> <TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor model={activeRequest} /> <HttpAuthenticationEditor model={activeRequest} />

View File

@@ -1,15 +1,15 @@
import type { HttpResponse } from '@yaakapp-internal/models'; import type { HttpResponse } from '@yaakapp-internal/models';
import classNames from 'classnames'; import classNames from 'classnames';
import type { ComponentType, CSSProperties } from 'react'; import type { ComponentType, CSSProperties } from 'react';
import { lazy, Suspense, useMemo } from 'react'; import { lazy, Suspense, useCallback, useMemo } from 'react';
import { useLocalStorage } from 'react-use';
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse'; import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents'; import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse'; 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 { useTimelineViewMode } from '../hooks/useTimelineViewMode';
import { getMimeTypeFromContentType } from '../lib/contentType'; import { getMimeTypeFromContentType } from '../lib/contentType';
import { getContentTypeFromHeaders, getCookieCounts } 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';
@@ -55,18 +55,32 @@ const TAB_HEADERS = 'headers';
const TAB_COOKIES = 'cookies'; const TAB_COOKIES = 'cookies';
const TAB_TIMELINE = 'timeline'; const TAB_TIMELINE = 'timeline';
export type TimelineViewMode = 'timeline' | 'text';
export function HttpResponsePane({ style, className, activeRequestId }: Props) { export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId); const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId); const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode(); const [activeTabs, setActiveTabs] = useLocalStorage<Record<string, string>>(
'responsePaneActiveTabs',
{},
);
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null); const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence; const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
const responseEvents = useHttpResponseEvents(activeResponse); 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[]>( const tabs = useMemo<TabItem[]>(
() => [ () => [
@@ -78,9 +92,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
onChange: setViewMode, onChange: setViewMode,
items: [ items: [
{ label: 'Response', value: 'pretty' }, { label: 'Response', value: 'pretty' },
...(mimeType?.startsWith('image') ...(mimeType?.startsWith('image') ? [] : [{ label: 'Raw', value: 'raw' }]),
? []
: [{ label: 'Response (Raw)', shortLabel: 'Raw', value: 'raw' }]),
], ],
}, },
}, },
@@ -95,47 +107,40 @@ 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,
label: 'Timeline',
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />, rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
options: {
value: timelineViewMode,
onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? 'timeline'),
items: [
{ label: 'Timeline', value: 'timeline' },
{ label: 'Timeline (Text)', shortLabel: 'Timeline', value: 'text' },
],
},
}, },
], ],
[ [
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,
viewMode, viewMode,
timelineViewMode,
setTimelineViewMode,
], ],
); );
const activeTab = activeTabs?.[activeRequestId];
const setActiveTab = useCallback(
(tab: string) => {
setActiveTabs((r) => ({ ...r, [activeRequestId]: tab }));
},
[activeRequestId, setActiveTabs],
);
const cancel = useCancelHttpResponse(activeResponse?.id ?? null); const cancel = useCancelHttpResponse(activeResponse?.id ?? null);
@@ -199,12 +204,14 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
)} )}
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */} {/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
<Tabs <Tabs
key={activeRequestId} // Freshen tabs on request change
value={activeTab}
onChangeValue={setActiveTab}
tabs={tabs} tabs={tabs}
label="Response" label="Response"
className="ml-3 mr-3 mb-3 min-h-0 flex-1" className="ml-3 mr-3 mb-3 min-h-0 flex-1"
tabListClassName="mt-0.5 -mb-1.5" tabListClassName="mt-0.5"
storageKey="http_response_tabs" storageKey="http_response_tabs_order"
activeTabKey={activeRequestId}
> >
<TabContent value={TAB_BODY}> <TabContent value={TAB_BODY}>
<ErrorBoundary name="Http Response Viewer"> <ErrorBoundary name="Http Response Viewer">
@@ -264,7 +271,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<ResponseCookies response={activeResponse} /> <ResponseCookies response={activeResponse} />
</TabContent> </TabContent>
<TabContent value={TAB_TIMELINE}> <TabContent value={TAB_TIMELINE}>
<HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} /> <HttpResponseTimeline response={activeResponse} />
</TabContent> </TabContent>
</Tabs> </Tabs>
</div> </div>

View File

@@ -3,179 +3,186 @@ import type {
HttpResponseEvent, HttpResponseEvent,
HttpResponseEventData, HttpResponseEventData,
} from '@yaakapp-internal/models'; } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { format } from 'date-fns';
import { type ReactNode, useMemo, useState } from 'react'; 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 { type EventDetailAction, EventDetailHeader, EventViewer } 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 type { TimelineViewMode } from './HttpResponsePane'; import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout';
interface Props { interface Props {
response: HttpResponse; response: HttpResponse;
viewMode: TimelineViewMode;
} }
export function HttpResponseTimeline({ response, viewMode }: Props) { export function HttpResponseTimeline({ response }: Props) {
return <Inner key={response.id} response={response} viewMode={viewMode} />; return <Inner key={response.id} response={response} />;
} }
function Inner({ response, viewMode }: 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);
// Generate plain text representation of all events (with prefixes for timeline view) const activeEvent = useMemo(
const plainText = useMemo(() => { () => (activeEventIndex == null ? null : events?.[activeEventIndex]),
if (!events || events.length === 0) return ''; [activeEventIndex, events],
return events.map((event) => formatEventText(event.event, true)).join('\n'); );
}, [events]);
// Plain text view - show all events as text in an editor if (isLoading) {
if (viewMode === 'text') { return <div className="p-3 text-text-subtlest italic">Loading events...</div>;
if (isLoading) { }
return <div className="p-4 text-text-subtlest">Loading events...</div>;
} else if (error) { if (error) {
return <div className="p-4 text-danger">{String(error)}</div>; return (
} else if (!events || events.length === 0) { <Banner color="danger" className="m-3">
return <div className="p-4 text-text-subtlest">No events recorded</div>; {String(error)}
} else { </Banner>
return ( );
<Editor language="timeline" defaultValue={plainText} readOnly stateKey={null} hideGutter /> }
);
} 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, onClose }) => ( }}
<EventDetails event={event} showRaw={showRaw} setShowRaw={setShowRaw} onClose={onClose} /> />
)}
/>
)} )}
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,
onClose,
}: {
event: HttpResponseEvent;
showRaw: boolean;
setShowRaw: (v: boolean) => void;
onClose: () => 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[] = [ // Headers - show name and value with Editor for JSON
{ if (e.type === 'header_up' || e.type === 'header_down') {
key: 'toggle-raw', return (
label: showRaw ? 'Formatted' : 'Text', <div className="flex flex-col gap-2 h-full">
onClick: () => setShowRaw(!showRaw), <DetailHeader
}, title={e.type === 'header_down' ? 'Header Received' : 'Header Sent'}
]; timestamp={timestamp}
/>
// Determine the title based on event type
const title = (() => {
switch (e.type) {
case 'header_up':
return 'Header Sent';
case 'header_down':
return 'Header Received';
case 'send_url':
return 'Request';
case 'receive_url':
return 'Response';
case 'redirect':
return 'Redirect';
case 'setting':
return 'Apply Setting';
case 'chunk_sent':
return 'Data Sent';
case 'chunk_received':
return 'Data Received';
case 'dns_resolved':
return e.overridden ? 'DNS Override' : 'DNS Resolution';
default:
return label;
}
})();
// Render content based on view mode and event type
const renderContent = () => {
// Raw view - show plaintext representation (without prefix)
if (showRaw) {
const rawText = formatEventText(event.event, false);
return <Editor language="text" defaultValue={rawText} readOnly stateKey={null} hideGutter />;
}
// Headers - show name and value
if (e.type === 'header_up' || e.type === 'header_down') {
return (
<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>
</KeyValueRows> </KeyValueRows>
); </div>
} );
}
// Request URL - show method and path separately // Request URL - show method and path separately
if (e.type === 'send_url') { if (e.type === 'send_url') {
return ( return (
<div className="flex flex-col gap-2">
<DetailHeader title="Request" timestamp={timestamp} />
<KeyValueRows> <KeyValueRows>
<KeyValueRow label="Method"> <KeyValueRow label="Method">
<HttpMethodTagRaw forceColor method={e.method} /> <HttpMethodTagRaw forceColor method={e.method} />
</KeyValueRow> </KeyValueRow>
<KeyValueRow label="Path">{e.path}</KeyValueRow> <KeyValueRow label="Path">{e.path}</KeyValueRow>
</KeyValueRows> </KeyValueRows>
); </div>
} );
}
// Response status - show version and status separately // Response status - show version and status separately
if (e.type === 'receive_url') { if (e.type === 'receive_url') {
return ( return (
<div className="flex flex-col gap-2">
<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">
<HttpStatusTagRaw status={e.status} /> <HttpStatusTagRaw status={e.status} />
</KeyValueRow> </KeyValueRow>
</KeyValueRows> </KeyValueRows>
); </div>
} );
}
// Redirect - show status, URL, and behavior // Redirect - show status, URL, and behavior
if (e.type === 'redirect') { if (e.type === 'redirect') {
return ( return (
<div className="flex flex-col gap-2">
<DetailHeader title="Redirect" timestamp={timestamp} />
<KeyValueRows> <KeyValueRows>
<KeyValueRow label="Status"> <KeyValueRow label="Status">
<HttpStatusTagRaw status={e.status} /> <HttpStatusTagRaw status={e.status} />
@@ -185,98 +192,51 @@ function EventDetails({
{e.behavior === 'drop_body' ? 'Drop body, change to GET' : 'Preserve method and body'} {e.behavior === 'drop_body' ? 'Drop body, change to GET' : 'Preserve method and body'}
</KeyValueRow> </KeyValueRow>
</KeyValueRows> </KeyValueRows>
); </div>
} );
}
// Settings - show as key/value // Settings - show as key/value
if (e.type === 'setting') { if (e.type === 'setting') {
return ( return (
<div className="flex flex-col gap-2">
<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>
</KeyValueRows> </KeyValueRows>
); </div>
} );
}
// Chunks - show formatted bytes // Chunks - show formatted bytes
if (e.type === 'chunk_sent' || e.type === 'chunk_received') { if (e.type === 'chunk_sent' || e.type === 'chunk_received') {
return <div className="font-mono text-editor">{formatBytes(e.bytes)}</div>; const direction = e.type === 'chunk_sent' ? 'Sent' : 'Received';
} return (
<div className="flex flex-col gap-2">
<DetailHeader title={`Data ${direction}`} timestamp={timestamp} />
<div className="font-mono text-editor">{formatBytes(e.bytes)}</div>
</div>
);
}
// DNS Resolution - show hostname, addresses, and timing // Default - use summary
if (e.type === 'dns_resolved') { const { summary } = getEventDisplay(event.event);
return (
<KeyValueRows>
<KeyValueRow label="Hostname">{e.hostname}</KeyValueRow>
<KeyValueRow label="Addresses">{e.addresses.join(', ')}</KeyValueRow>
<KeyValueRow label="Duration">
{e.overridden ? (
<span className="text-text-subtlest">--</span>
) : (
`${String(e.duration)}ms`
)}
</KeyValueRow>
{e.overridden ? <KeyValueRow label="Source">Workspace Override</KeyValueRow> : null}
</KeyValueRows>
);
}
// Default - use summary
const { summary } = getEventDisplay(event.event);
return <div className="font-mono text-editor">{summary}</div>;
};
return ( return (
<div className="flex flex-col gap-2 h-full"> <div className="flex flex-col gap-1">
<EventDetailHeader <DetailHeader title={label} timestamp={timestamp} />
title={title} <div className="font-mono text-editor">{summary}</div>
timestamp={event.createdAt}
actions={actions}
onClose={onClose}
/>
{renderContent()}
</div> </div>
); );
} }
type EventTextParts = { prefix: '>' | '<' | '*'; text: string }; function DetailHeader({ title, timestamp }: { title: string; timestamp: string }) {
return (
/** Get the prefix and text for an event */ <div className="flex items-center justify-between gap-2">
function getEventTextParts(event: HttpResponseEventData): EventTextParts { <h3 className="font-semibold select-auto cursor-auto">{title}</h3>
switch (event.type) { <span className="text-text-subtlest font-mono text-editor">{timestamp}</span>
case 'send_url': </div>
return { prefix: '>', text: `${event.method} ${event.path}` }; );
case 'receive_url':
return { prefix: '<', text: `${event.version} ${event.status}` };
case 'header_up':
return { prefix: '>', text: `${event.name}: ${event.value}` };
case 'header_down':
return { prefix: '<', text: `${event.name}: ${event.value}` };
case 'redirect': {
const behavior = event.behavior === 'drop_body' ? 'drop body' : 'preserve';
return { prefix: '*', text: `Redirect ${event.status} -> ${event.url} (${behavior})` };
}
case 'setting':
return { prefix: '*', text: `Setting ${event.name}=${event.value}` };
case 'info':
return { prefix: '*', text: event.message };
case 'chunk_sent':
return { prefix: '*', text: `[${formatBytes(event.bytes)} sent]` };
case 'chunk_received':
return { prefix: '*', text: `[${formatBytes(event.bytes)} received]` };
case 'dns_resolved':
if (event.overridden) {
return { prefix: '*', text: `DNS override ${event.hostname} -> ${event.addresses.join(', ')}` };
}
return { prefix: '*', text: `DNS resolved ${event.hostname} to ${event.addresses.join(', ')} (${event.duration}ms)` };
default:
return { prefix: '*', text: '[unknown event]' };
}
}
/** Format event as plaintext, optionally with curl-style prefix (> outgoing, < incoming, * info) */
function formatEventText(event: HttpResponseEventData, includePrefix: boolean): string {
const { prefix, text } = getEventTextParts(event);
return includePrefix ? `${prefix} ${text}` : text;
} }
type EventDisplay = { type EventDisplay = {
@@ -305,7 +265,7 @@ function getEventDisplay(event: HttpResponseEventData): EventDisplay {
case 'redirect': case 'redirect':
return { return {
icon: 'arrow_big_right_dash', icon: 'arrow_big_right_dash',
color: 'success', color: 'warning',
label: 'Redirect', label: 'Redirect',
summary: `Redirecting ${event.status} ${event.url}${event.behavior === 'drop_body' ? ' (drop body)' : ''}`, summary: `Redirecting ${event.status} ${event.url}${event.behavior === 'drop_body' ? ' (drop body)' : ''}`,
}; };
@@ -352,15 +312,6 @@ function getEventDisplay(event: HttpResponseEventData): EventDisplay {
label: 'Chunk', label: 'Chunk',
summary: `${formatBytes(event.bytes)} chunk received`, summary: `${formatBytes(event.bytes)} chunk received`,
}; };
case 'dns_resolved':
return {
icon: 'globe',
color: event.overridden ? 'success' : 'secondary',
label: event.overridden ? 'DNS Override' : 'DNS',
summary: event.overridden
? `${event.hostname}${event.addresses.join(', ')} (overridden)`
: `${event.hostname}${event.addresses.join(', ')} (${event.duration}ms)`,
};
default: default:
return { return {
icon: 'info', icon: 'info',

View File

@@ -5,6 +5,7 @@ import { useLicense } from '@yaakapp-internal/license';
import { pluginsAtom, settingsAtom } from '@yaakapp-internal/models'; import { pluginsAtom, settingsAtom } from '@yaakapp-internal/models';
import classNames from 'classnames'; import classNames from 'classnames';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useState } from 'react';
import { useKeyPressEvent } from 'react-use'; import { useKeyPressEvent } from 'react-use';
import { appInfo } from '../../lib/appInfo'; import { appInfo } from '../../lib/appInfo';
import { capitalize } from '../../lib/capitalize'; import { capitalize } from '../../lib/capitalize';
@@ -50,6 +51,7 @@ export default function Settings({ hide }: Props) {
const { tab: tabFromQuery } = useSearch({ from: '/workspaces/$workspaceId/settings' }); const { tab: tabFromQuery } = useSearch({ from: '/workspaces/$workspaceId/settings' });
// Parse tab and subtab (e.g., "plugins:installed") // Parse tab and subtab (e.g., "plugins:installed")
const [mainTab, subtab] = tabFromQuery?.split(':') ?? []; const [mainTab, subtab] = tabFromQuery?.split(':') ?? [];
const [tab, setTab] = useState<string | undefined>(mainTab || tabFromQuery);
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
const plugins = useAtomValue(pluginsAtom); const plugins = useAtomValue(pluginsAtom);
const licenseCheck = useLicense(); const licenseCheck = useLicense();
@@ -89,10 +91,11 @@ export default function Settings({ hide }: Props) {
)} )}
<Tabs <Tabs
layout="horizontal" layout="horizontal"
defaultValue={mainTab || tabFromQuery} value={tab}
addBorders addBorders
tabListClassName="min-w-[10rem] bg-surface x-theme-sidebar border-r border-border pl-3" tabListClassName="min-w-[10rem] bg-surface x-theme-sidebar border-r border-border pl-3"
label="Settings" label="Settings"
onChangeValue={setTab}
tabs={tabs.map( tabs={tabs.map(
(value): TabItem => ({ (value): TabItem => ({
value, value,
@@ -142,7 +145,7 @@ export default function Settings({ hide }: Props) {
<SettingsHotkeys /> <SettingsHotkeys />
</TabContent> </TabContent>
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1 px-6 !py-4"> <TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1 px-6 !py-4">
<SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} /> <SettingsPlugins defaultSubtab={tab === TAB_PLUGINS ? subtab : undefined} />
</TabContent> </TabContent>
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 !py-4"> <TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 !py-4">
<SettingsProxy /> <SettingsProxy />

View File

@@ -54,11 +54,13 @@ export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
const installedPlugins = plugins.filter((p) => !isPluginBundled(p, appInfo.vendoredPluginDir)); const installedPlugins = plugins.filter((p) => !isPluginBundled(p, appInfo.vendoredPluginDir));
const createPlugin = useInstallPlugin(); const createPlugin = useInstallPlugin();
const refreshPlugins = useRefreshPlugins(); const refreshPlugins = useRefreshPlugins();
const [tab, setTab] = useState<string | undefined>(defaultSubtab);
return ( return (
<div className="h-full"> <div className="h-full">
<Tabs <Tabs
defaultValue={defaultSubtab} value={tab}
label="Plugins" label="Plugins"
onChangeValue={setTab}
addBorders addBorders
tabs={[ tabs={[
{ label: 'Discover', value: 'search' }, { label: 'Discover', value: 'search' },
@@ -115,7 +117,7 @@ export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
icon="help" icon="help"
title="View documentation" title="View documentation"
onClick={() => onClick={() =>
openUrl('https://yaak.app/docs/plugin-development/plugins-quick-start') openUrl('https://feedback.yaak.app/help/articles/6911763-quick-start')
} }
/> />
</HStack> </HStack>

View File

@@ -75,7 +75,7 @@ export function SettingsTheme() {
<Heading>Theme</Heading> <Heading>Theme</Heading>
<p className="text-text-subtle"> <p className="text-text-subtle">
Make Yaak your own by selecting a theme, or{' '} Make Yaak your own by selecting a theme, or{' '}
<Link href="https://yaak.app/docs/plugin-development/plugins-quick-start"> <Link href="https://feedback.yaak.app/help/articles/6911763-plugins-quick-start">
Create Your Own Create Your Own
</Link> </Link>
</p> </p>

View File

@@ -5,7 +5,7 @@ import { closeWebsocket, connectWebsocket, sendWebsocket } from '@yaakapp-intern
import classNames from 'classnames'; import classNames from 'classnames';
import { atom, useAtomValue } from 'jotai'; import { atom, useAtomValue } from 'jotai';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import { useCallback, useMemo, useRef } from 'react'; import { useCallback, useMemo } from 'react';
import { getActiveCookieJar } from '../hooks/useActiveCookieJar'; import { getActiveCookieJar } from '../hooks/useActiveCookieJar';
import { getActiveEnvironment } from '../hooks/useActiveEnvironment'; import { getActiveEnvironment } from '../hooks/useActiveEnvironment';
import { activeRequestIdAtom } from '../hooks/useActiveRequestId'; import { activeRequestIdAtom } from '../hooks/useActiveRequestId';
@@ -14,6 +14,7 @@ import { useAuthTab } from '../hooks/useAuthTab';
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse'; import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useHeadersTab } from '../hooks/useHeadersTab'; import { useHeadersTab } from '../hooks/useHeadersTab';
import { useInheritedHeaders } from '../hooks/useInheritedHeaders'; import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
import { useKeyValue } from '../hooks/useKeyValue';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse'; import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { activeWebsocketConnectionAtom } from '../hooks/usePinnedWebsocketConnection'; import { activeWebsocketConnectionAtom } from '../hooks/usePinnedWebsocketConnection';
import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor'; import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor';
@@ -29,8 +30,8 @@ import { Editor } from './core/Editor/LazyEditor';
import { IconButton } from './core/IconButton'; import { IconButton } from './core/IconButton';
import type { Pair } from './core/PairEditor'; import type { Pair } from './core/PairEditor';
import { PlainInput } from './core/PlainInput'; import { PlainInput } from './core/PlainInput';
import type { TabItem, TabsRef } from './core/Tabs/Tabs'; import type { TabItem } from './core/Tabs/Tabs';
import { setActiveTab, TabContent, Tabs } from './core/Tabs/Tabs'; import { TabContent, Tabs } from './core/Tabs/Tabs';
import { HeadersEditor } from './HeadersEditor'; import { HeadersEditor } from './HeadersEditor';
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor'; import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
import { MarkdownEditor } from './MarkdownEditor'; import { MarkdownEditor } from './MarkdownEditor';
@@ -49,7 +50,6 @@ const TAB_PARAMS = 'params';
const TAB_HEADERS = 'headers'; const TAB_HEADERS = 'headers';
const TAB_AUTH = 'auth'; const TAB_AUTH = 'auth';
const TAB_DESCRIPTION = 'description'; const TAB_DESCRIPTION = 'description';
const TABS_STORAGE_KEY = 'websocket_request_tabs';
const nonActiveRequestUrlsAtom = atom((get) => { const nonActiveRequestUrlsAtom = atom((get) => {
const activeRequestId = get(activeRequestIdAtom); const activeRequestId = get(activeRequestIdAtom);
@@ -63,18 +63,17 @@ const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) { export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) {
const activeRequestId = activeRequest.id; const activeRequestId = activeRequest.id;
const tabsRef = useRef<TabsRef>(null); const { value: activeTabs, set: setActiveTabs } = useKeyValue<Record<string, string>>({
namespace: 'no_sync',
key: 'websocketRequestActiveTabs',
fallback: {},
});
const forceUpdateKey = useRequestUpdateKey(activeRequest.id); const forceUpdateKey = useRequestUpdateKey(activeRequest.id);
const [{ urlKey }, { forceUrlRefresh, forceParamsRefresh }] = useRequestEditor(); const [{ urlKey }, { focusParamsTab, forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
const authTab = useAuthTab(TAB_AUTH, activeRequest); const authTab = useAuthTab(TAB_AUTH, activeRequest);
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest); const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
const inheritedHeaders = useInheritedHeaders(activeRequest); const inheritedHeaders = useInheritedHeaders(activeRequest);
// Listen for event to focus the params tab (e.g., when clicking a :param in the URL)
useRequestEditorEvent('request_pane.focus_tab', () => {
tabsRef.current?.setActiveTab(TAB_PARAMS);
}, []);
const { urlParameterPairs, urlParametersKey } = useMemo(() => { const { urlParameterPairs, urlParametersKey } = useMemo(() => {
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map( const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map(
(m) => m[1] ?? '', (m) => m[1] ?? '',
@@ -116,6 +115,18 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null); const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
const connection = useAtomValue(activeWebsocketConnectionAtom); const connection = useAtomValue(activeWebsocketConnectionAtom);
const activeTab = activeTabs?.[activeRequestId];
const setActiveTab = useCallback(
async (tab: string) => {
await setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab }));
},
[activeRequest.id, setActiveTabs],
);
useRequestEditorEvent('request_pane.focus_tab', async () => {
await setActiveTab(TAB_PARAMS);
});
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom); const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
const autocomplete: GenericCompletionConfig = useMemo( const autocomplete: GenericCompletionConfig = useMemo(
@@ -165,11 +176,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
e.preventDefault(); // Prevent input onChange e.preventDefault(); // Prevent input onChange
await patchModel(activeRequest, patch); await patchModel(activeRequest, patch);
await setActiveTab({ focusParamsTab();
storageKey: TABS_STORAGE_KEY,
activeTabKey: activeRequestId,
value: TAB_PARAMS,
});
// Wait for request to update, then refresh the UI // Wait for request to update, then refresh the UI
// TODO: Somehow make this deterministic // TODO: Somehow make this deterministic
@@ -179,7 +186,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
}, 100); }, 100);
} }
}, },
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh], [activeRequest, focusParamsTab, forceParamsRefresh, forceUrlRefresh],
); );
const messageLanguage = languageFromContentType(null, activeRequest.message); const messageLanguage = languageFromContentType(null, activeRequest.message);
@@ -222,12 +229,12 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
/> />
</div> </div>
<Tabs <Tabs
ref={tabsRef} value={activeTab}
label="Request" label="Request"
onChangeValue={setActiveTab}
tabs={tabs} tabs={tabs}
tabListClassName="mt-1 !mb-1.5" tabListClassName="mt-1 !mb-1.5"
storageKey={TABS_STORAGE_KEY} storageKey="websocket_request_tabs_order"
activeTabKey={activeRequestId}
> >
<TabContent value={TAB_AUTH}> <TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor model={activeRequest} /> <HttpAuthenticationEditor model={activeRequest} />

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,5 +1,6 @@
import { patchModel, workspaceMetasAtom, workspacesAtom } from '@yaakapp-internal/models'; import { patchModel, workspaceMetasAtom, workspacesAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useState } from 'react';
import { useAuthTab } from '../hooks/useAuthTab'; import { useAuthTab } from '../hooks/useAuthTab';
import { useHeadersTab } from '../hooks/useHeadersTab'; import { useHeadersTab } from '../hooks/useHeadersTab';
import { useInheritedHeaders } from '../hooks/useInheritedHeaders'; import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
@@ -8,12 +9,10 @@ import { router } from '../lib/router';
import { CopyIconButton } from './CopyIconButton'; import { CopyIconButton } from './CopyIconButton';
import { Banner } from './core/Banner'; import { Banner } from './core/Banner';
import { Button } from './core/Button'; import { Button } from './core/Button';
import { CountBadge } from './core/CountBadge';
import { InlineCode } from './core/InlineCode'; import { InlineCode } from './core/InlineCode';
import { PlainInput } from './core/PlainInput'; import { PlainInput } from './core/PlainInput';
import { HStack, VStack } from './core/Stacks'; import { HStack, VStack } from './core/Stacks';
import { TabContent, Tabs } from './core/Tabs/Tabs'; import { TabContent, Tabs } from './core/Tabs/Tabs';
import { DnsOverridesEditor } from './DnsOverridesEditor';
import { HeadersEditor } from './HeadersEditor'; import { HeadersEditor } from './HeadersEditor';
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor'; import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
import { MarkdownEditor } from './MarkdownEditor'; import { MarkdownEditor } from './MarkdownEditor';
@@ -28,13 +27,11 @@ interface Props {
const TAB_AUTH = 'auth'; const TAB_AUTH = 'auth';
const TAB_DATA = 'data'; const TAB_DATA = 'data';
const TAB_DNS = 'dns';
const TAB_HEADERS = 'headers'; const TAB_HEADERS = 'headers';
const TAB_GENERAL = 'general'; const TAB_GENERAL = 'general';
export type WorkspaceSettingsTab = export type WorkspaceSettingsTab =
| typeof TAB_AUTH | typeof TAB_AUTH
| typeof TAB_DNS
| typeof TAB_HEADERS | typeof TAB_HEADERS
| typeof TAB_GENERAL | typeof TAB_GENERAL
| typeof TAB_DATA; | typeof TAB_DATA;
@@ -44,6 +41,7 @@ const DEFAULT_TAB: WorkspaceSettingsTab = TAB_GENERAL;
export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) { export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
const workspace = useAtomValue(workspacesAtom).find((w) => w.id === workspaceId); const workspace = useAtomValue(workspacesAtom).find((w) => w.id === workspaceId);
const workspaceMeta = useAtomValue(workspaceMetasAtom).find((m) => m.workspaceId === workspaceId); const workspaceMeta = useAtomValue(workspaceMetasAtom).find((m) => m.workspaceId === workspaceId);
const [activeTab, setActiveTab] = useState<string>(tab ?? DEFAULT_TAB);
const authTab = useAuthTab(TAB_AUTH, workspace ?? null); const authTab = useAuthTab(TAB_AUTH, workspace ?? null);
const headersTab = useHeadersTab(TAB_HEADERS, workspace ?? null); const headersTab = useHeadersTab(TAB_HEADERS, workspace ?? null);
const inheritedHeaders = useInheritedHeaders(workspace ?? null); const inheritedHeaders = useInheritedHeaders(workspace ?? null);
@@ -65,7 +63,8 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
return ( return (
<Tabs <Tabs
defaultValue={tab ?? DEFAULT_TAB} value={activeTab}
onChangeValue={setActiveTab}
label="Folder Settings" label="Folder Settings"
className="pt-4 pb-2 px-3" className="pt-4 pb-2 px-3"
tabListClassName="pl-4" tabListClassName="pl-4"
@@ -78,16 +77,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
}, },
...headersTab, ...headersTab,
...authTab, ...authTab,
{
value: TAB_DNS,
label: 'DNS',
rightSlot:
workspace.settingDnsOverrides.length > 0 ? (
<CountBadge count={workspace.settingDnsOverrides.length} />
) : null,
},
]} ]}
storageKey="workspace_settings_tabs"
> >
<TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4"> <TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4">
<HttpAuthenticationEditor model={workspace} /> <HttpAuthenticationEditor model={workspace} />
@@ -95,7 +85,6 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
<TabContent value={TAB_HEADERS} className="overflow-y-auto h-full px-4"> <TabContent value={TAB_HEADERS} className="overflow-y-auto h-full px-4">
<HeadersEditor <HeadersEditor
inheritedHeaders={inheritedHeaders} inheritedHeaders={inheritedHeaders}
inheritedHeadersLabel="Defaults"
forceUpdateKey={workspace.id} forceUpdateKey={workspace.id}
headers={workspace.headers} headers={workspace.headers}
onChange={(headers) => patchModel(workspace, { headers })} onChange={(headers) => patchModel(workspace, { headers })}
@@ -164,9 +153,6 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
<WorkspaceEncryptionSetting size="xs" /> <WorkspaceEncryptionSetting size="xs" />
</VStack> </VStack>
</TabContent> </TabContent>
<TabContent value={TAB_DNS} className="overflow-y-auto h-full px-4">
<DnsOverridesEditor workspace={workspace} />
</TabContent>
</Tabs> </Tabs>
); );
} }

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 focus:outline-none"
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

@@ -101,7 +101,7 @@
.template-tag { .template-tag {
/* Colors */ /* Colors */
@apply bg-surface text-text border-border-subtle whitespace-nowrap cursor-default; @apply bg-surface text-text border-border-subtle whitespace-nowrap;
@apply hover:border-border-subtle hover:text-text hover:bg-surface-highlight; @apply hover:border-border-subtle hover:text-text hover:bg-surface-highlight;
@apply inline border px-1 mx-[0.5px] rounded dark:shadow; @apply inline border px-1 mx-[0.5px] rounded dark:shadow;

View File

@@ -77,7 +77,7 @@ export interface EditorProps {
heightMode?: 'auto' | 'full'; heightMode?: 'auto' | 'full';
hideGutter?: boolean; hideGutter?: boolean;
id?: string; id?: string;
language?: EditorLanguage | 'pairs' | 'url' | 'timeline' | null; language?: EditorLanguage | 'pairs' | 'url' | null;
graphQLSchema?: GraphQLSchema | null; graphQLSchema?: GraphQLSchema | null;
onBlur?: () => void; onBlur?: () => void;
onChange?: (value: string) => void; onChange?: (value: string) => void;

View File

@@ -48,7 +48,6 @@ import type { EditorProps } from './Editor';
import { jsonParseLinter } from './json-lint'; import { jsonParseLinter } from './json-lint';
import { pairs } from './pairs/extension'; import { pairs } from './pairs/extension';
import { text } from './text/extension'; import { text } from './text/extension';
import { timeline } from './timeline/extension';
import type { TwigCompletionOption } from './twig/completion'; import type { TwigCompletionOption } from './twig/completion';
import { twig } from './twig/extension'; import { twig } from './twig/extension';
import { pathParametersPlugin } from './twig/pathParameters'; import { pathParametersPlugin } from './twig/pathParameters';
@@ -96,7 +95,6 @@ const syntaxExtensions: Record<
url: url, url: url,
pairs: pairs, pairs: pairs,
text: text, text: text,
timeline: timeline,
markdown: markdown, markdown: markdown,
}; };

View File

@@ -1,12 +0,0 @@
import { LanguageSupport, LRLanguage } from '@codemirror/language';
import { parser } from './timeline';
export const timelineLanguage = LRLanguage.define({
name: 'timeline',
parser,
languageData: {},
});
export function timeline() {
return new LanguageSupport(timelineLanguage);
}

View File

@@ -1,7 +0,0 @@
import { styleTags, tags as t } from '@lezer/highlight';
export const highlight = styleTags({
OutgoingText: t.propertyName, // > lines - primary color (matches timeline icons)
IncomingText: t.tagName, // < lines - info color (matches timeline icons)
InfoText: t.comment, // * lines - subtle color (matches timeline icons)
});

View File

@@ -1,21 +0,0 @@
@top Timeline { line* }
line { OutgoingLine | IncomingLine | InfoLine | PlainLine }
@skip {} {
OutgoingLine { OutgoingText Newline }
IncomingLine { IncomingText Newline }
InfoLine { InfoText Newline }
PlainLine { PlainText Newline }
}
@tokens {
OutgoingText { "> " ![\n]* }
IncomingText { "< " ![\n]* }
InfoText { "* " ![\n]* }
PlainText { ![\n]+ }
Newline { "\n" }
@precedence { OutgoingText, IncomingText, InfoText, PlainText }
}
@external propSource highlight from "./highlight"

View File

@@ -1,12 +0,0 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
Timeline = 1,
OutgoingLine = 2,
OutgoingText = 3,
Newline = 4,
IncomingLine = 5,
IncomingText = 6,
InfoLine = 7,
InfoText = 8,
PlainLine = 9,
PlainText = 10

View File

@@ -1,18 +0,0 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {highlight} from "./highlight"
export const parser = LRParser.deserialize({
version: 14,
states: "!pQQOPOOO`OPO'#C^OeOPO'#CaOjOPO'#CcOoOPO'#CeOOOO'#Ci'#CiOOOO'#Cg'#CgQQOPOOOOOO,58x,58xOOOO,58{,58{OOOO,58},58}OOOO,59P,59POOOO-E6e-E6e",
stateData: "z~ORPOUQOWROYSO~OSWO~OSXO~OSYO~OSZO~ORUWYW~",
goto: "m^PP_PP_P_P_PcPiTTOVQVOR[VTUOV",
nodeNames: "⚠ Timeline OutgoingLine OutgoingText Newline IncomingLine IncomingText InfoLine InfoText PlainLine PlainText",
maxTerm: 13,
propSources: [highlight],
skippedNodes: [0],
repeatNodeCount: 1,
tokenData: "%h~RZOYtYZ!]Zztz{!b{!^t!^!_#d!_!`t!`!a$f!a;'St;'S;=`!V<%lOt~ySY~OYtZ;'St;'S;=`!V<%lOt~!YP;=`<%lt~!bOS~~!gUY~OYtZptpq!yq;'St;'S;=`!V<%lOt~#QSW~Y~OY!yZ;'S!y;'S;=`#^<%lO!y~#aP;=`<%l!y~#iUY~OYtZptpq#{q;'St;'S;=`!V<%lOt~$SSU~Y~OY#{Z;'S#{;'S;=`$`<%lO#{~$cP;=`<%l#{~$kUY~OYtZptpq$}q;'St;'S;=`!V<%lOt~%USR~Y~OY$}Z;'S$};'S;=`%b<%lO$}~%eP;=`<%l$}",
tokenizers: [0],
topRules: {"Timeline":[0,1]},
tokenPrec: 36
})

View File

@@ -1,258 +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';
import { IconButton } from './IconButton';
import classNames from 'classnames';
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; onClose: () => void }) => 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);
const [isPanelOpen, setIsPanelOpen] = useState(false);
// 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,
closePanel: () => setIsPanelOpen(false),
openPanel: () => setIsPanelOpen(true),
});
// Handle virtualizer ready callback
const handleVirtualizerReady = useCallback(
(virtualizer: Virtualizer<HTMLDivElement, Element>) => {
virtualizerRef.current = virtualizer;
},
[],
);
// Handle row click - select and open panel, scroll into view
const handleRowClick = useCallback(
(index: number) => {
setActiveIndex(index);
setIsPanelOpen(true);
// Scroll to ensure selected item is visible after panel opens
requestAnimationFrame(() => {
virtualizerRef.current?.scrollToIndex(index, { align: 'auto' });
});
},
[setActiveIndex],
);
const handleClose = useCallback(() => {
setIsPanelOpen(false);
}, []);
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 && isPanelOpen
? ({ 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, onClose: handleClose })}
</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: string;
prefix?: ReactNode;
timestamp?: string;
actions?: EventDetailAction[];
copyText?: string;
onClose?: () => void;
}
export function EventDetailHeader({
title,
prefix,
timestamp,
actions,
copyText,
onClose,
}: 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">
<HStack space={2} className="items-center min-w-0">
{prefix}
<h3 className="font-semibold select-auto cursor-auto truncate">{title}</h3>
</HStack>
<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 ml-2">{formattedTime}</span>
)}
<div className={classNames(copyText != null || formattedTime || (actions ?? []).length > 0 && "border-l border-l-surface-highlight ml-2 pl-3")}>
<IconButton color="custom" className="text-text-subtle -mr-3" size="xs" icon="x" title="Close event panel" onClick={onClose} />
</div>
</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>
{timestamp && <div className="opacity-50">{format(`${timestamp}Z`, 'HH:mm:ss.SSS')}</div>}
</button>
</div>
);
}

View File

@@ -19,8 +19,7 @@ export function HttpResponseDurationTag({ response }: Props) {
return () => clearInterval(timeout.current); return () => clearInterval(timeout.current);
}, [response.createdAt, response.state]); }, [response.createdAt, response.state]);
const dnsValue = response.elapsedDns > 0 ? formatMillis(response.elapsedDns) : '--'; const title = `HEADER: ${formatMillis(response.elapsedHeaders)}\nTOTAL: ${formatMillis(response.elapsed)}`;
const title = `DNS: ${dnsValue}\nHEADER: ${formatMillis(response.elapsedHeaders)}\nTOTAL: ${formatMillis(response.elapsed)}`;
const elapsed = response.state === 'closed' ? response.elapsed : fallbackElapsed; const elapsed = response.state === 'closed' ? response.elapsed : fallbackElapsed;

View File

@@ -78,7 +78,6 @@ import {
GitCommitVerticalIcon, GitCommitVerticalIcon,
GitForkIcon, GitForkIcon,
GitPullRequestIcon, GitPullRequestIcon,
GlobeIcon,
GripVerticalIcon, GripVerticalIcon,
HandIcon, HandIcon,
HardDriveDownloadIcon, HardDriveDownloadIcon,
@@ -213,7 +212,6 @@ const icons = {
git_commit_vertical: GitCommitVerticalIcon, git_commit_vertical: GitCommitVerticalIcon,
git_fork: GitForkIcon, git_fork: GitForkIcon,
git_pull_request: GitPullRequestIcon, git_pull_request: GitPullRequestIcon,
globe: GlobeIcon,
grip_vertical: GripVerticalIcon, grip_vertical: GripVerticalIcon,
circle_off: CircleOffIcon, circle_off: CircleOffIcon,
hand: HandIcon, hand: HandIcon,

View File

@@ -4,15 +4,15 @@ import type { HTMLAttributes, ReactElement, ReactNode } from 'react';
interface Props { interface Props {
children: children:
| ReactElement<HTMLAttributes<HTMLTableColElement>> | ReactElement<HTMLAttributes<HTMLTableColElement>>
| (ReactElement<HTMLAttributes<HTMLTableColElement>> | null)[]; | ReactElement<HTMLAttributes<HTMLTableColElement>>[];
} }
export function KeyValueRows({ children }: Props) { export function KeyValueRows({ children }: Props) {
const childArray = Array.isArray(children) ? children.filter(Boolean) : [children]; children = Array.isArray(children) ? children : [children];
return ( return (
<table className="text-editor font-mono min-w-0 w-full mb-auto"> <table className="text-editor font-mono min-w-0 w-full mb-auto">
<tbody className="divide-y divide-surface-highlight"> <tbody className="divide-y divide-surface-highlight">
{childArray.map((child, i) => ( {children.map((child, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: none // biome-ignore lint/suspicious/noArrayIndexKey: none
<tr key={i}>{child}</tr> <tr key={i}>{child}</tr>
))} ))}

View File

@@ -136,8 +136,8 @@ export function PairEditor({
rowsRef.current[id] = n; rowsRef.current[id] = n;
const validHandles = Object.values(rowsRef.current).filter((v) => v != null); const validHandles = Object.values(rowsRef.current).filter((v) => v != null);
// Use >= because more might be added if an ID of one changes (eg. editing placeholder in URL regenerates fresh pairs every keystroke) // NOTE: Ignore the last placeholder pair
const ready = validHandles.length >= pairs.length - 1; const ready = validHandles.length === pairs.length - 1;
if (ready) { if (ready) {
setRef?.(handle); setRef?.(handle);
} }

View File

@@ -10,17 +10,8 @@ import {
useSensors, useSensors,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import classNames from 'classnames'; import classNames from 'classnames';
import type { ReactNode, Ref } from 'react'; import type { ReactNode } from 'react';
import { import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { useKeyValue } from '../../../hooks/useKeyValue'; import { useKeyValue } from '../../../hooks/useKeyValue';
import { computeSideForDragMove } from '../../../lib/dnd'; import { computeSideForDragMove } from '../../../lib/dnd';
import { DropMarker } from '../../DropMarker'; import { DropMarker } from '../../DropMarker';
@@ -46,120 +37,41 @@ export type TabItem =
rightSlot?: ReactNode; rightSlot?: ReactNode;
}; };
interface TabsStorage {
order: string[];
activeTabs: Record<string, string>;
}
export interface TabsRef {
/** Programmatically set the active tab */
setActiveTab: (value: string) => void;
}
interface Props { interface Props {
label: string; label: string;
/** Default tab value. If not provided, defaults to first tab. */ value?: string;
defaultValue?: string; onChangeValue: (value: string) => void;
/** Called when active tab changes */
onChangeValue?: (value: string) => void;
tabs: TabItem[]; tabs: TabItem[];
tabListClassName?: string; tabListClassName?: string;
className?: string; className?: string;
children: ReactNode; children: ReactNode;
addBorders?: boolean; addBorders?: boolean;
layout?: 'horizontal' | 'vertical'; layout?: 'horizontal' | 'vertical';
/** Storage key for persisting tab order and active tab. When provided, enables drag-to-reorder and active tab persistence. */
storageKey?: string | string[]; storageKey?: string | string[];
/** Key to identify which context this tab belongs to (e.g., request ID). Used for per-context active tab persistence. */
activeTabKey?: string;
} }
export const Tabs = forwardRef<TabsRef, Props>(function Tabs( export function Tabs({
{ value,
defaultValue, onChangeValue,
onChangeValue: onChangeValueProp, label,
label, children,
children, tabs: originalTabs,
tabs: originalTabs, className,
className, tabListClassName,
tabListClassName, addBorders,
addBorders, layout = 'vertical',
layout = 'vertical', storageKey,
storageKey, }: Props) {
activeTabKey,
}: Props,
forwardedRef: Ref<TabsRef>,
) {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
const reorderable = !!storageKey; const reorderable = !!storageKey;
// Use key-value storage for persistence if storageKey is provided // Use key-value storage for persistence if storageKey is provided
// Handle migration from old format (string[]) to new format (TabsStorage) const { value: savedOrder, set: setSavedOrder } = useKeyValue<string[]>({
const { value: rawStorage, set: setStorage } = useKeyValue<TabsStorage | string[]>({ namespace: 'global',
namespace: 'no_sync', key: storageKey ?? ['tabs_order', 'default'],
key: storageKey ?? ['tabs', 'default'], fallback: [],
fallback: { order: [], activeTabs: {} },
}); });
// Migrate old format (string[]) to new format (TabsStorage)
const storage: TabsStorage = Array.isArray(rawStorage)
? { order: rawStorage, activeTabs: {} }
: (rawStorage ?? { order: [], activeTabs: {} });
const savedOrder = storage.order;
// Get the active tab value - prefer storage (if activeTabKey), then defaultValue, then first tab
const storedActiveTab = activeTabKey ? storage?.activeTabs?.[activeTabKey] : undefined;
const [internalValue, setInternalValue] = useState<string | undefined>(undefined);
const value = storedActiveTab ?? internalValue ?? defaultValue ?? originalTabs[0]?.value;
// Helper to normalize storage (handle migration from old format)
const normalizeStorage = useCallback(
(s: TabsStorage | string[]): TabsStorage =>
Array.isArray(s) ? { order: s, activeTabs: {} } : s,
[],
);
// Handle tab change - update internal state, storage if we have a key, and call prop callback
const onChangeValue = useCallback(
async (newValue: string) => {
setInternalValue(newValue);
if (storageKey && activeTabKey) {
await setStorage((s) => {
const normalized = normalizeStorage(s);
return {
...normalized,
activeTabs: { ...normalized.activeTabs, [activeTabKey]: newValue },
};
});
}
onChangeValueProp?.(newValue);
},
[storageKey, activeTabKey, setStorage, onChangeValueProp, normalizeStorage],
);
// Expose imperative methods via ref
useImperativeHandle(
forwardedRef,
() => ({
setActiveTab: (value: string) => {
onChangeValue(value);
},
}),
[onChangeValue],
);
// Helper to save order
const setSavedOrder = useCallback(
async (order: string[]) => {
await setStorage((s) => {
const normalized = normalizeStorage(s);
return { ...normalized, order };
});
},
[setStorage, normalizeStorage],
);
// State for ordered tabs // State for ordered tabs
const [orderedTabs, setOrderedTabs] = useState<TabItem[]>(originalTabs); const [orderedTabs, setOrderedTabs] = useState<TabItem[]>(originalTabs);
const [isDragging, setIsDragging] = useState<TabItem | null>(null); const [isDragging, setIsDragging] = useState<TabItem | null>(null);
@@ -200,6 +112,8 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
const tabs = storageKey ? orderedTabs : originalTabs; const tabs = storageKey ? orderedTabs : originalTabs;
value = value ?? tabs[0]?.value;
// Update tabs when value changes // Update tabs when value changes
useEffect(() => { useEffect(() => {
const tabs = ref.current?.querySelectorAll<HTMLDivElement>('[data-tab]'); const tabs = ref.current?.querySelectorAll<HTMLDivElement>('[data-tab]');
@@ -301,10 +215,13 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
items.push( items.push(
<div <div
key={`marker-${t.value}`} key={`marker-${t.value}`}
className={classNames('relative', layout === 'vertical' ? 'w-0' : 'h-0')} className={classNames(
'relative',
layout === 'vertical' ? 'w-0' : 'h-0',
)}
> >
<DropMarker orientation={layout === 'vertical' ? 'vertical' : 'horizontal'} /> <DropMarker orientation={layout === 'vertical' ? 'vertical' : 'horizontal'} />
</div>, </div>
); );
} }
@@ -318,7 +235,7 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
reorderable={reorderable} reorderable={reorderable}
isDragging={isDragging?.value === t.value} isDragging={isDragging?.value === t.value}
onChangeValue={onChangeValue} onChangeValue={onChangeValue}
/>, />
); );
}); });
return items; return items;
@@ -347,7 +264,12 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
> >
{tabButtons} {tabButtons}
{hoveredIndex === tabs.length && ( {hoveredIndex === tabs.length && (
<div className={classNames('relative', layout === 'vertical' ? 'w-0' : 'h-0')}> <div
className={classNames(
'relative',
layout === 'vertical' ? 'w-0' : 'h-0',
)}
>
<DropMarker orientation={layout === 'vertical' ? 'vertical' : 'horizontal'} /> <DropMarker orientation={layout === 'vertical' ? 'vertical' : 'horizontal'} />
</div> </div>
)} )}
@@ -398,7 +320,7 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
{children} {children}
</div> </div>
); );
}); }
interface TabButtonProps { interface TabButtonProps {
tab: TabItem; tab: TabItem;
@@ -407,7 +329,7 @@ interface TabButtonProps {
layout: 'horizontal' | 'vertical'; layout: 'horizontal' | 'vertical';
reorderable: boolean; reorderable: boolean;
isDragging: boolean; isDragging: boolean;
onChangeValue?: (value: string) => void; onChangeValue: (value: string) => void;
overlay?: boolean; overlay?: boolean;
} }
@@ -428,8 +350,6 @@ function TabButton({
} = useDraggable({ } = useDraggable({
id: tab.value, id: tab.value,
disabled: !reorderable, disabled: !reorderable,
// The button inside handles focus
attributes: { tabIndex: -1 },
}); });
const { setNodeRef: setDroppableRef } = useDroppable({ const { setNodeRef: setDroppableRef } = useDroppable({
id: tab.value, id: tab.value,
@@ -449,12 +369,7 @@ function TabButton({
const btnProps: Partial<ButtonProps> = { const btnProps: Partial<ButtonProps> = {
color: 'custom', color: 'custom',
justify: layout === 'horizontal' ? 'start' : 'center', justify: layout === 'horizontal' ? 'start' : 'center',
onClick: isActive onClick: isActive ? undefined : () => onChangeValue(tab.value),
? undefined
: (e: React.MouseEvent) => {
e.preventDefault(); // Prevent dropdown from opening on first click
onChangeValue?.(tab.value);
},
className: classNames( className: classNames(
'flex items-center rounded whitespace-nowrap', 'flex items-center rounded whitespace-nowrap',
'!px-2 ml-[1px]', '!px-2 ml-[1px]',
@@ -511,7 +426,11 @@ function TabButton({
); );
} }
return ( return (
<Button leftSlot={tab.leftSlot} rightSlot={tab.rightSlot} {...btnProps}> <Button
leftSlot={tab.leftSlot}
rightSlot={tab.rightSlot}
{...btnProps}
>
{'label' in tab && tab.label ? tab.label : tab.value} {'label' in tab && tab.label ? tab.label : tab.value}
</Button> </Button>
); );
@@ -554,32 +473,3 @@ export const TabContent = memo(function TabContent({
</ErrorBoundary> </ErrorBoundary>
); );
}); });
/**
* Programmatically set the active tab for a Tabs component that uses storageKey + activeTabKey.
* This is useful when you need to change the tab from outside the component (e.g., in response to an event).
*/
export async function setActiveTab({
storageKey,
activeTabKey,
value,
}: {
storageKey: string;
activeTabKey: string;
value: string;
}): Promise<void> {
const { getKeyValue, setKeyValue } = await import('../../../lib/keyValueStore');
const current = getKeyValue<TabsStorage>({
namespace: 'no_sync',
key: storageKey,
fallback: { order: [], activeTabs: {} },
});
await setKeyValue({
namespace: 'no_sync',
key: storageKey,
value: {
...current,
activeTabs: { ...current.activeTabs, [activeTabKey]: value },
},
});
}

Some files were not shown because too many files have changed in this diff Show More