Compare commits

...

17 Commits

Author SHA1 Message Date
Gregory Schier
ea730d0184 Fix clicking URL placeholder params not focusing value input
The PairEditor ref callback used strict equality to determine when all
rows were ready, but placeholder params (like :id) regenerate fresh IDs
on every keystroke, causing rowsRef to accumulate entries. Using >=
allows the ref to be set even when there are more registered rows than
current pairs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 11:28:09 -08:00
Gregory Schier
fe706998d4 Fix cursor style on template tags 2026-01-14 10:36:42 -08:00
Gregory Schier
99209e088f Consolidate tab persistence logic into Tabs component
- Move active tab persistence into Tabs component with storageKey + activeTabKey props
- Change value prop to defaultValue so callers don't manage tab state
- Add TabsRef with setActiveTab method for programmatic tab switching
- Restore request_pane.focus_tab listener for :param placeholder clicks
- Update all Tab consumers to use new pattern

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:32:10 -08:00
Gregory Schier
3eb29ff2fe Fix gRPC schema refresh not invalidating cache
The skip_cache flag in services() called reflect(), but reflect() had its
own cache check that returned early. Simplified by removing skip_cache and
always invalidating the pool in cmd_grpc_reflect, since that command is
only called when fresh schema is needed.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 08:20:03 -08:00
Gregory Schier
b759003c83 Fix events from old connections showing in new connections
Events from previous WebSocket/gRPC connections and HTTP responses were
persisting in the store and displaying in new connections. Added filter
parameter to mergeModelsInStore that clears old events when switching
connections, plus render-time filtering as a safety net.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 07:58:32 -08:00
Gregory Schier
6cba38ac89 Strip empty headers before sending 2026-01-14 06:59:05 -08:00
Gregory Schier
ba8f85baaf Update feedback links 2026-01-14 06:45:45 -08:00
Gregory Schier
9970d5fa6f Fix lint issues 2026-01-13 09:32:52 -08:00
Gregory Schier
d550b42ca3 Add count badge to DNS tab and make workspace settings tabs reorderable 2026-01-13 09:24:56 -08:00
Gregory Schier
2e1f0cb53f Adjust tab list margins 2026-01-13 09:24:53 -08:00
Gregory Schier
eead422ada Fix HeadersEditor padding when no inherited headers 2026-01-13 09:24:48 -08:00
Gregory Schier
b5753da3b7 Fix dropdown opening on first click of inactive tab 2026-01-13 09:24:44 -08:00
Gregory Schier
ae2f2459e9 Improve EventViewer UX
- Separate selected item from panel open state (closing panel keeps selection)
- Scroll selected item into view when detail panel opens
- Enter/Space opens detail panel, Escape closes it
- Remove browser focus outline on scroll container
- Add prefix prop to EventDetailHeader for labels
- Make timestamp optional in EventViewerRow
- Add close button to EventDetailHeader
- Fix title truncation with min-w-0
- Consolidate HttpResponseTimeline title generation
- Add ID/event labels to SSE detail header
- Remove fake timestamp from SSE events

Closes https://feedback.yaak.app/p/feedback-on-sse-viewer-ux-in-yaak
2026-01-13 09:05:50 -08:00
Gregory Schier
306e6f358a feat: Add DNS timings and resolution overrides (#360)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 08:42:22 -08:00
Gregory Schier
822d52a57e Better logging for plugin timeouts 2026-01-13 07:26:32 -08:00
Gregory Schier
e665ce04df Fix plugins commands 2026-01-12 12:58:39 -08:00
Alex Coté
e4828e1b17 Fix README icon (#361) 2026-01-12 08:08:54 -08:00
67 changed files with 1029 additions and 452 deletions

View File

@@ -1,6 +1,6 @@
<p align="center">
<a href="https://github.com/JamesIves/github-sponsors-readme-action">
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/src-tauri/icons/icon.png">
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/crates-tauri/yaak-app/icons/icon.png">
</a>
</p>
@@ -64,7 +64,7 @@ visit [`DEVELOPMENT.md`](DEVELOPMENT.md) for tips on setting up your environment
## Useful Resources
- [Feedback and Bug Reports](https://feedback.yaak.app)
- [Documentation](https://feedback.yaak.app/help)
- [Documentation](https://yaak.app/docs)
- [Yaak vs Postman](https://yaak.app/alternatives/postman)
- [Yaak vs Bruno](https://yaak.app/alternatives/bruno)
- [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::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::{parse_and_render, render_json_value_raw, RenderOptions};
use yaak_templates::{RenderOptions, parse_and_render, render_json_value_raw};
#[derive(Parser)]
#[command(name = "yaakcli")]
@@ -149,14 +149,7 @@ async fn render_http_request(
// Apply path placeholders (e.g., /users/:id -> /users/123)
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]
@@ -169,16 +162,10 @@ async fn main() {
}
// 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(|| {
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");
@@ -191,9 +178,7 @@ async fn main() {
// Initialize encryption manager for secure() template function
// 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
let vendored_plugin_dir = data_dir.join("vendored-plugins");
@@ -203,9 +188,8 @@ async fn main() {
let node_bin_path = PathBuf::from("node");
// Find the plugin runtime - check YAAK_PLUGIN_RUNTIME env var, then fallback to development path
let plugin_runtime_main = std::env::var("YAAK_PLUGIN_RUNTIME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
let plugin_runtime_main =
std::env::var("YAAK_PLUGIN_RUNTIME").map(PathBuf::from).unwrap_or_else(|_| {
// Development fallback: look relative to crate root
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs")
@@ -226,14 +210,10 @@ async fn main() {
// Initialize plugins from database
let plugins = db.list_plugins().unwrap_or_default();
if !plugins.is_empty() {
let errors = plugin_manager
.initialize_all_plugins(plugins, &PluginContext::new_empty())
.await;
let errors =
plugin_manager.initialize_all_plugins(plugins, &PluginContext::new_empty()).await;
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);
}
}
@@ -249,9 +229,7 @@ async fn main() {
}
}
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() {
println!("No requests found in workspace {}", workspace_id);
} else {
@@ -261,9 +239,7 @@ async fn main() {
}
}
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
let environment_chain = db
@@ -318,18 +294,13 @@ async fn main() {
}))
} else {
// 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
};
// Send the request
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
if let Some(handle) = verbose_handle {
@@ -383,18 +354,13 @@ async fn main() {
}
}))
} else {
tokio::spawn(async move {
while event_rx.recv().await.is_some() {}
});
tokio::spawn(async move { while event_rx.recv().await.is_some() {} });
None
};
// Send the request
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 {
let _ = handle.await;
@@ -421,12 +387,7 @@ async fn main() {
let (body, _stats) = response.text().await.expect("Failed to read response body");
println!("{}", body);
}
Commands::Create {
workspace_id,
name,
method,
url,
} => {
Commands::Create { workspace_id, name, method, url } => {
let request = HttpRequest {
workspace_id,
name,

View File

@@ -1,5 +1,5 @@
use crate::error::Result;
use crate::PluginContextExt;
use crate::error::Result;
use std::sync::Arc;
use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow, command};
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
@@ -54,7 +54,12 @@ pub(crate) async fn cmd_secure_template<R: Runtime>(
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let plugin_context = window.plugin_context();
Ok(encrypt_secure_template_function(plugin_manager, encryption_manager, &plugin_context, template)?)
Ok(encrypt_secure_template_function(
plugin_manager,
encryption_manager,
&plugin_context,
template,
)?)
}
#[command]

View File

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

View File

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

View File

@@ -1,9 +1,13 @@
use crate::PluginContextExt;
use crate::error::Error::GenericError;
use crate::error::Result;
use crate::models_ext::BlobManagerExt;
use crate::models_ext::QueryManagerExt;
use crate::render::render_http_request;
use log::{debug, warn};
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicI32, Ordering};
use std::time::{Duration, Instant};
use tauri::{AppHandle, Manager, Runtime, WebviewWindow};
use tokio::fs::{File, create_dir_all};
@@ -15,22 +19,19 @@ use yaak_http::client::{
HttpConnectionOptions, HttpConnectionProxySetting, HttpConnectionProxySettingAuth,
};
use yaak_http::cookies::CookieStore;
use yaak_http::manager::HttpConnectionManager;
use yaak_http::manager::{CachedClient, HttpConnectionManager};
use yaak_http::sender::ReqwestSender;
use yaak_http::tee_reader::TeeReader;
use yaak_http::transaction::HttpTransaction;
use yaak_http::types::{
SendableBody, SendableHttpRequest, SendableHttpRequestOptions, append_query_params,
};
use crate::models_ext::BlobManagerExt;
use yaak_models::blob_manager::BodyChunk;
use yaak_models::models::{
CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseHeader,
HttpResponseState, ProxySetting, ProxySettingAuth,
};
use crate::models_ext::QueryManagerExt;
use yaak_models::util::UpdateSource;
use crate::PluginContextExt;
use yaak_plugins::events::{
CallHttpAuthenticationRequest, HttpHeader, PluginContext, RenderPurpose,
};
@@ -173,7 +174,12 @@ async fn send_http_request_inner<R: Runtime>(
let environment_id = environment.map(|e| e.id);
let workspace = window.db().get_workspace(workspace_id)?;
let (resolved, auth_context_id) = resolve_http_request(window, unrendered_request)?;
let cb = PluginTemplateCallback::new(plugin_manager.clone(), encryption_manager.clone(), &plugin_context, RenderPurpose::Send);
let cb = PluginTemplateCallback::new(
plugin_manager.clone(),
encryption_manager.clone(),
&plugin_context,
RenderPurpose::Send,
);
let env_chain =
window.db().resolve_environments(&workspace.id, folder_id, environment_id.as_deref())?;
let request = render_http_request(&resolved, env_chain, &cb, &RenderOptions::throw()).await?;
@@ -228,12 +234,13 @@ async fn send_http_request_inner<R: Runtime>(
None => None,
};
let client = connection_manager
let cached_client = connection_manager
.get_client(&HttpConnectionOptions {
id: plugin_context.id.clone(),
validate_certificates: workspace.setting_validate_certificates,
proxy: proxy_setting,
client_certificate,
dns_overrides: workspace.setting_dns_overrides.clone(),
})
.await?;
@@ -250,7 +257,7 @@ async fn send_http_request_inner<R: Runtime>(
let cookie_store = maybe_cookie_store.as_ref().map(|(cs, _)| cs.clone());
let result = execute_transaction(
client,
cached_client,
sendable_request,
response_ctx,
cancelled_rx.clone(),
@@ -310,7 +317,7 @@ pub fn resolve_http_request<R: Runtime>(
}
async fn execute_transaction<R: Runtime>(
client: reqwest::Client,
cached_client: CachedClient,
mut sendable_request: SendableHttpRequest,
response_ctx: &mut ResponseContext<R>,
mut cancelled_rx: Receiver<bool>,
@@ -321,7 +328,10 @@ async fn execute_transaction<R: Runtime>(
let workspace_id = response_ctx.response().workspace_id.clone();
let is_persisted = response_ctx.is_persisted();
let sender = ReqwestSender::with_client(client);
// Keep a reference to the resolver for DNS timing events
let resolver = cached_client.resolver.clone();
let sender = ReqwestSender::with_client(cached_client.client);
let transaction = match cookie_store {
Some(cs) => HttpTransaction::with_cookie_store(sender, cs),
None => HttpTransaction::new(sender),
@@ -346,21 +356,39 @@ async fn execute_transaction<R: Runtime>(
let (event_tx, mut event_rx) =
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)
if is_persisted {
let response_id = response_id.clone();
let app_handle = app_handle.clone();
let update_source = response_ctx.update_source.clone();
let workspace_id = workspace_id.clone();
let dns_elapsed = dns_elapsed.clone();
tokio::spawn(async move {
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 _ = app_handle.db().upsert_http_response_event(&db_event, &update_source);
}
});
} else {
// For ephemeral responses, just drain the events
tokio::spawn(async move { while event_rx.recv().await.is_some() {} });
// For ephemeral responses, just drain the events but still capture DNS timing
let dns_elapsed = dns_elapsed.clone();
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)
@@ -528,10 +556,14 @@ async fn execute_transaction<R: Runtime>(
// Final update with closed state and accurate byte count
response_ctx.update(|r| {
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.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))
}

View File

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

View File

@@ -189,7 +189,6 @@ async fn cmd_grpc_reflect<R: Runtime>(
request_id: &str,
environment_id: Option<&str>,
proto_files: Vec<String>,
skip_cache: Option<bool>,
window: WebviewWindow<R>,
app_handle: AppHandle<R>,
grpc_handle: State<'_, Mutex<GrpcHandle>>,
@@ -224,18 +223,21 @@ async fn cmd_grpc_reflect<R: Runtime>(
let settings = window.db().get_settings();
let client_certificate =
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();
Ok(grpc_handle
.lock()
.await
// Always invalidate cached pool when this command is called, to force re-reflection
let mut handle = grpc_handle.lock().await;
handle.invalidate_pool(&req.id, &uri, &proto_files);
Ok(handle
.services(
&req.id,
&uri,
&proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect(),
&proto_files,
&metadata,
workspace.setting_validate_certificates,
client_certificate,
skip_cache.unwrap_or(false),
)
.await
.map_err(|e| GenericError(e.to_string()))?)
@@ -1684,6 +1686,13 @@ pub fn run() {
git_ext::cmd_git_add_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
ws_ext::cmd_ws_upsert_request,
ws_ext::cmd_ws_duplicate_request,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
// 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 EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
@@ -18,4 +20,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 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 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>, };

View File

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

View File

@@ -2,6 +2,8 @@ use crate::dns::LocalhostResolver;
use crate::error::Result;
use log::{debug, info, warn};
use reqwest::{Client, Proxy, redirect};
use std::sync::Arc;
use yaak_models::models::DnsOverride;
use yaak_tls::{ClientCertificateConfig, get_tls_config};
#[derive(Clone)]
@@ -28,10 +30,14 @@ pub struct HttpConnectionOptions {
pub validate_certificates: bool,
pub proxy: HttpConnectionProxySetting,
pub client_certificate: Option<ClientCertificateConfig>,
pub dns_overrides: Vec<DnsOverride>,
}
impl HttpConnectionOptions {
pub(crate) fn build_client(&self) -> Result<Client> {
/// Build a reqwest Client and return it along with the DNS resolver.
/// 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()
.connection_verbose(true)
.redirect(redirect::Policy::none())
@@ -40,15 +46,19 @@ impl HttpConnectionOptions {
.no_brotli()
.no_deflate()
.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
let config =
get_tls_config(self.validate_certificates, true, self.client_certificate.clone())?;
client = client.use_preconfigured_tls(config);
// Configure DNS resolver
client = client.dns_resolver(LocalhostResolver::new());
// Configure DNS resolver - keep a reference to configure per-request
let resolver = LocalhostResolver::new(self.dns_overrides.clone());
client = client.dns_resolver(resolver.clone());
// Configure proxy
match self.proxy.clone() {
@@ -69,7 +79,7 @@ impl HttpConnectionOptions {
self.client_certificate.is_some()
);
Ok(client.build()?)
Ok((client.build()?, resolver))
}
}

View File

@@ -1,53 +1,185 @@
use crate::sender::HttpResponseEvent;
use hyper_util::client::legacy::connect::dns::{
GaiResolver as HyperGaiResolver, Name as HyperName,
};
use log::info;
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::str::FromStr;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{RwLock, mpsc};
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)]
pub struct LocalhostResolver {
fallback: HyperGaiResolver,
event_tx: Arc<RwLock<Option<mpsc::Sender<HttpResponseEvent>>>>,
overrides: Arc<HashMap<String, ResolvedOverride>>,
}
impl LocalhostResolver {
pub fn new() -> Arc<Self> {
pub fn new(dns_overrides: Vec<DnsOverride>) -> Arc<Self> {
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 {
fn resolve(&self, name: Name) -> Resolving {
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");
if is_localhost {
let hostname = host.clone();
// Port 0 is fine; reqwest replaces it with the URL's explicit
// port or the schemes default (80/443, etc.).
// (See docs note below.)
// port or the scheme's default (80/443, etc.).
let addrs: Vec<SocketAddr> = vec![
SocketAddr::new(IpAddr::V4(Ipv4Addr::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 {
// 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()))
});
}
// Fall back to system DNS
let mut fallback = self.fallback.clone();
let name_str = name.as_str().to_string();
let hostname = host.clone();
Box::pin(async move {
match HyperName::from_str(&name_str) {
Ok(n) => fallback
.call(n)
.await
.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 start = Instant::now();
let result = match HyperName::from_str(&name_str) {
Ok(n) => fallback.call(n).await,
Err(e) => return 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,4 +1,5 @@
use crate::client::HttpConnectionOptions;
use crate::dns::LocalhostResolver;
use crate::error::Result;
use log::info;
use reqwest::Client;
@@ -7,8 +8,15 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
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 {
connections: Arc<RwLock<BTreeMap<String, (Client, Instant)>>>,
connections: Arc<RwLock<BTreeMap<String, (CachedClient, Instant)>>>,
ttl: Duration,
}
@@ -20,21 +28,26 @@ impl HttpConnectionManager {
}
}
pub async fn get_client(&self, opt: &HttpConnectionOptions) -> Result<Client> {
pub async fn get_client(&self, opt: &HttpConnectionOptions) -> Result<CachedClient> {
let mut connections = self.connections.write().await;
let id = opt.id.clone();
// Clean old connections
connections.retain(|_, (_, last_used)| last_used.elapsed() <= self.ttl);
if let Some((c, last_used)) = connections.get_mut(&id) {
if let Some((cached, last_used)) = connections.get_mut(&id) {
info!("Re-using HTTP client {id}");
*last_used = Instant::now();
return Ok(c.clone());
return Ok(CachedClient {
client: cached.client.clone(),
resolver: cached.resolver.clone(),
});
}
let c = opt.build_client()?;
connections.insert(id.into(), (c.clone(), Instant::now()));
Ok(c)
let (client, resolver) = opt.build_client()?;
let cached = CachedClient { client: client.clone(), resolver: resolver.clone() };
connections.insert(id.into(), (cached, Instant::now()));
Ok(CachedClient { client, resolver })
}
}

View File

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

View File

@@ -12,6 +12,8 @@ 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 DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
export type EncryptedKey = { encryptedKey: string, };
@@ -38,7 +40,7 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: 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, 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, 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 HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
@@ -47,7 +49,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* 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, };
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 HttpResponseHeader = { name: string, value: string, };
@@ -91,6 +93,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 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 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 WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };

View File

@@ -209,12 +209,24 @@ export function replaceModelsInStore<
export function mergeModelsInStore<
M extends AnyModel['model'],
T extends Extract<AnyModel, { model: M }>,
>(model: M, models: T[]) {
>(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,

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,8 @@ 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 DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
export type EncryptedKey = { encryptedKey: string, };
@@ -38,7 +40,7 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: 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, 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, 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 HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
@@ -47,7 +49,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* 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, };
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 HttpResponseHeader = { name: string, value: string, };
@@ -77,6 +79,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 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 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 WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };

View File

@@ -80,10 +80,7 @@ pub async fn check_plugin_updates(
}
/// 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 query_pairs = url.query_pairs_mut();

View File

@@ -378,7 +378,8 @@ impl PluginManager {
plugins: Vec<PluginHandle>,
timeout_duration: Duration,
) -> Result<Vec<InternalEvent>> {
let label = format!("wait[{}.{}]", plugins.len(), payload.type_name());
let event_type = payload.type_name();
let label = format!("wait[{}.{}]", plugins.len(), event_type);
let (rx_id, mut rx) = self.subscribe(label.as_str()).await;
// 1. Build the events with IDs and everything
@@ -412,10 +413,21 @@ impl PluginManager {
// Timeout to prevent hanging forever if plugin doesn't respond
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!(
"Timeout waiting for plugin responses. Got {}/{} responses",
"Timeout ({:?}) waiting for {} responses. Got {}/{} responses. \
Non-responding plugins: [{}]",
timeout_duration,
event_type,
found_events.len(),
events_to_send.len()
events_to_send.len(),
non_responding.join(", ")
);
}

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
// 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 EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
@@ -20,4 +22,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 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 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>, };

View File

@@ -296,11 +296,7 @@ pub fn compute_sync_ops(
.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
// the sync process. Otherwise, they would be treated as deleted.
let include_private_environments = true;

View File

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

View File

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

View File

@@ -38,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 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 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 HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
@@ -47,7 +47,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* 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, };
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 HttpResponseHeader = { name: string, value: string, };

View File

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

View File

@@ -7,7 +7,6 @@ import { useContainerSize } from '../hooks/useContainerQuery';
import type { ReflectResponseService } from '../hooks/useGrpc';
import { useHeadersTab } from '../hooks/useHeadersTab';
import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
import { useKeyValue } from '../hooks/useKeyValue';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { resolvedModelName } from '../lib/resolvedModelName';
import { Button } from './core/Button';
@@ -69,11 +68,6 @@ export function GrpcRequestPane({
const authTab = useAuthTab(TAB_AUTH, activeRequest);
const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, 'Metadata');
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 urlContainerEl = useRef<HTMLDivElement>(null);
@@ -145,14 +139,6 @@ export function GrpcRequestPane({
[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(
(metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }),
[activeRequest],
@@ -265,12 +251,11 @@ export function GrpcRequestPane({
</HStack>
</div>
<Tabs
value={activeTab}
label="Request"
onChangeValue={setActiveTab}
tabs={tabs}
tabListClassName="mt-1 !mb-1.5"
storageKey="grpc_request_tabs_order"
storageKey="grpc_request_tabs"
activeTabKey={activeRequest.id}
>
<TabContent value="message">
<GrpcEditor

View File

@@ -33,9 +33,16 @@ export function HeadersEditor({
}: Props) {
const validInheritedHeaders =
inheritedHeaders?.filter((pair) => pair.enabled && (pair.name || pair.value)) ?? [];
const hasInheritedHeaders = validInheritedHeaders.length > 0;
return (
<div className="@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5">
{validInheritedHeaders.length > 0 ? (
<div
className={
hasInheritedHeaders
? '@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5'
: '@container w-full h-full'
}
>
{hasInheritedHeaders && (
<DetailsBanner
color="secondary"
className="text-sm"
@@ -63,8 +70,6 @@ export function HeadersEditor({
))}
</div>
</DetailsBanner>
) : (
<span />
)}
<PairOrBulkEditor
forceUpdateKey={forceUpdateKey}

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { ComponentType, CSSProperties } from 'react';
import { lazy, Suspense, useCallback, useMemo } from 'react';
import { useLocalStorage } from 'react-use';
import { lazy, Suspense, useMemo } from 'react';
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
@@ -58,10 +57,6 @@ const TAB_TIMELINE = 'timeline';
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const [activeTabs, setActiveTabs] = useLocalStorage<Record<string, string>>(
'responsePaneActiveTabs',
{},
);
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
@@ -129,13 +124,6 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
viewMode,
],
);
const activeTab = activeTabs?.[activeRequestId];
const setActiveTab = useCallback(
(tab: string) => {
setActiveTabs((r) => ({ ...r, [activeRequestId]: tab }));
},
[activeRequestId, setActiveTabs],
);
const cancel = useCancelHttpResponse(activeResponse?.id ?? null);
@@ -199,14 +187,12 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
)}
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
<Tabs
key={activeRequestId} // Freshen tabs on request change
value={activeTab}
onChangeValue={setActiveTab}
tabs={tabs}
label="Response"
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
tabListClassName="mt-0.5"
storageKey="http_response_tabs_order"
tabListClassName="mt-0.5 -mb-1.5"
storageKey="http_response_tabs"
activeTabKey={activeRequestId}
>
<TabContent value={TAB_BODY}>
<ErrorBoundary name="Http Response Viewer">

View File

@@ -47,8 +47,8 @@ function Inner({ response }: Props) {
/>
);
}}
renderDetail={({ event }) => (
<EventDetails event={event} showRaw={showRaw} setShowRaw={setShowRaw} />
renderDetail={({ event, onClose }) => (
<EventDetails event={event} showRaw={showRaw} setShowRaw={setShowRaw} onClose={onClose} />
)}
/>
);
@@ -64,10 +64,12 @@ function EventDetails({
event,
showRaw,
setShowRaw,
onClose,
}: {
event: HttpResponseEvent;
showRaw: boolean;
setShowRaw: (v: boolean) => void;
onClose: () => void;
}) {
const { label } = getEventDisplay(event.event);
const e = event.event;
@@ -81,72 +83,76 @@ function EventDetails({
];
// Determine the title based on event type
const title =
e.type === 'header_up'
? 'Header Sent'
: e.type === 'header_down'
? 'Header Received'
: label;
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;
}
})();
// Raw view - show plaintext representation
if (showRaw) {
const rawText = formatEventRaw(event.event);
return (
<div className="flex flex-col gap-2 h-full">
<EventDetailHeader title={title} timestamp={event.createdAt} actions={actions} />
<Editor language="text" defaultValue={rawText} readOnly stateKey={null} />
</div>
);
}
// Render content based on view mode and event type
const renderContent = () => {
// Raw view - show plaintext representation
if (showRaw) {
const rawText = formatEventRaw(event.event);
return <Editor language="text" defaultValue={rawText} readOnly stateKey={null} />;
}
// Headers - show name and value with Editor for JSON
if (e.type === 'header_up' || e.type === 'header_down') {
return (
<div className="flex flex-col gap-2 h-full">
<EventDetailHeader title={title} timestamp={event.createdAt} actions={actions} />
// Headers - show name and value
if (e.type === 'header_up' || e.type === 'header_down') {
return (
<KeyValueRows>
<KeyValueRow label="Header">{e.name}</KeyValueRow>
<KeyValueRow label="Value">{e.value}</KeyValueRow>
</KeyValueRows>
</div>
);
}
);
}
// Request URL - show method and path separately
if (e.type === 'send_url') {
return (
<div className="flex flex-col gap-2">
<EventDetailHeader title="Request" timestamp={event.createdAt} actions={actions} />
// Request URL - show method and path separately
if (e.type === 'send_url') {
return (
<KeyValueRows>
<KeyValueRow label="Method">
<HttpMethodTagRaw forceColor method={e.method} />
</KeyValueRow>
<KeyValueRow label="Path">{e.path}</KeyValueRow>
</KeyValueRows>
</div>
);
}
);
}
// Response status - show version and status separately
if (e.type === 'receive_url') {
return (
<div className="flex flex-col gap-2">
<EventDetailHeader title="Response" timestamp={event.createdAt} actions={actions} />
// Response status - show version and status separately
if (e.type === 'receive_url') {
return (
<KeyValueRows>
<KeyValueRow label="HTTP Version">{e.version}</KeyValueRow>
<KeyValueRow label="Status">
<HttpStatusTagRaw status={e.status} />
</KeyValueRow>
</KeyValueRows>
</div>
);
}
);
}
// Redirect - show status, URL, and behavior
if (e.type === 'redirect') {
return (
<div className="flex flex-col gap-2">
<EventDetailHeader title="Redirect" timestamp={event.createdAt} actions={actions} />
// Redirect - show status, URL, and behavior
if (e.type === 'redirect') {
return (
<KeyValueRows>
<KeyValueRow label="Status">
<HttpStatusTagRaw status={e.status} />
@@ -156,44 +162,50 @@ function EventDetails({
{e.behavior === 'drop_body' ? 'Drop body, change to GET' : 'Preserve method and body'}
</KeyValueRow>
</KeyValueRows>
</div>
);
}
);
}
// Settings - show as key/value
if (e.type === 'setting') {
return (
<div className="flex flex-col gap-2">
<EventDetailHeader title="Apply Setting" timestamp={event.createdAt} actions={actions} />
// Settings - show as key/value
if (e.type === 'setting') {
return (
<KeyValueRows>
<KeyValueRow label="Setting">{e.name}</KeyValueRow>
<KeyValueRow label="Value">{e.value}</KeyValueRow>
</KeyValueRows>
</div>
);
}
);
}
// Chunks - show formatted bytes
if (e.type === 'chunk_sent' || e.type === 'chunk_received') {
const direction = e.type === 'chunk_sent' ? 'Sent' : 'Received';
return (
<div className="flex flex-col gap-2">
<EventDetailHeader
title={`Data ${direction}`}
timestamp={event.createdAt}
actions={actions}
/>
<div className="font-mono text-editor">{formatBytes(e.bytes)}</div>
</div>
);
}
// Chunks - show formatted bytes
if (e.type === 'chunk_sent' || e.type === 'chunk_received') {
return <div className="font-mono text-editor">{formatBytes(e.bytes)}</div>;
}
// Default - use summary
const { summary } = getEventDisplay(event.event);
// DNS Resolution - show hostname, addresses, and timing
if (e.type === 'dns_resolved') {
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 (
<div className="flex flex-col gap-1">
<EventDetailHeader title={label} timestamp={event.createdAt} actions={actions} />
<div className="font-mono text-editor">{summary}</div>
<div className="flex flex-col gap-2 h-full">
<EventDetailHeader title={title} timestamp={event.createdAt} actions={actions} onClose={onClose} />
{renderContent()}
</div>
);
}
@@ -219,6 +231,11 @@ function formatEventRaw(event: HttpResponseEventData): string {
return `[${formatBytes(event.bytes)} sent]`;
case 'chunk_received':
return `[${formatBytes(event.bytes)} received]`;
case 'dns_resolved':
if (event.overridden) {
return `DNS override ${event.hostname}${event.addresses.join(', ')}`;
}
return `DNS resolved ${event.hostname}${event.addresses.join(', ')} (${event.duration}ms)`;
default:
return '[unknown event]';
}
@@ -250,7 +267,7 @@ function getEventDisplay(event: HttpResponseEventData): EventDisplay {
case 'redirect':
return {
icon: 'arrow_big_right_dash',
color: 'warning',
color: 'success',
label: 'Redirect',
summary: `Redirecting ${event.status} ${event.url}${event.behavior === 'drop_body' ? ' (drop body)' : ''}`,
};
@@ -297,6 +314,15 @@ function getEventDisplay(event: HttpResponseEventData): EventDisplay {
label: 'Chunk',
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:
return {
icon: 'info',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -80,7 +80,7 @@ export function AutoScroller<T>({
{header ?? <span aria-hidden />}
<div
ref={containerRef}
className="h-full w-full overflow-y-auto"
className="h-full w-full overflow-y-auto focus:outline-none"
onScroll={handleScroll}
tabIndex={focusable ? 0 : undefined}
>

View File

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

View File

@@ -10,6 +10,8 @@ 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 */
@@ -27,7 +29,7 @@ interface EventViewerProps<T> {
}) => ReactNode;
/** Render the detail pane for the selected event */
renderDetail?: (props: { event: T; index: number }) => ReactNode;
renderDetail?: (props: { event: T; index: number; onClose: () => void }) => ReactNode;
/** Optional header above the event list (e.g., connection status) */
header?: ReactNode;
@@ -73,6 +75,7 @@ export function EventViewer<T>({
onActiveIndexChange,
}: EventViewerProps<T>) {
const [activeIndex, setActiveIndexInternal] = useState<number | null>(null);
const [isPanelOpen, setIsPanelOpen] = useState(false);
// Wrap setActiveIndex to notify parent
const setActiveIndex = useCallback(
@@ -107,6 +110,8 @@ export function EventViewer<T>({
virtualizer: virtualizerRef.current,
isContainerFocused,
enabled: enableKeyboardNav,
closePanel: () => setIsPanelOpen(false),
openPanel: () => setIsPanelOpen(true),
});
// Handle virtualizer ready callback
@@ -117,14 +122,23 @@ export function EventViewer<T>({
[],
);
// Toggle selection on click
// Handle row click - select and open panel, scroll into view
const handleRowClick = useCallback(
(index: number) => {
setActiveIndex((prev) => (prev === index ? null : index));
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>;
}
@@ -168,14 +182,14 @@ export function EventViewer<T>({
</div>
)}
secondSlot={
activeEvent != null && renderDetail
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 })}
{renderDetail({ event: activeEvent, index: activeIndex ?? 0, onClose: handleClose })}
</div>
</div>
)
@@ -198,28 +212,30 @@ export interface EventDetailAction {
}
interface EventDetailHeaderProps {
/** Title/label for the event */
title: string;
/** Timestamp string (ISO format) - will be formatted as HH:mm:ss.SSS */
prefix?: ReactNode;
timestamp?: string;
/** Optional action buttons to show before timestamp */
actions?: EventDetailAction[];
/** Text to copy when copy button is clicked - renders a copy icon button after actions */
copyText?: string;
onClose?: () => void;
}
/** Standardized header for event detail panes */
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">
<h3 className="font-semibold select-auto cursor-auto">{title}</h3>
<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}>
@@ -231,8 +247,11 @@ export function EventDetailHeader({
<CopyIconButton text={copyText} size="xs" title="Copy" variant="border" iconSize="sm" />
)}
{formattedTime && (
<span className="text-text-subtlest font-mono text-editor">{formattedTime}</span>
<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

@@ -7,7 +7,7 @@ interface EventViewerRowProps {
onClick: () => void;
icon: ReactNode;
content: ReactNode;
timestamp: string;
timestamp?: string;
}
export function EventViewerRow({
@@ -25,13 +25,13 @@ export function EventViewerRow({
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',
isActive && 'bg-surface-active !text-text',
'text-text-subtle hover:text',
)}
>
{icon}
<div className="w-full truncate">{content}</div>
<div className="opacity-50">{format(`${timestamp}Z`, 'HH:mm:ss.SSS')}</div>
{timestamp && <div className="opacity-50">{format(`${timestamp}Z`, 'HH:mm:ss.SSS')}</div>}
</button>
</div>
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,8 +10,8 @@ import {
useSensors,
} from '@dnd-kit/core';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode, Ref } from 'react';
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { useKeyValue } from '../../../hooks/useKeyValue';
import { computeSideForDragMove } from '../../../lib/dnd';
import { DropMarker } from '../../DropMarker';
@@ -37,22 +37,37 @@ export type TabItem =
rightSlot?: ReactNode;
};
interface TabsStorage {
order: string[];
activeTabs: Record<string, string>;
}
export interface TabsRef {
/** Programmatically set the active tab */
setActiveTab: (value: string) => void;
}
interface Props {
label: string;
value?: string;
onChangeValue: (value: string) => void;
/** Default tab value. If not provided, defaults to first tab. */
defaultValue?: string;
/** Called when active tab changes */
onChangeValue?: (value: string) => void;
tabs: TabItem[];
tabListClassName?: string;
className?: string;
children: ReactNode;
addBorders?: boolean;
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[];
/** Key to identify which context this tab belongs to (e.g., request ID). Used for per-context active tab persistence. */
activeTabKey?: string;
}
export function Tabs({
value,
onChangeValue,
export const Tabs = forwardRef<TabsRef, Props>(function Tabs({
defaultValue,
onChangeValue: onChangeValueProp,
label,
children,
tabs: originalTabs,
@@ -61,17 +76,74 @@ export function Tabs({
addBorders,
layout = 'vertical',
storageKey,
}: Props) {
activeTabKey,
}: Props, forwardedRef: Ref<TabsRef>) {
const ref = useRef<HTMLDivElement | null>(null);
const reorderable = !!storageKey;
// Use key-value storage for persistence if storageKey is provided
const { value: savedOrder, set: setSavedOrder } = useKeyValue<string[]>({
namespace: 'global',
key: storageKey ?? ['tabs_order', 'default'],
fallback: [],
// Handle migration from old format (string[]) to new format (TabsStorage)
const { value: rawStorage, set: setStorage } = useKeyValue<TabsStorage | string[]>({
namespace: 'no_sync',
key: storageKey ?? ['tabs', 'default'],
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
const [orderedTabs, setOrderedTabs] = useState<TabItem[]>(originalTabs);
const [isDragging, setIsDragging] = useState<TabItem | null>(null);
@@ -112,8 +184,6 @@ export function Tabs({
const tabs = storageKey ? orderedTabs : originalTabs;
value = value ?? tabs[0]?.value;
// Update tabs when value changes
useEffect(() => {
const tabs = ref.current?.querySelectorAll<HTMLDivElement>('[data-tab]');
@@ -320,7 +390,7 @@ export function Tabs({
{children}
</div>
);
}
});
interface TabButtonProps {
tab: TabItem;
@@ -329,7 +399,7 @@ interface TabButtonProps {
layout: 'horizontal' | 'vertical';
reorderable: boolean;
isDragging: boolean;
onChangeValue: (value: string) => void;
onChangeValue?: (value: string) => void;
overlay?: boolean;
}
@@ -369,7 +439,12 @@ function TabButton({
const btnProps: Partial<ButtonProps> = {
color: 'custom',
justify: layout === 'horizontal' ? 'start' : 'center',
onClick: isActive ? undefined : () => onChangeValue(tab.value),
onClick: isActive
? undefined
: (e: React.MouseEvent) => {
e.preventDefault(); // Prevent dropdown from opening on first click
onChangeValue?.(tab.value);
},
className: classNames(
'flex items-center rounded whitespace-nowrap',
'!px-2 ml-[1px]',
@@ -473,3 +548,32 @@ export const TabContent = memo(function TabContent({
</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 },
},
});
}

View File

@@ -51,12 +51,13 @@ function ActualEventStreamViewer({ response }: Props) {
<span className="truncate text-xs">{event.data.slice(0, 1000)}</span>
</HStack>
}
timestamp={new Date().toISOString().slice(0, -1)} // SSE events don't have timestamps
/>
)}
renderDetail={({ event }) => (
renderDetail={({ event, index }) => (
<EventDetail
event={event}
index={index}
showLarge={showLarge}
showingLarge={showingLarge}
setShowLarge={setShowLarge}
@@ -69,12 +70,14 @@ function ActualEventStreamViewer({ response }: Props) {
function EventDetail({
event,
index,
showLarge,
showingLarge,
setShowLarge,
setShowingLarge,
}: {
event: ServerSentEvent;
index: number;
showLarge: boolean;
showingLarge: boolean;
setShowLarge: (v: boolean) => void;
@@ -87,7 +90,7 @@ function EventDetail({
return (
<div className="flex flex-col h-full">
<EventDetailHeader title="Message Received" />
<EventDetailHeader title="Message Received" prefix={<EventLabels event={event} index={index} />} />
{!showLarge && event.data.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden

View File

@@ -1,5 +1,5 @@
import { type MultipartPart, parseMultipart } from '@mjackson/multipart-parser';
import { lazy, Suspense, useMemo, useState } from 'react';
import { lazy, Suspense, useMemo } from 'react';
import { languageFromContentType } from '../../lib/contentType';
import { Banner } from '../core/Banner';
import { Icon } from '../core/Icon';
@@ -22,8 +22,6 @@ interface Props {
}
export function MultipartViewer({ data, boundary, idPrefix = 'multipart' }: Props) {
const [tab, setTab] = useState<string>();
const parseResult = useMemo(() => {
try {
const maxFileSize = 1024 * 1024 * 10; // 10MB
@@ -55,12 +53,10 @@ export function MultipartViewer({ data, boundary, idPrefix = 'multipart' }: Prop
return (
<Tabs
value={tab}
addBorders
label="Multipart"
layout="horizontal"
tabListClassName="border-r border-r-border"
onChangeValue={setTab}
tabs={parts.map((part) => ({
label: part.name ?? '',
value: part.name ?? '',

View File

@@ -9,6 +9,8 @@ interface UseEventViewerKeyboardProps {
virtualizer?: Virtualizer<HTMLDivElement, Element> | null;
isContainerFocused: () => boolean;
enabled?: boolean;
closePanel?: () => void;
openPanel?: () => void;
}
export function useEventViewerKeyboard({
@@ -18,6 +20,8 @@ export function useEventViewerKeyboard({
virtualizer,
isContainerFocused,
enabled = true,
closePanel,
openPanel,
}: UseEventViewerKeyboardProps) {
const selectPrev = useCallback(() => {
if (totalCount === 0) return;
@@ -62,9 +66,20 @@ export function useEventViewerKeyboard({
(e) => {
if (!enabled || !isContainerFocused()) return;
e.preventDefault();
setActiveIndex(null);
closePanel?.();
},
undefined,
[enabled, isContainerFocused, setActiveIndex],
[enabled, isContainerFocused, closePanel],
);
useKey(
(e) => e.key === 'Enter' || e.key === ' ',
(e) => {
if (!enabled || !isContainerFocused() || activeIndex == null) return;
e.preventDefault();
openPanel?.();
},
undefined,
[enabled, isContainerFocused, activeIndex, openPanel],
);
}

View File

@@ -54,7 +54,7 @@ export function useGrpc(
queryFn: () => {
const environmentId = jotaiStore.get(activeEnvironmentIdAtom);
return minPromiseMillis<ReflectResponseService[]>(
invokeCmd('cmd_grpc_reflect', { requestId, protoFiles, environmentId, skipCache: true }),
invokeCmd('cmd_grpc_reflect', { requestId, protoFiles, environmentId }),
300,
);
},

View File

@@ -6,7 +6,7 @@ import {
replaceModelsInStore,
} from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import { useEffect, useMemo } from 'react';
import { useEffect } from 'react';
export function useHttpResponseEvents(response: HttpResponse | null) {
const allEvents = useAtomValue(httpResponseEventsAtom);
@@ -17,18 +17,13 @@ export function useHttpResponseEvents(response: HttpResponse | null) {
return;
}
// Use merge instead of replace to preserve events that came in via model_write
// while we were fetching from the database
// Fetch events from database, filtering out events from other responses and merging atomically
invoke<HttpResponseEvent[]>('cmd_get_http_response_events', { responseId: response.id }).then(
(events) => mergeModelsInStore('http_response_event', events),
(events) =>
mergeModelsInStore('http_response_event', events, (e) => e.responseId === response.id),
);
}, [response?.id]);
// Filter events for the current response
const events = useMemo(
() => allEvents.filter((e) => e.responseId === response?.id),
[allEvents, response?.id],
);
const events = allEvents.filter((e) => e.responseId === response?.id);
return { data: events, error: null, isLoading: false };
}

View File

@@ -7,7 +7,7 @@ import {
replaceModelsInStore,
} from '@yaakapp-internal/models';
import { atom, useAtomValue } from 'jotai';
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage';
import { activeRequestIdAtom } from './useActiveRequestId';
@@ -60,7 +60,7 @@ export const activeGrpcConnectionAtom = atom<GrpcConnection | null>((get) => {
});
export function useGrpcEvents(connectionId: string | null) {
const events = useAtomValue(grpcEventsAtom);
const allEvents = useAtomValue(grpcEventsAtom);
useEffect(() => {
if (connectionId == null) {
@@ -68,12 +68,14 @@ export function useGrpcEvents(connectionId: string | null) {
return;
}
// Use merge instead of replace to preserve events that came in via model_write
// while we were fetching from the database
invoke<GrpcEvent[]>('models_grpc_events', { connectionId }).then((events) => {
mergeModelsInStore('grpc_event', events);
});
// Fetch events from database, filtering out events from other connections and merging atomically
invoke<GrpcEvent[]>('models_grpc_events', { connectionId }).then((events) =>
mergeModelsInStore('grpc_event', events, (e) => e.connectionId === connectionId),
);
}, [connectionId]);
return events;
return useMemo(
() => allEvents.filter((e) => e.connectionId === connectionId),
[allEvents, connectionId],
);
}

View File

@@ -7,7 +7,7 @@ import {
websocketEventsAtom,
} from '@yaakapp-internal/models';
import { atom, useAtomValue } from 'jotai';
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage';
import { jotaiStore } from '../lib/jotai';
import { activeRequestIdAtom } from './useActiveRequestId';
@@ -47,7 +47,7 @@ export function setPinnedWebsocketConnectionId(id: string | null) {
}
export function useWebsocketEvents(connectionId: string | null) {
const events = useAtomValue(websocketEventsAtom);
const allEvents = useAtomValue(websocketEventsAtom);
useEffect(() => {
if (connectionId == null) {
@@ -55,12 +55,14 @@ export function useWebsocketEvents(connectionId: string | null) {
return;
}
// Use merge instead of replace to preserve events that came in via model_write
// while we were fetching from the database
invoke<WebsocketEvent[]>('models_websocket_events', { connectionId }).then(
(events) => mergeModelsInStore('websocket_event', events),
// Fetch events from database, filtering out events from other connections and merging atomically
invoke<WebsocketEvent[]>('models_websocket_events', { connectionId }).then((events) =>
mergeModelsInStore('websocket_event', events, (e) => e.connectionId === connectionId),
);
}, [connectionId]);
return events;
return useMemo(
() => allEvents.filter((e) => e.connectionId === connectionId),
[allEvents, connectionId],
);
}

View File

@@ -34,7 +34,7 @@ export function useRequestEditor() {
const focusParamValue = useCallback(
(name: string) => {
focusParamsTab();
setTimeout(() => emitter.emit('request_params.focus_value', name), 50);
requestAnimationFrame(() => emitter.emit('request_params.focus_value', name));
},
[focusParamsTab],
);