Compare commits

...

3 Commits

Author SHA1 Message Date
Gregory Schier
88f5f0e045 Add redirect drop metadata and warning UI (#418) 2026-03-05 06:14:11 -08:00
Gregory Schier
615f3134d2 Fix plugin settings layout 2026-03-04 09:21:15 -08:00
Gregory Schier
0c7051d59c Better external OAuth callback format 2026-03-04 09:10:49 -08:00
16 changed files with 434 additions and 185 deletions

View File

@@ -47,8 +47,7 @@ impl CliContext {
std::process::exit(1);
}
};
let encryption_manager =
Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
Self {
data_dir,

View File

@@ -162,8 +162,7 @@ fn resolve_send_execution_context(
AnyRequest::GrpcRequest(r) => (Some(r.id), r.workspace_id),
AnyRequest::WebsocketRequest(r) => (Some(r.id), r.workspace_id),
};
let cookie_jar_id =
resolve_cookie_jar_id(context, &workspace_id, explicit_cookie_jar_id)?;
let cookie_jar_id = resolve_cookie_jar_id(context, &workspace_id, explicit_cookie_jar_id)?;
return Ok(CliExecutionContext {
request_id,
workspace_id: Some(workspace_id),
@@ -184,8 +183,7 @@ fn resolve_send_execution_context(
}
if let Ok(workspace) = context.db().get_workspace(id) {
let cookie_jar_id =
resolve_cookie_jar_id(context, &workspace.id, explicit_cookie_jar_id)?;
let cookie_jar_id = resolve_cookie_jar_id(context, &workspace.id, explicit_cookie_jar_id)?;
return Ok(CliExecutionContext {
request_id: None,
workspace_id: Some(workspace.id),
@@ -213,8 +211,7 @@ fn resolve_request_execution_context(
AnyRequest::GrpcRequest(r) => r.workspace_id,
AnyRequest::WebsocketRequest(r) => r.workspace_id,
};
let cookie_jar_id =
resolve_cookie_jar_id(context, &workspace_id, explicit_cookie_jar_id)?;
let cookie_jar_id = resolve_cookie_jar_id(context, &workspace_id, explicit_cookie_jar_id)?;
Ok(CliExecutionContext {
request_id: Some(request_id.to_string()),

View File

@@ -30,6 +30,8 @@ pub enum HttpResponseEvent {
url: String,
status: u16,
behavior: RedirectBehavior,
dropped_body: bool,
dropped_headers: Vec<String>,
},
SendUrl {
method: String,
@@ -67,12 +69,28 @@ impl Display for HttpResponseEvent {
match self {
HttpResponseEvent::Setting(name, value) => write!(f, "* Setting {}={}", name, value),
HttpResponseEvent::Info(s) => write!(f, "* {}", s),
HttpResponseEvent::Redirect { url, status, behavior } => {
HttpResponseEvent::Redirect {
url,
status,
behavior,
dropped_body,
dropped_headers,
} => {
let behavior_str = match behavior {
RedirectBehavior::Preserve => "preserve",
RedirectBehavior::DropBody => "drop body",
};
write!(f, "* Redirect {} -> {} ({})", status, url, behavior_str)
let body_str = if *dropped_body { ", body dropped" } else { "" };
let headers_str = if dropped_headers.is_empty() {
String::new()
} else {
format!(", headers dropped: {}", dropped_headers.join(", "))
};
write!(
f,
"* Redirect {} -> {} ({}{}{})",
status, url, behavior_str, body_str, headers_str
)
}
HttpResponseEvent::SendUrl {
method,
@@ -130,13 +148,21 @@ impl From<HttpResponseEvent> for yaak_models::models::HttpResponseEventData {
match event {
HttpResponseEvent::Setting(name, value) => D::Setting { name, value },
HttpResponseEvent::Info(message) => D::Info { message },
HttpResponseEvent::Redirect { url, status, behavior } => D::Redirect {
HttpResponseEvent::Redirect {
url,
status,
behavior,
dropped_body,
dropped_headers,
} => D::Redirect {
url,
status,
behavior: match behavior {
RedirectBehavior::Preserve => "preserve".to_string(),
RedirectBehavior::DropBody => "drop_body".to_string(),
},
dropped_body,
dropped_headers,
},
HttpResponseEvent::SendUrl {
method,

View File

@@ -1,7 +1,7 @@
use crate::cookies::CookieStore;
use crate::error::Result;
use crate::sender::{HttpResponse, HttpResponseEvent, HttpSender, RedirectBehavior};
use crate::types::SendableHttpRequest;
use crate::types::{SendableBody, SendableHttpRequest};
use log::debug;
use tokio::sync::mpsc;
use tokio::sync::watch::Receiver;
@@ -87,6 +87,11 @@ impl<S: HttpSender> HttpTransaction<S> {
};
// Build request for this iteration
let preserved_body = match &current_body {
Some(SendableBody::Bytes(b)) => Some(SendableBody::Bytes(b.clone())),
_ => None,
};
let request_had_body = current_body.is_some();
let req = SendableHttpRequest {
url: current_url.clone(),
method: current_method.clone(),
@@ -182,8 +187,6 @@ impl<S: HttpSender> HttpTransaction<S> {
format!("{}/{}", base_path, location)
};
Self::remove_sensitive_headers(&mut current_headers, &previous_url, &current_url);
// Determine redirect behavior based on status code and method
let behavior = if status == 303 {
// 303 See Other always changes to GET
@@ -197,11 +200,8 @@ impl<S: HttpSender> HttpTransaction<S> {
RedirectBehavior::Preserve
};
send_event(HttpResponseEvent::Redirect {
url: current_url.clone(),
status,
behavior: behavior.clone(),
});
let mut dropped_headers =
Self::remove_sensitive_headers(&mut current_headers, &previous_url, &current_url);
// Handle method changes for certain redirect codes
if matches!(behavior, RedirectBehavior::DropBody) {
@@ -211,13 +211,40 @@ impl<S: HttpSender> HttpTransaction<S> {
// Remove content-related headers
current_headers.retain(|h| {
let name_lower = h.0.to_lowercase();
!name_lower.starts_with("content-") && name_lower != "transfer-encoding"
let should_drop =
name_lower.starts_with("content-") || name_lower == "transfer-encoding";
if should_drop {
Self::push_header_if_missing(&mut dropped_headers, &h.0);
}
!should_drop
});
}
// Reset body for next iteration (since it was moved in the send call)
// For redirects that change method to GET or for all redirects since body was consumed
current_body = None;
// Restore body for Preserve redirects (307/308), drop for others.
// Stream bodies can't be replayed (same limitation as reqwest).
current_body = if matches!(behavior, RedirectBehavior::Preserve) {
if request_had_body && preserved_body.is_none() {
// Stream body was consumed and can't be replayed (same as reqwest)
return Err(crate::error::Error::RequestError(
"Cannot follow redirect: request body was a stream and cannot be resent"
.to_string(),
));
}
preserved_body
} else {
None
};
// Body was dropped if the request had one but we can't resend it
let dropped_body = request_had_body && current_body.is_none();
send_event(HttpResponseEvent::Redirect {
url: current_url.clone(),
status,
behavior: behavior.clone(),
dropped_body,
dropped_headers,
});
redirect_count += 1;
}
@@ -231,7 +258,8 @@ impl<S: HttpSender> HttpTransaction<S> {
headers: &mut Vec<(String, String)>,
previous_url: &str,
next_url: &str,
) {
) -> Vec<String> {
let mut dropped_headers = Vec::new();
let previous_host = Url::parse(previous_url).ok().and_then(|u| {
u.host_str().map(|h| format!("{}:{}", h, u.port_or_known_default().unwrap_or(0)))
});
@@ -241,13 +269,24 @@ impl<S: HttpSender> HttpTransaction<S> {
if previous_host != next_host {
headers.retain(|h| {
let name_lower = h.0.to_lowercase();
name_lower != "authorization"
&& name_lower != "cookie"
&& name_lower != "cookie2"
&& name_lower != "proxy-authorization"
&& name_lower != "www-authenticate"
let should_drop = name_lower == "authorization"
|| name_lower == "cookie"
|| name_lower == "cookie2"
|| name_lower == "proxy-authorization"
|| name_lower == "www-authenticate";
if should_drop {
Self::push_header_if_missing(&mut dropped_headers, &h.0);
}
!should_drop
});
}
dropped_headers
}
fn push_header_if_missing(headers: &mut Vec<String>, name: &str) {
if !headers.iter().any(|h| h.eq_ignore_ascii_case(name)) {
headers.push(name.to_string());
}
}
/// Check if a status code indicates a redirect

View File

@@ -49,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, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: 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

@@ -1499,6 +1499,10 @@ pub enum HttpResponseEventData {
url: String,
status: u16,
behavior: String,
#[serde(default)]
dropped_body: bool,
#[serde(default)]
dropped_headers: Vec<String>,
},
SendUrl {
method: String,

View File

@@ -62,7 +62,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, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: 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

@@ -18,12 +18,12 @@ export type CallHttpAuthenticationActionRequest = { index: number, pluginRefId:
export type CallHttpAuthenticationRequest = { contextId: string, values: { [key in string]?: JsonPrimitive }, method: string, url: string, headers: Array<HttpHeader>, };
export type CallHttpAuthenticationResponse = {
export type CallHttpAuthenticationResponse = {
/**
* HTTP headers to add to the request. Existing headers will be replaced, while
* new headers will be added.
*/
setHeaders?: Array<HttpHeader>,
setHeaders?: Array<HttpHeader>,
/**
* Query parameters to add to the request. Existing params will be replaced, while
* new params will be added.
@@ -78,7 +78,7 @@ export type ExportHttpRequestRequest = { httpRequest: HttpRequest, };
export type ExportHttpRequestResponse = { content: string, };
export type FileFilter = { name: string,
export type FileFilter = { name: string,
/**
* File extensions to require
*/
@@ -100,149 +100,149 @@ export type FormInputAccordion = { label: string, inputs?: Array<FormInput>, hid
export type FormInputBanner = { inputs?: Array<FormInput>, hidden?: boolean, color?: Color, };
export type FormInputBase = {
export type FormInputBase = {
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type FormInputCheckbox = {
export type FormInputCheckbox = {
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type FormInputEditor = {
export type FormInputEditor = {
/**
* Placeholder for the text input
*/
placeholder?: string | null,
placeholder?: string | null,
/**
* Don't show the editor gutter (line numbers, folds, etc.)
*/
hideGutter?: boolean,
hideGutter?: boolean,
/**
* Language for syntax highlighting
*/
language?: EditorLanguage, readOnly?: boolean,
language?: EditorLanguage, readOnly?: boolean,
/**
* Fixed number of visible rows
*/
rows?: number, completionOptions?: Array<GenericCompletionOption>,
rows?: number, completionOptions?: Array<GenericCompletionOption>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type FormInputFile = {
export type FormInputFile = {
/**
* The title of the file selection window
*/
title: string,
title: string,
/**
* Allow selecting multiple files
*/
multiple?: boolean, directory?: boolean, defaultPath?: string, filters?: Array<FileFilter>,
multiple?: boolean, directory?: boolean, defaultPath?: string, filters?: Array<FileFilter>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
@@ -250,63 +250,63 @@ description?: string, };
export type FormInputHStack = { inputs?: Array<FormInput>, hidden?: boolean, };
export type FormInputHttpRequest = {
export type FormInputHttpRequest = {
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type FormInputKeyValue = {
export type FormInputKeyValue = {
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
@@ -314,36 +314,36 @@ description?: string, };
export type FormInputMarkdown = { content: string, hidden?: boolean, };
export type FormInputSelect = {
export type FormInputSelect = {
/**
* The options that will be available in the select input
*/
options: Array<FormInputSelectOption>,
options: Array<FormInputSelectOption>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
@@ -351,44 +351,44 @@ description?: string, };
export type FormInputSelectOption = { label: string, value: string, };
export type FormInputText = {
export type FormInputText = {
/**
* Placeholder for the text input
*/
placeholder?: string | null,
placeholder?: string | null,
/**
* Placeholder for the text input
*/
password?: boolean,
password?: boolean,
/**
* Whether to allow newlines in the input, like a <textarea/>
*/
multiLine?: boolean, completionOptions?: Array<GenericCompletionOption>,
multiLine?: boolean, completionOptions?: Array<GenericCompletionOption>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
@@ -474,7 +474,7 @@ export type ListOpenWorkspacesResponse = { workspaces: Array<WorkspaceInfo>, };
export type OpenExternalUrlRequest = { url: string, };
export type OpenWindowRequest = { url: string,
export type OpenWindowRequest = { url: string,
/**
* Label for the window. If not provided, a random one will be generated.
*/
@@ -486,15 +486,15 @@ export type PromptFormRequest = { id: string, title: string, description?: strin
export type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, done?: boolean, };
export type PromptTextRequest = { id: string, title: string, label: string, description?: string, defaultValue?: string, placeholder?: string,
export type PromptTextRequest = { id: string, title: string, label: string, description?: string, defaultValue?: string, placeholder?: string,
/**
* Text to add to the confirmation button
*/
confirmText?: string, password?: boolean,
confirmText?: string, password?: boolean,
/**
* Text to add to the cancel button
*/
cancelText?: string,
cancelText?: string,
/**
* Require the user to enter a non-empty value
*/
@@ -524,12 +524,12 @@ export type SetKeyValueResponse = {};
export type ShowToastRequest = { message: string, color?: Color, icon?: Icon, timeout?: number, };
export type TemplateFunction = { name: string, previewType?: TemplateFunctionPreviewType, description?: string,
export type TemplateFunction = { name: string, previewType?: TemplateFunctionPreviewType, description?: string,
/**
* Also support alternative names. This is useful for not breaking existing
* tags when changing the `name` property
*/
aliases?: Array<string>, args: Array<TemplateFunctionArg>,
aliases?: Array<string>, args: Array<TemplateFunctionArg>,
/**
* A list of arg names to show in the inline preview. If not provided, none will be shown (for privacy reasons).
*/
@@ -546,23 +546,23 @@ export type TemplateRenderRequest = { data: JsonValue, purpose: RenderPurpose, }
export type TemplateRenderResponse = { data: JsonValue, };
export type Theme = {
export type Theme = {
/**
* How the theme is identified. This should never be changed
*/
id: string,
id: string,
/**
* The friendly name of the theme to be displayed to the user
*/
label: string,
label: string,
/**
* Whether the theme will be used for dark or light appearance
*/
dark: boolean,
dark: boolean,
/**
* The default top-level colors for the theme
*/
base: ThemeComponentColors,
base: ThemeComponentColors,
/**
* Optionally override theme for individual UI components for more control
*/

View File

@@ -18,7 +18,12 @@ export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
export type EncryptedKey = { encryptedKey: string, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null,
/**
* Variables defined in this environment scope.
* Child environments override parent variables by name.
*/
variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
@@ -34,9 +39,17 @@ export type GrpcEvent = { model: "grpc_event", id: string, createdAt: string, up
export type GrpcEventType = "info" | "error" | "client_message" | "server_message" | "connection_start" | "connection_end";
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number, url: string, };
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number,
/**
* Server URL (http for plaintext or https for secure)
*/
url: string, };
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, };
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
@@ -49,17 +62,24 @@ 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, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: 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, };
export type HttpResponseState = "initialized" | "connected" | "closed";
export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, id?: string, };
export type HttpUrlParameter = { enabled?: boolean,
/**
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
* Other entries are appended as query parameters
*/
name: string, value: string, id?: string, };
export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, };
export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, };
export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, source: PluginSource, };
export type PluginSource = "bundled" | "filesystem" | "registry";
export type ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { "type": "disabled" };
@@ -77,7 +97,11 @@ export type WebsocketEvent = { model: "websocket_event", id: string, createdAt:
export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" | "pong" | "text";
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, };

View File

@@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
import http from 'node:http';
import type { Context } from '@yaakapp/api';
export const HOSTED_CALLBACK_URL = 'https://oauth.yaak.app/redirect';
export const HOSTED_CALLBACK_URL_BASE = 'https://oauth.yaak.app/redirect';
export const DEFAULT_LOCALHOST_PORT = 8765;
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
@@ -176,12 +176,15 @@ export function startCallbackServer(options: {
/**
* Build the redirect URI for the hosted callback page.
* The hosted page will redirect to the local server with the OAuth response.
* The port is encoded in the URL path so the hosted page can redirect
* to the local server without relying on query params (which some OAuth
* providers strip). The default port is omitted for a cleaner URL.
*/
export function buildHostedCallbackRedirectUri(localPort: number, localPath: string): string {
const localRedirectUri = `http://127.0.0.1:${localPort}${localPath}`;
// The hosted callback page will read params and redirect to the local server
return `${HOSTED_CALLBACK_URL}?redirect_to=${encodeURIComponent(localRedirectUri)}`;
export function buildHostedCallbackRedirectUri(localPort: number): string {
if (localPort === DEFAULT_LOCALHOST_PORT) {
return HOSTED_CALLBACK_URL_BASE;
}
return `${HOSTED_CALLBACK_URL_BASE}/${localPort}`;
}
/**
@@ -213,14 +216,9 @@ export async function getRedirectUrlViaExternalBrowser(
): Promise<{ callbackUrl: string; redirectUri: string }> {
const { callbackType, callbackPort } = options;
// Determine port based on callback type:
// - localhost: use specified port or default stable port
// - hosted: use random port (0) since hosted page redirects to local
const port = callbackType === 'localhost' ? (callbackPort ?? DEFAULT_LOCALHOST_PORT) : 0;
const port = callbackPort ?? DEFAULT_LOCALHOST_PORT;
console.log(
`[oauth2] Starting callback server (type: ${callbackType}, port: ${port || 'random'})`,
);
console.log(`[oauth2] Starting callback server (type: ${callbackType}, port: ${port})`);
const server = await startCallbackServer({
port,
@@ -232,7 +230,7 @@ export async function getRedirectUrlViaExternalBrowser(
let oauthRedirectUri: string;
if (callbackType === 'hosted') {
oauthRedirectUri = buildHostedCallbackRedirectUri(server.port, '/callback');
oauthRedirectUri = buildHostedCallbackRedirectUri(server.port);
console.log('[oauth2] Using hosted callback redirect:', oauthRedirectUri);
} else {
oauthRedirectUri = server.redirectUri;

View File

@@ -6,7 +6,11 @@ import type {
PluginDefinition,
} from '@yaakapp/api';
import type { Algorithm } from 'jsonwebtoken';
import { DEFAULT_LOCALHOST_PORT, HOSTED_CALLBACK_URL, stopActiveServer } from './callbackServer';
import {
buildHostedCallbackRedirectUri,
DEFAULT_LOCALHOST_PORT,
stopActiveServer,
} from './callbackServer';
import {
type CallbackType,
DEFAULT_PKCE_METHOD,
@@ -300,8 +304,7 @@ export const plugin: PluginDefinition = {
optional: true,
dynamic: hiddenIfNot(
['authorization_code', 'implicit'],
({ useExternalBrowser, callbackType }) =>
!!useExternalBrowser && callbackType === 'localhost',
({ useExternalBrowser }) => !!useExternalBrowser,
),
},
],
@@ -328,11 +331,11 @@ export const plugin: PluginDefinition = {
}
// Compute the redirect URI based on callback type
const port = intArg(values, 'callbackPort') || DEFAULT_LOCALHOST_PORT;
let redirectUri: string;
if (callbackType === 'hosted') {
redirectUri = HOSTED_CALLBACK_URL;
redirectUri = buildHostedCallbackRedirectUri(port);
} else {
const port = intArg(values, 'callbackPort') || DEFAULT_LOCALHOST_PORT;
redirectUri = `http://127.0.0.1:${port}/callback`;
}

View File

@@ -1,4 +1,4 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import type { HttpResponse, HttpResponseEvent } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { ComponentType, CSSProperties } from 'react';
import { lazy, Suspense, useMemo } from 'react';
@@ -18,11 +18,14 @@ import { CountBadge } from './core/CountBadge';
import { HotkeyList } from './core/HotkeyList';
import { HttpResponseDurationTag } from './core/HttpResponseDurationTag';
import { HttpStatusTag } from './core/HttpStatusTag';
import { Icon } from './core/Icon';
import { LoadingIcon } from './core/LoadingIcon';
import { PillButton } from './core/PillButton';
import { SizeTag } from './core/SizeTag';
import { HStack, VStack } from './core/Stacks';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { Tooltip } from './core/Tooltip';
import { EmptyStateText } from './EmptyStateText';
import { ErrorBoundary } from './ErrorBoundary';
import { HttpResponseTimeline } from './HttpResponseTimeline';
@@ -57,6 +60,11 @@ const TAB_TIMELINE = 'timeline';
export type TimelineViewMode = 'timeline' | 'text';
interface RedirectDropWarning {
droppedBodyCount: number;
droppedHeaders: string[];
}
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
@@ -65,6 +73,12 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
const responseEvents = useHttpResponseEvents(activeResponse);
const redirectDropWarning = useMemo(
() => getRedirectDropWarning(responseEvents.data),
[responseEvents.data],
);
const shouldShowRedirectDropWarning =
activeResponse?.state === 'closed' && redirectDropWarning != null;
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
@@ -162,32 +176,77 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
)}
>
{activeResponse && (
<HStack
space={2}
alignItems="center"
<div
className={classNames(
'grid grid-cols-[auto_minmax(4rem,1fr)_auto]',
'cursor-default select-none',
'whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars',
)}
>
{activeResponse.state !== 'closed' && <LoadingIcon size="sm" />}
<HttpStatusTag showReason response={activeResponse} />
<span>&bull;</span>
<HttpResponseDurationTag response={activeResponse} />
<span>&bull;</span>
<SizeTag
contentLength={activeResponse.contentLength ?? 0}
contentLengthCompressed={activeResponse.contentLengthCompressed}
/>
<div className="ml-auto">
<HStack space={2} className="w-full flex-shrink-0">
{activeResponse.state !== 'closed' && <LoadingIcon size="sm" />}
<HttpStatusTag showReason response={activeResponse} />
<span>&bull;</span>
<HttpResponseDurationTag response={activeResponse} />
<span>&bull;</span>
<SizeTag
contentLength={activeResponse.contentLength ?? 0}
contentLengthCompressed={activeResponse.contentLengthCompressed}
/>
</HStack>
{shouldShowRedirectDropWarning ? (
<Tooltip
tabIndex={0}
className="my-auto pl-3 flex-shrink-0 max-w-full justify-self-end overflow-hidden"
content={
<VStack alignItems="start" space={1} className="text-xs">
<span className="font-medium text-warning">
Redirect changed this request
</span>
{redirectDropWarning.droppedBodyCount > 0 && (
<span>
Body dropped on {redirectDropWarning.droppedBodyCount}{' '}
{redirectDropWarning.droppedBodyCount === 1
? 'redirect hop'
: 'redirect hops'}
</span>
)}
{redirectDropWarning.droppedHeaders.length > 0 && (
<span>
Headers dropped:{' '}
<span className="font-mono">
{redirectDropWarning.droppedHeaders.join(', ')}
</span>
</span>
)}
<span className="text-text-subtle">See Timeline for details.</span>
</VStack>
}
>
<span className="inline-flex min-w-0">
<PillButton
color="warning"
className="font-sans text-sm !flex-shrink max-w-full"
innerClassName="flex items-center"
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
>
<span className="truncate">
{getRedirectWarningLabel(redirectDropWarning)}
</span>
</PillButton>
</span>
</Tooltip>
) : (
<span />
)}
<div className="justify-self-end flex-shrink-0">
<RecentHttpResponsesDropdown
responses={responses}
activeResponse={activeResponse}
onPinnedResponseId={setPinnedResponseId}
/>
</div>
</HStack>
</div>
)}
</HStack>
@@ -274,6 +333,54 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
);
}
function getRedirectDropWarning(
events: HttpResponseEvent[] | undefined,
): RedirectDropWarning | null {
if (events == null || events.length === 0) return null;
let droppedBodyCount = 0;
const droppedHeaders = new Set<string>();
for (const e of events) {
const event = e.event;
if (event.type !== 'redirect') {
continue;
}
if (event.dropped_body) {
droppedBodyCount += 1;
}
for (const headerName of event.dropped_headers ?? []) {
pushHeaderName(droppedHeaders, headerName);
}
}
if (droppedBodyCount === 0 && droppedHeaders.size === 0) {
return null;
}
return {
droppedBodyCount,
droppedHeaders: Array.from(droppedHeaders).sort(),
};
}
function pushHeaderName(headers: Set<string>, headerName: string): void {
const existing = Array.from(headers).find((h) => h.toLowerCase() === headerName.toLowerCase());
if (existing == null) {
headers.add(headerName);
}
}
function getRedirectWarningLabel(warning: RedirectDropWarning): string {
if (warning.droppedBodyCount > 0 && warning.droppedHeaders.length > 0) {
return 'Dropped body and headers';
}
if (warning.droppedBodyCount > 0) {
return 'Dropped body';
}
return 'Dropped headers';
}
function EnsureCompleteResponse({
response,
Component,

View File

@@ -187,6 +187,7 @@ function EventDetails({
// Redirect - show status, URL, and behavior
if (e.type === 'redirect') {
const droppedHeaders = e.dropped_headers ?? [];
return (
<KeyValueRows>
<KeyValueRow label="Status">
@@ -196,6 +197,10 @@ function EventDetails({
<KeyValueRow label="Behavior">
{e.behavior === 'drop_body' ? 'Drop body, change to GET' : 'Preserve method and body'}
</KeyValueRow>
<KeyValueRow label="Body Dropped">{e.dropped_body ? 'Yes' : 'No'}</KeyValueRow>
<KeyValueRow label="Headers Dropped">
{droppedHeaders.length > 0 ? droppedHeaders.join(', ') : '--'}
</KeyValueRow>
</KeyValueRows>
);
}
@@ -268,7 +273,17 @@ function getEventTextParts(event: HttpResponseEventData): EventTextParts {
return { prefix: '<', text: `${event.name}: ${event.value}` };
case 'redirect': {
const behavior = event.behavior === 'drop_body' ? 'drop body' : 'preserve';
return { prefix: '*', text: `Redirect ${event.status} -> ${event.url} (${behavior})` };
const droppedHeaders = event.dropped_headers ?? [];
const dropped = [
event.dropped_body ? 'body dropped' : null,
droppedHeaders.length > 0 ? `headers dropped: ${droppedHeaders.join(', ')}` : null,
]
.filter(Boolean)
.join(', ');
return {
prefix: '*',
text: `Redirect ${event.status} -> ${event.url} (${behavior}${dropped ? `, ${dropped}` : ''})`,
};
}
case 'setting':
return { prefix: '*', text: `Setting ${event.name}=${event.value}` };
@@ -323,13 +338,23 @@ function getEventDisplay(event: HttpResponseEventData): EventDisplay {
label: 'Info',
summary: event.message,
};
case 'redirect':
case 'redirect': {
const droppedHeaders = event.dropped_headers ?? [];
const dropped = [
event.dropped_body ? 'drop body' : null,
droppedHeaders.length > 0
? `drop ${droppedHeaders.length} ${droppedHeaders.length === 1 ? 'header' : 'headers'}`
: null,
]
.filter(Boolean)
.join(', ');
return {
icon: 'arrow_big_right_dash',
color: 'success',
label: 'Redirect',
summary: `Redirecting ${event.status} ${event.url}${event.behavior === 'drop_body' ? ' (drop body)' : ''}`,
summary: `Redirecting ${event.status} ${event.url}${dropped ? ` (${dropped})` : ''}`,
};
}
case 'send_url':
return {
icon: 'arrow_big_up_dash',

View File

@@ -39,9 +39,9 @@ const tabs = [
TAB_THEME,
TAB_INTERFACE,
TAB_SHORTCUTS,
TAB_PLUGINS,
TAB_CERTIFICATES,
TAB_PROXY,
TAB_PLUGINS,
TAB_LICENSE,
] as const;
export type SettingsTab = (typeof tabs)[number];
@@ -120,7 +120,7 @@ export default function Settings({ hide }: Props) {
value === TAB_CERTIFICATES ? (
<CountBadge count={settings.clientCertificates.length} />
) : value === TAB_PLUGINS ? (
<CountBadge count={plugins.length} />
<CountBadge count={plugins.filter((p) => p.source !== 'bundled').length} />
) : value === TAB_PROXY && settings.proxy?.type === 'enabled' ? (
<CountBadge count />
) : value === TAB_LICENSE && licenseCheck.check.data?.status === 'personal_use' ? (
@@ -141,7 +141,7 @@ export default function Settings({ hide }: Props) {
<TabContent value={TAB_SHORTCUTS} className="overflow-y-auto h-full px-6 !py-4">
<SettingsHotkeys />
</TabContent>
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1 px-6 !py-4">
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1">
<SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />
</TabContent>
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 !py-4">

View File

@@ -9,6 +9,7 @@ import {
searchPlugins,
uninstallPlugin,
} from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import { useState } from 'react';
import { useDebouncedValue } from '../../hooks/useDebouncedValue';
@@ -49,6 +50,7 @@ export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
defaultValue={defaultSubtab}
label="Plugins"
addBorders
tabListClassName="px-6 pt-2"
tabs={[
{ label: 'Discover', value: 'search' },
{
@@ -63,13 +65,13 @@ export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
},
]}
>
<TabContent value="search">
<TabContent value="search" className="px-6">
<PluginSearch />
</TabContent>
<TabContent value="installed" className="pb-0">
<div className="h-full grid grid-rows-[minmax(0,1fr)_auto]">
<InstalledPlugins plugins={installedPlugins} />
<footer className="grid grid-cols-[minmax(0,1fr)_auto] -mx-4 py-2 px-4 border-t bg-surface-highlight border-border-subtle min-w-0">
<InstalledPlugins plugins={installedPlugins} className="px-6" />
<footer className="grid grid-cols-[minmax(0,1fr)_auto] py-2 px-4 border-t bg-surface-highlight border-border-subtle min-w-0">
<SelectFile
size="xs"
noun="Plugin"
@@ -111,7 +113,7 @@ export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
</footer>
</div>
</TabContent>
<TabContent value="bundled" className="pb-0">
<TabContent value="bundled" className="pb-0 px-6">
<BundledPlugins plugins={bundledPlugins} />
</TabContent>
</Tabs>
@@ -330,9 +332,9 @@ function PluginSearch() {
);
}
function InstalledPlugins({ plugins }: { plugins: Plugin[] }) {
function InstalledPlugins({ plugins, className }: { plugins: Plugin[]; className?: string }) {
return plugins.length === 0 ? (
<div className="pb-4">
<div className={classNames(className, 'pb-4')}>
<EmptyStateText className="text-center">
Plugins extend the functionality of Yaak.
<br />
@@ -340,7 +342,7 @@ function InstalledPlugins({ plugins }: { plugins: Plugin[] }) {
</EmptyStateText>
</div>
) : (
<Table scrollable>
<Table scrollable className={className}>
<TableHead>
<TableRow>
<TableHeaderCell className="w-0" />

View File

@@ -20,8 +20,15 @@ const hiddenStyles: CSSProperties = {
opacity: 0,
};
type TooltipPosition = 'top' | 'bottom';
interface TooltipOpenState {
styles: CSSProperties;
position: TooltipPosition;
}
export function Tooltip({ children, className, content, tabIndex, size = 'md' }: TooltipProps) {
const [isOpen, setIsOpen] = useState<CSSProperties>();
const [openState, setOpenState] = useState<TooltipOpenState | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const showTimeout = useRef<NodeJS.Timeout>(undefined);
@@ -29,16 +36,25 @@ export function Tooltip({ children, className, content, tabIndex, size = 'md' }:
const handleOpenImmediate = () => {
if (triggerRef.current == null || tooltipRef.current == null) return;
clearTimeout(showTimeout.current);
setIsOpen(undefined);
const triggerRect = triggerRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
const docRect = document.documentElement.getBoundingClientRect();
const viewportHeight = document.documentElement.clientHeight;
const margin = 8;
const spaceAbove = Math.max(0, triggerRect.top - margin);
const spaceBelow = Math.max(0, viewportHeight - triggerRect.bottom - margin);
const preferBottom = spaceAbove < tooltipRect.height + margin && spaceBelow > spaceAbove;
const position: TooltipPosition = preferBottom ? 'bottom' : 'top';
const styles: CSSProperties = {
bottom: docRect.height - triggerRect.top,
left: Math.max(0, triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2),
maxHeight: triggerRect.top,
maxHeight: position === 'top' ? spaceAbove : spaceBelow,
...(position === 'top'
? { bottom: viewportHeight - triggerRect.top }
: { top: triggerRect.bottom }),
};
setIsOpen(styles);
setOpenState({ styles, position });
};
const handleOpen = () => {
@@ -48,16 +64,16 @@ export function Tooltip({ children, className, content, tabIndex, size = 'md' }:
const handleClose = () => {
clearTimeout(showTimeout.current);
setIsOpen(undefined);
setOpenState(null);
};
const handleToggleImmediate = () => {
if (isOpen) handleClose();
if (openState) handleClose();
else handleOpenImmediate();
};
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
if (isOpen && e.key === 'Escape') {
if (openState && e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
handleClose();
@@ -71,10 +87,10 @@ export function Tooltip({ children, className, content, tabIndex, size = 'md' }:
<Portal name="tooltip">
<div
ref={tooltipRef}
style={isOpen ?? hiddenStyles}
style={openState?.styles ?? hiddenStyles}
id={id.current}
role="tooltip"
aria-hidden={!isOpen}
aria-hidden={openState == null}
onMouseEnter={handleOpenImmediate}
onMouseLeave={handleClose}
className="p-2 fixed z-50 text-sm transition-opacity grid grid-rows-[minmax(0,1fr)]"
@@ -88,14 +104,17 @@ export function Tooltip({ children, className, content, tabIndex, size = 'md' }:
>
{content}
</div>
<Triangle className="text-border mb-2" />
<Triangle
className="text-border"
position={openState?.position === 'bottom' ? 'top' : 'bottom'}
/>
</div>
</Portal>
{/** biome-ignore lint/a11y/useSemanticElements: Needs to be usable in other buttons */}
<span
ref={triggerRef}
role="button"
aria-describedby={isOpen ? id.current : undefined}
aria-describedby={openState ? id.current : undefined}
tabIndex={tabIndex ?? -1}
className={classNames(className, 'flex-grow-0 flex items-center')}
onClick={handleToggleImmediate}
@@ -111,7 +130,9 @@ export function Tooltip({ children, className, content, tabIndex, size = 'md' }:
);
}
function Triangle({ className }: { className?: string }) {
function Triangle({ className, position }: { className?: string; position: 'top' | 'bottom' }) {
const isBottom = position === 'bottom';
return (
<svg
aria-hidden
@@ -120,15 +141,19 @@ function Triangle({ className }: { className?: string }) {
shapeRendering="crispEdges"
className={classNames(
className,
'absolute z-50 border-t-[2px] border-surface-highlight',
'-bottom-[calc(0.5rem-3px)] left-[calc(50%-0.4rem)]',
'h-[0.5rem] w-[0.8rem]',
'absolute z-50 left-[calc(50%-0.4rem)] h-[0.5rem] w-[0.8rem]',
isBottom
? 'border-t-[2px] border-surface-highlight -bottom-[calc(0.5rem-3px)] mb-2'
: 'border-b-[2px] border-surface-highlight -top-[calc(0.5rem-3px)] mt-2',
)}
>
<title>Triangle</title>
<polygon className="fill-surface-highlight" points="0,0 30,0 15,10" />
<polygon
className="fill-surface-highlight"
points={isBottom ? '0,0 30,0 15,10' : '0,10 30,10 15,0'}
/>
<path
d="M0 0 L15 9 L30 0"
d={isBottom ? 'M0 0 L15 9 L30 0' : 'M0 10 L15 1 L30 10'}
fill="none"
stroke="currentColor"
strokeWidth="1"