mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-19 23:31:21 +02:00
Add Cookies response pane tab (#346)
This commit is contained in:
@@ -28,8 +28,8 @@ import { ErrorBoundary } from './ErrorBoundary';
|
|||||||
import { HttpResponseTimeline } from './HttpResponseTimeline';
|
import { HttpResponseTimeline } from './HttpResponseTimeline';
|
||||||
import { RecentHttpResponsesDropdown } from './RecentHttpResponsesDropdown';
|
import { RecentHttpResponsesDropdown } from './RecentHttpResponsesDropdown';
|
||||||
import { RequestBodyViewer } from './RequestBodyViewer';
|
import { RequestBodyViewer } from './RequestBodyViewer';
|
||||||
|
import { ResponseCookies } from './ResponseCookies';
|
||||||
import { ResponseHeaders } from './ResponseHeaders';
|
import { ResponseHeaders } from './ResponseHeaders';
|
||||||
import { ResponseInfo } from './ResponseInfo';
|
|
||||||
import { AudioViewer } from './responseViewers/AudioViewer';
|
import { AudioViewer } from './responseViewers/AudioViewer';
|
||||||
import { CsvViewer } from './responseViewers/CsvViewer';
|
import { CsvViewer } from './responseViewers/CsvViewer';
|
||||||
import { EventStreamViewer } from './responseViewers/EventStreamViewer';
|
import { EventStreamViewer } from './responseViewers/EventStreamViewer';
|
||||||
@@ -52,7 +52,7 @@ interface Props {
|
|||||||
const TAB_BODY = 'body';
|
const TAB_BODY = 'body';
|
||||||
const TAB_REQUEST = 'request';
|
const TAB_REQUEST = 'request';
|
||||||
const TAB_HEADERS = 'headers';
|
const TAB_HEADERS = 'headers';
|
||||||
const TAB_INFO = 'info';
|
const TAB_COOKIES = 'cookies';
|
||||||
const TAB_TIMELINE = 'timeline';
|
const TAB_TIMELINE = 'timeline';
|
||||||
|
|
||||||
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||||
@@ -67,16 +67,31 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
|
|
||||||
const responseEvents = useHttpResponseEvents(activeResponse);
|
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<TabItem[]>(
|
const tabs = useMemo<TabItem[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
value: TAB_BODY,
|
value: TAB_BODY,
|
||||||
label: 'Preview Mode',
|
label: 'Response',
|
||||||
options: {
|
options: {
|
||||||
value: viewMode,
|
value: viewMode,
|
||||||
onChange: setViewMode,
|
onChange: setViewMode,
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Pretty', value: 'pretty' },
|
{ label: 'Response', value: 'pretty' },
|
||||||
...(mimeType?.startsWith('image') ? [] : [{ label: 'Raw', value: 'raw' }]),
|
...(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 ? <CountBadge count={cookieCount} /> : null,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: TAB_TIMELINE,
|
value: TAB_TIMELINE,
|
||||||
label: 'Timeline',
|
label: 'Timeline',
|
||||||
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
|
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: TAB_INFO,
|
|
||||||
label: 'Info',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
activeResponse?.headers,
|
activeResponse?.headers,
|
||||||
activeResponse?.requestContentLength,
|
activeResponse?.requestContentLength,
|
||||||
activeResponse?.requestHeaders.length,
|
activeResponse?.requestHeaders.length,
|
||||||
|
cookieCount,
|
||||||
mimeType,
|
mimeType,
|
||||||
responseEvents.data?.length,
|
responseEvents.data?.length,
|
||||||
setViewMode,
|
setViewMode,
|
||||||
@@ -249,8 +266,8 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
<TabContent value={TAB_HEADERS}>
|
<TabContent value={TAB_HEADERS}>
|
||||||
<ResponseHeaders response={activeResponse} />
|
<ResponseHeaders response={activeResponse} />
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value={TAB_INFO}>
|
<TabContent value={TAB_COOKIES}>
|
||||||
<ResponseInfo response={activeResponse} />
|
<ResponseCookies response={activeResponse} />
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value={TAB_TIMELINE}>
|
<TabContent value={TAB_TIMELINE}>
|
||||||
<HttpResponseTimeline response={activeResponse} />
|
<HttpResponseTimeline response={activeResponse} />
|
||||||
|
|||||||
225
src-web/components/ResponseCookies.tsx
Normal file
225
src-web/components/ResponseCookies.tsx
Normal file
@@ -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<string, { name: string; value: string }>();
|
||||||
|
const receivedMap = new Map<string, ParsedCookie>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
|
||||||
|
<DetailsBanner
|
||||||
|
defaultOpen
|
||||||
|
storageKey={`${response.requestId}.sent_cookies`}
|
||||||
|
summary={
|
||||||
|
<h2 className="flex items-center">
|
||||||
|
Sent Cookies <CountBadge showZero count={sentCookies.length} />
|
||||||
|
</h2>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sentCookies.length === 0 ? (
|
||||||
|
<NoCookies />
|
||||||
|
) : (
|
||||||
|
<KeyValueRows>
|
||||||
|
{sentCookies.map((cookie, i) => (
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: none
|
||||||
|
<KeyValueRow labelColor="primary" key={i} label={cookie.name}>
|
||||||
|
{cookie.value}
|
||||||
|
</KeyValueRow>
|
||||||
|
))}
|
||||||
|
</KeyValueRows>
|
||||||
|
)}
|
||||||
|
</DetailsBanner>
|
||||||
|
|
||||||
|
<DetailsBanner
|
||||||
|
defaultOpen
|
||||||
|
storageKey={`${response.requestId}.received_cookies`}
|
||||||
|
summary={
|
||||||
|
<h2 className="flex items-center">
|
||||||
|
Received Cookies <CountBadge showZero count={receivedCookies.length} />
|
||||||
|
</h2>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{receivedCookies.length === 0 ? (
|
||||||
|
<NoCookies />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{receivedCookies.map((cookie, i) => (
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: none
|
||||||
|
<div key={i} className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2 my-1">
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
'font-mono text-editor select-auto cursor-auto',
|
||||||
|
cookie.isDeleted ? 'line-through opacity-60 text-text-subtle' : 'text-text',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cookie.name}
|
||||||
|
<span className="text-text-subtlest select-auto cursor-auto mx-0.5">=</span>
|
||||||
|
{cookie.value}
|
||||||
|
</span>
|
||||||
|
{cookie.isDeleted && (
|
||||||
|
<span className="text-xs font-sans text-danger bg-danger/10 px-1.5 py-0.5 rounded">
|
||||||
|
Deleted
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<KeyValueRows>
|
||||||
|
{[
|
||||||
|
cookie.domain && (
|
||||||
|
<KeyValueRow labelColor="info" label="Domain" key="domain">
|
||||||
|
{cookie.domain}
|
||||||
|
</KeyValueRow>
|
||||||
|
),
|
||||||
|
cookie.path && (
|
||||||
|
<KeyValueRow labelColor="info" label="Path" key="path">
|
||||||
|
{cookie.path}
|
||||||
|
</KeyValueRow>
|
||||||
|
),
|
||||||
|
cookie.expires && (
|
||||||
|
<KeyValueRow labelColor="info" label="Expires" key="expires">
|
||||||
|
{cookie.expires}
|
||||||
|
</KeyValueRow>
|
||||||
|
),
|
||||||
|
cookie.maxAge && (
|
||||||
|
<KeyValueRow labelColor="info" label="Max-Age" key="maxAge">
|
||||||
|
{cookie.maxAge}
|
||||||
|
</KeyValueRow>
|
||||||
|
),
|
||||||
|
cookie.secure && (
|
||||||
|
<KeyValueRow labelColor="info" label="Secure" key="secure">
|
||||||
|
true
|
||||||
|
</KeyValueRow>
|
||||||
|
),
|
||||||
|
cookie.httpOnly && (
|
||||||
|
<KeyValueRow labelColor="info" label="HttpOnly" key="httpOnly">
|
||||||
|
true
|
||||||
|
</KeyValueRow>
|
||||||
|
),
|
||||||
|
cookie.sameSite && (
|
||||||
|
<KeyValueRow labelColor="info" label="SameSite" key="sameSite">
|
||||||
|
{cookie.sameSite}
|
||||||
|
</KeyValueRow>
|
||||||
|
),
|
||||||
|
].filter((item): item is JSX.Element => Boolean(item))}
|
||||||
|
</KeyValueRows>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DetailsBanner>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoCookies() {
|
||||||
|
return <span className="text-text-subtlest text-sm italic">No Cookies</span>;
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { CountBadge } from './core/CountBadge';
|
import { CountBadge } from './core/CountBadge';
|
||||||
import { DetailsBanner } from './core/DetailsBanner';
|
import { DetailsBanner } from './core/DetailsBanner';
|
||||||
|
import { IconButton } from './core/IconButton';
|
||||||
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
|
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -25,11 +27,33 @@ export function ResponseHeaders({ response }: Props) {
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
|
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
|
||||||
|
<DetailsBanner storageKey={`${response.requestId}.general`} summary={<h2>Info</h2>}>
|
||||||
|
<KeyValueRows>
|
||||||
|
<KeyValueRow labelColor="secondary" label="Request URL">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="select-text cursor-text">{response.url}</span>
|
||||||
|
<IconButton
|
||||||
|
iconSize="sm"
|
||||||
|
className="inline-block w-auto !h-auto opacity-50 hover:opacity-100"
|
||||||
|
icon="external_link"
|
||||||
|
onClick={() => openUrl(response.url)}
|
||||||
|
title="Open in browser"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</KeyValueRow>
|
||||||
|
<KeyValueRow labelColor="secondary" label="Remote Address">
|
||||||
|
{response.remoteAddr ?? <span className="text-text-subtlest">--</span>}
|
||||||
|
</KeyValueRow>
|
||||||
|
<KeyValueRow labelColor="secondary" label="Version">
|
||||||
|
{response.version ?? <span className="text-text-subtlest">--</span>}
|
||||||
|
</KeyValueRow>
|
||||||
|
</KeyValueRows>
|
||||||
|
</DetailsBanner>
|
||||||
<DetailsBanner
|
<DetailsBanner
|
||||||
storageKey={`${response.requestId}.request_headers`}
|
storageKey={`${response.requestId}.request_headers`}
|
||||||
summary={
|
summary={
|
||||||
<h2 className="flex items-center">
|
<h2 className="flex items-center">
|
||||||
Request <CountBadge showZero count={requestHeaders.length} />
|
Request Headers <CountBadge showZero count={requestHeaders.length} />
|
||||||
</h2>
|
</h2>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -51,7 +75,7 @@ export function ResponseHeaders({ response }: Props) {
|
|||||||
storageKey={`${response.requestId}.response_headers`}
|
storageKey={`${response.requestId}.response_headers`}
|
||||||
summary={
|
summary={
|
||||||
<h2 className="flex items-center">
|
<h2 className="flex items-center">
|
||||||
Response <CountBadge showZero count={responseHeaders.length} />
|
Response Headers <CountBadge showZero count={responseHeaders.length} />
|
||||||
</h2>
|
</h2>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -61,7 +85,7 @@ export function ResponseHeaders({ response }: Props) {
|
|||||||
<KeyValueRows>
|
<KeyValueRows>
|
||||||
{responseHeaders.map((h, i) => (
|
{responseHeaders.map((h, i) => (
|
||||||
// biome-ignore lint/suspicious/noArrayIndexKey: none
|
// biome-ignore lint/suspicious/noArrayIndexKey: none
|
||||||
<KeyValueRow labelColor="primary" key={i} label={h.name}>
|
<KeyValueRow labelColor="info" key={i} label={h.name}>
|
||||||
{h.value}
|
{h.value}
|
||||||
</KeyValueRow>
|
</KeyValueRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user