Add redirect drop metadata and response warning UI

This commit is contained in:
Gregory Schier
2026-03-04 14:47:11 -08:00
parent 615f3134d2
commit a42ffd0e8b
11 changed files with 272 additions and 66 deletions

View File

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

View File

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

View File

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

View File

@@ -87,6 +87,7 @@ impl<S: HttpSender> HttpTransaction<S> {
}; };
// Build request for this iteration // Build request for this iteration
let request_had_body = current_body.is_some();
let req = SendableHttpRequest { let req = SendableHttpRequest {
url: current_url.clone(), url: current_url.clone(),
method: current_method.clone(), method: current_method.clone(),
@@ -182,8 +183,6 @@ impl<S: HttpSender> HttpTransaction<S> {
format!("{}/{}", base_path, location) format!("{}/{}", base_path, location)
}; };
Self::remove_sensitive_headers(&mut current_headers, &previous_url, &current_url);
// Determine redirect behavior based on status code and method // Determine redirect behavior based on status code and method
let behavior = if status == 303 { let behavior = if status == 303 {
// 303 See Other always changes to GET // 303 See Other always changes to GET
@@ -197,11 +196,8 @@ impl<S: HttpSender> HttpTransaction<S> {
RedirectBehavior::Preserve RedirectBehavior::Preserve
}; };
send_event(HttpResponseEvent::Redirect { let mut dropped_headers =
url: current_url.clone(), Self::remove_sensitive_headers(&mut current_headers, &previous_url, &current_url);
status,
behavior: behavior.clone(),
});
// Handle method changes for certain redirect codes // Handle method changes for certain redirect codes
if matches!(behavior, RedirectBehavior::DropBody) { if matches!(behavior, RedirectBehavior::DropBody) {
@@ -211,10 +207,25 @@ impl<S: HttpSender> HttpTransaction<S> {
// Remove content-related headers // Remove content-related headers
current_headers.retain(|h| { current_headers.retain(|h| {
let name_lower = h.0.to_lowercase(); 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
}); });
} }
let dropped_body = matches!(behavior, RedirectBehavior::DropBody) && request_had_body;
send_event(HttpResponseEvent::Redirect {
url: current_url.clone(),
status,
behavior: behavior.clone(),
dropped_body,
dropped_headers,
});
// Reset body for next iteration (since it was moved in the send call) // 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 // For redirects that change method to GET or for all redirects since body was consumed
current_body = None; current_body = None;
@@ -231,7 +242,8 @@ impl<S: HttpSender> HttpTransaction<S> {
headers: &mut Vec<(String, String)>, headers: &mut Vec<(String, String)>,
previous_url: &str, previous_url: &str,
next_url: &str, next_url: &str,
) { ) -> Vec<String> {
let mut dropped_headers = Vec::new();
let previous_host = Url::parse(previous_url).ok().and_then(|u| { 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))) u.host_str().map(|h| format!("{}:{}", h, u.port_or_known_default().unwrap_or(0)))
}); });
@@ -241,13 +253,24 @@ impl<S: HttpSender> HttpTransaction<S> {
if previous_host != next_host { if previous_host != next_host {
headers.retain(|h| { headers.retain(|h| {
let name_lower = h.0.to_lowercase(); let name_lower = h.0.to_lowercase();
name_lower != "authorization" let should_drop = name_lower == "authorization"
&& name_lower != "cookie" || name_lower == "cookie"
&& name_lower != "cookie2" || name_lower == "cookie2"
&& name_lower != "proxy-authorization" || name_lower == "proxy-authorization"
&& name_lower != "www-authenticate" || 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 /// 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. * This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies. * The `From` impl is in yaak-http to avoid circular dependencies.
*/ */
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, 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 HttpResponseHeader = { name: string, value: string, };

View File

@@ -1499,6 +1499,10 @@ pub enum HttpResponseEventData {
url: String, url: String,
status: u16, status: u16,
behavior: String, behavior: String,
#[serde(default)]
dropped_body: bool,
#[serde(default)]
dropped_headers: Vec<String>,
}, },
SendUrl { SendUrl {
method: String, 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. * This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies. * The `From` impl is in yaak-http to avoid circular dependencies.
*/ */
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, 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 HttpResponseHeader = { name: string, value: 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. * This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies. * The `From` impl is in yaak-http to avoid circular dependencies.
*/ */
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, 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 HttpResponseHeader = { name: string, value: string, };

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 classNames from 'classnames';
import type { ComponentType, CSSProperties } from 'react'; import type { ComponentType, CSSProperties } from 'react';
import { lazy, Suspense, useMemo } from 'react'; import { lazy, Suspense, useMemo } from 'react';
@@ -18,11 +18,14 @@ import { CountBadge } from './core/CountBadge';
import { HotkeyList } from './core/HotkeyList'; import { HotkeyList } from './core/HotkeyList';
import { HttpResponseDurationTag } from './core/HttpResponseDurationTag'; import { HttpResponseDurationTag } from './core/HttpResponseDurationTag';
import { HttpStatusTag } from './core/HttpStatusTag'; import { HttpStatusTag } from './core/HttpStatusTag';
import { Icon } from './core/Icon';
import { LoadingIcon } from './core/LoadingIcon'; import { LoadingIcon } from './core/LoadingIcon';
import { PillButton } from './core/PillButton';
import { SizeTag } from './core/SizeTag'; import { SizeTag } from './core/SizeTag';
import { HStack, VStack } from './core/Stacks'; import { HStack, VStack } from './core/Stacks';
import type { TabItem } from './core/Tabs/Tabs'; import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs'; import { TabContent, Tabs } from './core/Tabs/Tabs';
import { Tooltip } from './core/Tooltip';
import { EmptyStateText } from './EmptyStateText'; import { EmptyStateText } from './EmptyStateText';
import { ErrorBoundary } from './ErrorBoundary'; import { ErrorBoundary } from './ErrorBoundary';
import { HttpResponseTimeline } from './HttpResponseTimeline'; import { HttpResponseTimeline } from './HttpResponseTimeline';
@@ -57,6 +60,11 @@ const TAB_TIMELINE = 'timeline';
export type TimelineViewMode = 'timeline' | 'text'; export type TimelineViewMode = 'timeline' | 'text';
interface RedirectDropWarning {
droppedBodyCount: number;
droppedHeaders: string[];
}
export function HttpResponsePane({ style, className, activeRequestId }: Props) { export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId); const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId); const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
@@ -65,6 +73,12 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence; const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
const responseEvents = useHttpResponseEvents(activeResponse); const responseEvents = useHttpResponseEvents(activeResponse);
const redirectDropWarning = useMemo(
() => getRedirectDropWarning(responseEvents.data),
[responseEvents.data],
);
const shouldShowRedirectDropWarning =
activeResponse?.state === 'closed' && redirectDropWarning != null;
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]); const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
@@ -162,32 +176,77 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
)} )}
> >
{activeResponse && ( {activeResponse && (
<HStack <div
space={2}
alignItems="center"
className={classNames( className={classNames(
'grid grid-cols-[auto_minmax(4rem,1fr)_auto]',
'cursor-default select-none', 'cursor-default select-none',
'whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars', 'whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars',
)} )}
> >
{activeResponse.state !== 'closed' && <LoadingIcon size="sm" />} <HStack space={2} className="w-full flex-shrink-0">
<HttpStatusTag showReason response={activeResponse} /> {activeResponse.state !== 'closed' && <LoadingIcon size="sm" />}
<span>&bull;</span> <HttpStatusTag showReason response={activeResponse} />
<HttpResponseDurationTag response={activeResponse} /> <span>&bull;</span>
<span>&bull;</span> <HttpResponseDurationTag response={activeResponse} />
<SizeTag <span>&bull;</span>
contentLength={activeResponse.contentLength ?? 0} <SizeTag
contentLengthCompressed={activeResponse.contentLengthCompressed} contentLength={activeResponse.contentLength ?? 0}
/> contentLengthCompressed={activeResponse.contentLengthCompressed}
/>
<div className="ml-auto"> </HStack>
{shouldShowRedirectDropWarning ? (
<Tooltip
tabIndex={0}
className="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 <RecentHttpResponsesDropdown
responses={responses} responses={responses}
activeResponse={activeResponse} activeResponse={activeResponse}
onPinnedResponseId={setPinnedResponseId} onPinnedResponseId={setPinnedResponseId}
/> />
</div> </div>
</HStack> </div>
)} )}
</HStack> </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({ function EnsureCompleteResponse({
response, response,
Component, Component,

View File

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

View File

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