diff --git a/src-web/components/HttpResponsePane.tsx b/src-web/components/HttpResponsePane.tsx index c59bcdc1..c761ef23 100644 --- a/src-web/components/HttpResponsePane.tsx +++ b/src-web/components/HttpResponsePane.tsx @@ -28,8 +28,8 @@ import { ErrorBoundary } from './ErrorBoundary'; import { HttpResponseTimeline } from './HttpResponseTimeline'; import { RecentHttpResponsesDropdown } from './RecentHttpResponsesDropdown'; import { RequestBodyViewer } from './RequestBodyViewer'; +import { ResponseCookies } from './ResponseCookies'; import { ResponseHeaders } from './ResponseHeaders'; -import { ResponseInfo } from './ResponseInfo'; import { AudioViewer } from './responseViewers/AudioViewer'; import { CsvViewer } from './responseViewers/CsvViewer'; import { EventStreamViewer } from './responseViewers/EventStreamViewer'; @@ -52,7 +52,7 @@ interface Props { const TAB_BODY = 'body'; const TAB_REQUEST = 'request'; const TAB_HEADERS = 'headers'; -const TAB_INFO = 'info'; +const TAB_COOKIES = 'cookies'; const TAB_TIMELINE = 'timeline'; export function HttpResponsePane({ style, className, activeRequestId }: Props) { @@ -67,16 +67,31 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { const responseEvents = useHttpResponseEvents(activeResponse); + const cookieCount = useMemo(() => { + if (!responseEvents.data) return 0; + let count = 0; + for (const event of responseEvents.data) { + const e = event.event; + if ( + (e.type === 'header_up' && e.name.toLowerCase() === 'cookie') || + (e.type === 'header_down' && e.name.toLowerCase() === 'set-cookie') + ) { + count++; + } + } + return count; + }, [responseEvents.data]); + const tabs = useMemo( () => [ { value: TAB_BODY, - label: 'Preview Mode', + label: 'Response', options: { value: viewMode, onChange: setViewMode, items: [ - { label: 'Pretty', value: 'pretty' }, + { label: 'Response', value: 'pretty' }, ...(mimeType?.startsWith('image') ? [] : [{ label: 'Raw', value: 'raw' }]), ], }, @@ -97,20 +112,22 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { /> ), }, + { + value: TAB_COOKIES, + label: 'Cookies', + rightSlot: cookieCount > 0 ? : null, + }, { value: TAB_TIMELINE, label: 'Timeline', rightSlot: , }, - { - value: TAB_INFO, - label: 'Info', - }, ], [ activeResponse?.headers, activeResponse?.requestContentLength, activeResponse?.requestHeaders.length, + cookieCount, mimeType, responseEvents.data?.length, setViewMode, @@ -249,8 +266,8 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { - - + + diff --git a/src-web/components/ResponseCookies.tsx b/src-web/components/ResponseCookies.tsx new file mode 100644 index 00000000..5e217a9c --- /dev/null +++ b/src-web/components/ResponseCookies.tsx @@ -0,0 +1,225 @@ +import type { HttpResponse } from '@yaakapp-internal/models'; +import classNames from 'classnames'; +import { useMemo } from 'react'; +import type { JSX } from 'react/jsx-runtime'; +import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents'; +import { CountBadge } from './core/CountBadge'; +import { DetailsBanner } from './core/DetailsBanner'; +import { KeyValueRow, KeyValueRows } from './core/KeyValueRow'; + +interface Props { + response: HttpResponse; +} + +interface ParsedCookie { + name: string; + value: string; + domain?: string; + path?: string; + expires?: string; + maxAge?: string; + secure?: boolean; + httpOnly?: boolean; + sameSite?: string; + isDeleted?: boolean; +} + +function parseCookieHeader(cookieHeader: string): Array<{ name: string; value: string }> { + // Parse "Cookie: name=value; name2=value2" format + return cookieHeader.split(';').map((pair) => { + const [name = '', ...valueParts] = pair.split('='); + return { + name: name.trim(), + value: valueParts.join('=').trim(), + }; + }); +} + +function parseSetCookieHeader(setCookieHeader: string): ParsedCookie { + // Parse "Set-Cookie: name=value; Domain=...; Path=..." format + const parts = setCookieHeader.split(';').map((p) => p.trim()); + const [nameValue = '', ...attributes] = parts; + const [name = '', ...valueParts] = nameValue.split('='); + + const cookie: ParsedCookie = { + name: name.trim(), + value: valueParts.join('=').trim(), + }; + + for (const attr of attributes) { + const [key = '', val] = attr.split('=').map((s) => s.trim()); + const lowerKey = key.toLowerCase(); + + if (lowerKey === 'domain') cookie.domain = val; + else if (lowerKey === 'path') cookie.path = val; + else if (lowerKey === 'expires') cookie.expires = val; + else if (lowerKey === 'max-age') cookie.maxAge = val; + else if (lowerKey === 'secure') cookie.secure = true; + else if (lowerKey === 'httponly') cookie.httpOnly = true; + else if (lowerKey === 'samesite') cookie.sameSite = val; + } + + // Detect if cookie is being deleted + if (cookie.maxAge !== undefined) { + const maxAgeNum = Number.parseInt(cookie.maxAge, 10); + if (!Number.isNaN(maxAgeNum) && maxAgeNum <= 0) { + cookie.isDeleted = true; + } + } else if (cookie.expires !== undefined) { + // Check if expires date is in the past + try { + const expiresDate = new Date(cookie.expires); + if (expiresDate.getTime() < Date.now()) { + cookie.isDeleted = true; + } + } catch { + // Invalid date, ignore + } + } + + return cookie; +} + +export function ResponseCookies({ response }: Props) { + const { data: events } = useHttpResponseEvents(response); + + const { sentCookies, receivedCookies } = useMemo(() => { + if (!events) return { sentCookies: [], receivedCookies: [] }; + + // Use Maps to deduplicate by cookie name (latest value wins) + const sentMap = new Map(); + const receivedMap = new Map(); + + for (const event of events) { + const e = event.event; + + // Cookie headers sent (header_up with name=cookie) + if (e.type === 'header_up' && e.name.toLowerCase() === 'cookie') { + const cookies = parseCookieHeader(e.value); + for (const cookie of cookies) { + sentMap.set(cookie.name, cookie); + } + } + + // Set-Cookie headers received (header_down with name=set-cookie) + if (e.type === 'header_down' && e.name.toLowerCase() === 'set-cookie') { + const cookie = parseSetCookieHeader(e.value); + receivedMap.set(cookie.name, cookie); + } + } + + return { + sentCookies: Array.from(sentMap.values()), + receivedCookies: Array.from(receivedMap.values()), + }; + }, [events]); + + return ( +
+ + Sent Cookies + + } + > + {sentCookies.length === 0 ? ( + + ) : ( + + {sentCookies.map((cookie, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: none + + {cookie.value} + + ))} + + )} + + + + Received Cookies + + } + > + {receivedCookies.length === 0 ? ( + + ) : ( +
+ {receivedCookies.map((cookie, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: none +
+
+ + {cookie.name} + = + {cookie.value} + + {cookie.isDeleted && ( + + Deleted + + )} +
+ + {[ + cookie.domain && ( + + {cookie.domain} + + ), + cookie.path && ( + + {cookie.path} + + ), + cookie.expires && ( + + {cookie.expires} + + ), + cookie.maxAge && ( + + {cookie.maxAge} + + ), + cookie.secure && ( + + true + + ), + cookie.httpOnly && ( + + true + + ), + cookie.sameSite && ( + + {cookie.sameSite} + + ), + ].filter((item): item is JSX.Element => Boolean(item))} + +
+ ))} +
+ )} +
+
+ ); +} + +function NoCookies() { + return No Cookies; +} diff --git a/src-web/components/ResponseHeaders.tsx b/src-web/components/ResponseHeaders.tsx index e109c806..67f7e665 100644 --- a/src-web/components/ResponseHeaders.tsx +++ b/src-web/components/ResponseHeaders.tsx @@ -1,7 +1,9 @@ +import { openUrl } from '@tauri-apps/plugin-opener'; import type { HttpResponse } from '@yaakapp-internal/models'; import { useMemo } from 'react'; import { CountBadge } from './core/CountBadge'; import { DetailsBanner } from './core/DetailsBanner'; +import { IconButton } from './core/IconButton'; import { KeyValueRow, KeyValueRows } from './core/KeyValueRow'; interface Props { @@ -25,11 +27,33 @@ export function ResponseHeaders({ response }: Props) { ); return (
+ Info}> + + +
+ {response.url} + openUrl(response.url)} + title="Open in browser" + /> +
+
+ + {response.remoteAddr ?? --} + + + {response.version ?? --} + +
+
- Request + Request Headers } > @@ -51,7 +75,7 @@ export function ResponseHeaders({ response }: Props) { storageKey={`${response.requestId}.response_headers`} summary={

- Response + Response Headers

} > @@ -61,7 +85,7 @@ export function ResponseHeaders({ response }: Props) { {responseHeaders.map((h, i) => ( // biome-ignore lint/suspicious/noArrayIndexKey: none - + {h.value} ))}