Improve response history menu (#492)

This commit is contained in:
Gregory Schier
2026-07-02 10:04:57 -07:00
committed by GitHub
parent 9b524e3dc7
commit 95ac3e310a
12 changed files with 351 additions and 75 deletions
@@ -4,10 +4,12 @@ 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";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse"; import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents"; import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse"; import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText"; import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
import { useResponseViewMode } from "../hooks/useResponseViewMode"; import { useResponseViewMode } from "../hooks/useResponseViewMode";
import { useSaveResponse } from "../hooks/useSaveResponse";
import { useTimelineViewMode } from "../hooks/useTimelineViewMode"; import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
import { getMimeTypeFromContentType } from "../lib/contentType"; import { getMimeTypeFromContentType } from "../lib/contentType";
import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util"; import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util";
@@ -78,6 +80,8 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
activeResponse?.state === "closed" && redirectDropWarning != null; activeResponse?.state === "closed" && redirectDropWarning != null;
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]); const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
const saveResponse = useSaveResponse(activeResponse ?? null);
const copyResponse = useCopyHttpResponse(activeResponse ?? null);
const tabs = useMemo<TabItem[]>( const tabs = useMemo<TabItem[]>(
() => [ () => [
@@ -93,6 +97,22 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
? [] ? []
: [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]), : [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]),
], ],
itemsAfter: [
{
label: "Save to File",
onSelect: saveResponse.mutate,
leftSlot: <Icon icon="save" />,
hidden: activeResponse == null || !!activeResponse.error,
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
},
{
label: "Copy Body",
onSelect: copyResponse.mutate,
leftSlot: <Icon icon="copy" />,
hidden: activeResponse == null || !!activeResponse.error,
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
},
],
}, },
}, },
{ {
@@ -135,12 +155,18 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
], ],
[ [
activeResponse?.headers, activeResponse?.headers,
activeResponse,
activeResponse?.error,
activeResponse?.requestContentLength, activeResponse?.requestContentLength,
activeResponse?.requestHeaders.length, activeResponse?.requestHeaders.length,
activeResponse?.state,
activeResponse?.status,
cookieCounts.sent, cookieCounts.sent,
cookieCounts.received, cookieCounts.received,
copyResponse.mutate,
mimeType, mimeType,
responseEvents.data?.length, responseEvents.data?.length,
saveResponse.mutate,
setViewMode, setViewMode,
viewMode, viewMode,
timelineViewMode, timelineViewMode,
@@ -1,10 +1,17 @@
import type { GrpcConnection } from "@yaakapp-internal/models"; import type { GrpcConnection } from "@yaakapp-internal/models";
import { deleteModel } from "@yaakapp-internal/models"; import { deleteModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui"; import { HStack, Icon } from "@yaakapp-internal/ui";
import { formatDistanceToNowStrict } from "date-fns"; import {
differenceInHours,
differenceInMinutes,
format,
isToday,
isYesterday,
} from "date-fns";
import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections"; import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections";
import { pluralizeCount } from "../lib/pluralize"; import { pluralizeCount } from "../lib/pluralize";
import { Dropdown } from "./core/Dropdown"; import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
interface Props { interface Props {
@@ -20,6 +27,63 @@ export function RecentGrpcConnectionsDropdown({
}: Props) { }: Props) {
const deleteAllConnections = useDeleteGrpcConnections(activeConnection?.requestId); const deleteAllConnections = useDeleteGrpcConnections(activeConnection?.requestId);
const latestConnectionId = connections[0]?.id ?? "n/a"; const latestConnectionId = connections[0]?.id ?? "n/a";
const connectionHistoryItems: DropdownItem[] = [];
let lastHistoryGroup: string | null = null;
let hasRecentConnections = false;
let hasShownRecentEmptyState = false;
const now = new Date();
for (const c of connections) {
const createdAt = `${c.createdAt}Z`;
const createdAtDate = new Date(createdAt);
const minutesAgo = differenceInMinutes(now, createdAtDate);
const hoursAgo = differenceInHours(now, createdAtDate);
let historyGroup = format(createdAtDate, "MMM d, yyyy");
if (minutesAgo < 5) historyGroup = "Just now";
else if (minutesAgo < 15) historyGroup = "5 minutes ago";
else if (minutesAgo < 60) historyGroup = "15 minutes ago";
else if (hoursAgo < 3) historyGroup = "1 hour ago";
else if (hoursAgo < 6) historyGroup = "3 hours ago";
else if (isToday(createdAtDate)) historyGroup = "Today";
else if (isYesterday(createdAtDate)) historyGroup = "Yesterday";
else if (createdAtDate.getFullYear() === now.getFullYear()) historyGroup = format(createdAtDate, "MMM d");
const absoluteTime = format(createdAt, "MMM d, yyyy, h:mm:ss a O");
if (historyGroup === "Just now") {
hasRecentConnections = true;
} else if (!hasRecentConnections && !hasShownRecentEmptyState) {
connectionHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
});
hasShownRecentEmptyState = true;
}
if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) {
connectionHistoryItems.push({
type: "separator",
label: <span title={absoluteTime}>{historyGroup}</span>,
});
lastHistoryGroup = historyGroup;
}
connectionHistoryItems.push({
label: (
<HStack space={2} className="text-sm" title={absoluteTime}>
<span className="font-mono">{formatMillis(c.elapsed)}</span>
</HStack>
),
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedConnectionId(c.id),
});
}
if (!hasRecentConnections && !hasShownRecentEmptyState) {
connectionHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
});
}
return ( return (
<Dropdown <Dropdown
@@ -36,16 +100,7 @@ export function RecentGrpcConnectionsDropdown({
disabled: connections.length === 0, disabled: connections.length === 0,
}, },
{ type: "separator", label: "History" }, { type: "separator", label: "History" },
...connections.map((c) => ({ ...connectionHistoryItems,
label: (
<HStack space={2}>
{formatDistanceToNowStrict(`${c.createdAt}Z`)} ago &bull;{" "}
<span className="font-mono text-sm">{c.elapsed}ms</span>
</HStack>
),
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedConnectionId(c.id),
})),
]} ]}
> >
<IconButton <IconButton
@@ -1,13 +1,21 @@
import type { HttpResponse } from "@yaakapp-internal/models"; import type { HttpResponse } from "@yaakapp-internal/models";
import { deleteModel } from "@yaakapp-internal/models"; import { deleteModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui"; import { HStack, Icon } from "@yaakapp-internal/ui";
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse"; import {
differenceInHours,
differenceInMinutes,
format,
isToday,
isYesterday,
} from "date-fns";
import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses"; import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
import { useSaveResponse } from "../hooks/useSaveResponse"; import { useKeyValue } from "../hooks/useKeyValue";
import { pluralize } from "../lib/pluralize"; import { DismissibleBanner } from "./core/DismissibleBanner";
import { Dropdown } from "./core/Dropdown"; import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { HttpStatusTag } from "./core/HttpStatusTag"; import { HttpStatusTag } from "./core/HttpStatusTag";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import { SizeTag } from "./core/SizeTag";
interface Props { interface Props {
responses: HttpResponse[]; responses: HttpResponse[];
@@ -22,32 +30,93 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
onPinnedResponseId, onPinnedResponseId,
}: Props) { }: Props) {
const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId); const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId);
const movedActionsBannerId = "response-actions-moved-to-response-menu-2026-07-02-v2";
const { value: dismissedMovedActions } = useKeyValue<boolean>({
namespace: "global",
key: ["dismiss-banner", movedActionsBannerId],
fallback: false,
});
const latestResponseId = responses[0]?.id ?? "n/a"; const latestResponseId = responses[0]?.id ?? "n/a";
const saveResponse = useSaveResponse(activeResponse); const responseHistoryItems: DropdownItem[] = [];
const copyResponse = useCopyHttpResponse(activeResponse); let lastHistoryGroup: string | null = null;
let hasRecentResponses = false;
let hasShownRecentEmptyState = false;
const now = new Date();
for (const r of responses) {
const createdAt = `${r.createdAt}Z`;
const createdAtDate = new Date(createdAt);
const minutesAgo = differenceInMinutes(now, createdAtDate);
const hoursAgo = differenceInHours(now, createdAtDate);
let historyGroup = format(createdAtDate, "MMM d, yyyy");
if (minutesAgo < 5) historyGroup = "Just now";
else if (minutesAgo < 15) historyGroup = "5 minutes ago";
else if (minutesAgo < 60) historyGroup = "15 minutes ago";
else if (hoursAgo < 3) historyGroup = "1 hour ago";
else if (hoursAgo < 6) historyGroup = "3 hours ago";
else if (isToday(createdAtDate)) historyGroup = "Today";
else if (isYesterday(createdAtDate)) historyGroup = "Yesterday";
else if (createdAtDate.getFullYear() === now.getFullYear()) historyGroup = format(createdAtDate, "MMM d");
const absoluteTime = format(createdAt, "MMM d, yyyy, h:mm:ss a O");
if (historyGroup === "Just now") {
hasRecentResponses = true;
} else if (!hasRecentResponses && !hasShownRecentEmptyState) {
responseHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent requests</span>,
});
hasShownRecentEmptyState = true;
}
if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) {
responseHistoryItems.push({
type: "separator",
label: <span title={absoluteTime}>{historyGroup}</span>,
});
lastHistoryGroup = historyGroup;
}
responseHistoryItems.push({
label: (
<HStack space={2} className="text-sm" title={absoluteTime}>
<HttpStatusTag short className="text-xs" response={r} />
<span className="text-text-subtlest">&bull;</span>
<span className="font-mono">{r.elapsed >= 0 ? formatMillis(r.elapsed) : "n/a"}</span>
<span className="text-text-subtlest">&bull;</span>
<SizeTag
className="text-xs"
contentLength={r.contentLength ?? 0}
contentLengthCompressed={r.contentLengthCompressed}
/>
</HStack>
),
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedResponseId(r.id),
});
}
if (!hasRecentResponses && !hasShownRecentEmptyState) {
responseHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent requests</span>,
});
}
return ( return (
<Dropdown <Dropdown
items={[ items={[
{
label: "Save to File",
onSelect: saveResponse.mutate,
leftSlot: <Icon icon="save" />,
hidden: responses.length === 0 || !!activeResponse.error,
disabled: activeResponse.state !== "closed" && activeResponse.status >= 100,
},
{
label: "Copy Body",
onSelect: copyResponse.mutate,
leftSlot: <Icon icon="copy" />,
hidden: responses.length === 0 || !!activeResponse.error,
disabled: activeResponse.state !== "closed" && activeResponse.status >= 100,
},
{ {
label: "Delete", label: "Delete",
leftSlot: <Icon icon="trash" />, leftSlot: <Icon icon="trash" />,
onSelect: () => deleteModel(activeResponse), onSelect: () => deleteModel(activeResponse),
}, },
{
label: "Delete all",
leftSlot: <Icon icon="trash" />,
onSelect: deleteAllResponses.mutate,
disabled: responses.length === 0,
},
{ {
label: "Unpin Response", label: "Unpin Response",
onSelect: () => onPinnedResponseId(activeResponse.id), onSelect: () => onPinnedResponseId(activeResponse.id),
@@ -55,25 +124,25 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
hidden: latestResponseId === activeResponse.id, hidden: latestResponseId === activeResponse.id,
disabled: responses.length === 0, disabled: responses.length === 0,
}, },
{ type: "separator", label: "History" },
{ {
label: `Delete ${responses.length} ${pluralize("Response", responses.length)}`, type: "content",
onSelect: deleteAllResponses.mutate, hidden: dismissedMovedActions === true,
hidden: responses.length === 0,
disabled: responses.length === 0,
},
{ type: "separator" },
...responses.map((r: HttpResponse) => ({
label: ( label: (
<HStack space={2}> <DismissibleBanner
<HttpStatusTag short className="text-xs" response={r} /> id={movedActionsBannerId}
<span className="text-text-subtle">&rarr;</span>{" "} color="info"
<span className="font-mono text-sm">{r.elapsed >= 0 ? `${r.elapsed}ms` : "n/a"}</span> size="xs"
</HStack> className="max-w-72"
>
<p>Copy and save actions moved to the Response tab menu.</p>
</DismissibleBanner>
), ),
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />, },
onSelect: () => onPinnedResponseId(r.id), {
})), type: "separator",
label: "Recent",
},
...responseHistoryItems,
]} ]}
> >
<IconButton <IconButton
@@ -1,10 +1,17 @@
import type { WebsocketConnection } from "@yaakapp-internal/models"; import type { WebsocketConnection } from "@yaakapp-internal/models";
import { deleteModel, getModel } from "@yaakapp-internal/models"; import { deleteModel, getModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui"; import { HStack, Icon } from "@yaakapp-internal/ui";
import { formatDistanceToNowStrict } from "date-fns"; import {
differenceInHours,
differenceInMinutes,
format,
isToday,
isYesterday,
} from "date-fns";
import { deleteWebsocketConnections } from "../commands/deleteWebsocketConnections"; import { deleteWebsocketConnections } from "../commands/deleteWebsocketConnections";
import { pluralizeCount } from "../lib/pluralize"; import { pluralizeCount } from "../lib/pluralize";
import { Dropdown } from "./core/Dropdown"; import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
interface Props { interface Props {
@@ -19,6 +26,63 @@ export function RecentWebsocketConnectionsDropdown({
onPinnedConnectionId, onPinnedConnectionId,
}: Props) { }: Props) {
const latestConnectionId = connections[0]?.id ?? "n/a"; const latestConnectionId = connections[0]?.id ?? "n/a";
const connectionHistoryItems: DropdownItem[] = [];
let lastHistoryGroup: string | null = null;
let hasRecentConnections = false;
let hasShownRecentEmptyState = false;
const now = new Date();
for (const c of connections) {
const createdAt = `${c.createdAt}Z`;
const createdAtDate = new Date(createdAt);
const minutesAgo = differenceInMinutes(now, createdAtDate);
const hoursAgo = differenceInHours(now, createdAtDate);
let historyGroup = format(createdAtDate, "MMM d, yyyy");
if (minutesAgo < 5) historyGroup = "Just now";
else if (minutesAgo < 15) historyGroup = "5 minutes ago";
else if (minutesAgo < 60) historyGroup = "15 minutes ago";
else if (hoursAgo < 3) historyGroup = "1 hour ago";
else if (hoursAgo < 6) historyGroup = "3 hours ago";
else if (isToday(createdAtDate)) historyGroup = "Today";
else if (isYesterday(createdAtDate)) historyGroup = "Yesterday";
else if (createdAtDate.getFullYear() === now.getFullYear()) historyGroup = format(createdAtDate, "MMM d");
const absoluteTime = format(createdAt, "MMM d, yyyy, h:mm:ss a O");
if (historyGroup === "Just now") {
hasRecentConnections = true;
} else if (!hasRecentConnections && !hasShownRecentEmptyState) {
connectionHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
});
hasShownRecentEmptyState = true;
}
if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) {
connectionHistoryItems.push({
type: "separator",
label: <span title={absoluteTime}>{historyGroup}</span>,
});
lastHistoryGroup = historyGroup;
}
connectionHistoryItems.push({
label: (
<HStack space={2} className="text-sm" title={absoluteTime}>
<span className="font-mono">{formatMillis(c.elapsed)}</span>
</HStack>
),
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedConnectionId(c.id),
});
}
if (!hasRecentConnections && !hasShownRecentEmptyState) {
connectionHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
});
}
return ( return (
<Dropdown <Dropdown
@@ -40,16 +104,7 @@ export function RecentWebsocketConnectionsDropdown({
disabled: connections.length === 0, disabled: connections.length === 0,
}, },
{ type: "separator", label: "History" }, { type: "separator", label: "History" },
...connections.map((c) => ({ ...connectionHistoryItems,
label: (
<HStack space={2}>
{formatDistanceToNowStrict(`${c.createdAt}Z`)} ago &bull;{" "}
<span className="font-mono text-sm">{c.elapsed}ms</span>
</HStack>
),
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedConnectionId(c.id),
})),
]} ]}
> >
<IconButton <IconButton
@@ -1,5 +1,6 @@
import { openUrl } from "@tauri-apps/plugin-opener"; import { openUrl } from "@tauri-apps/plugin-opener";
import type { HttpResponse } from "@yaakapp-internal/models"; import type { HttpResponse } from "@yaakapp-internal/models";
import { format, formatDistanceToNowStrict } from "date-fns";
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";
@@ -29,6 +30,14 @@ export function ResponseHeaders({ response }: Props) {
<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>}> <DetailsBanner storageKey={`${response.requestId}.general`} summary={<h2>Info</h2>}>
<KeyValueRows> <KeyValueRows>
<KeyValueRow labelColor="secondary" label="Sent">
<time
dateTime={new Date(`${response.createdAt}Z`).toISOString()}
title={formatDistanceToNowStrict(`${response.createdAt}Z`, { addSuffix: true })}
>
{format(`${response.createdAt}Z`, "MMM d, yyyy, h:mm:ss a O")}
</time>
</KeyValueRow>
<KeyValueRow labelColor="secondary" label="Request URL"> <KeyValueRow labelColor="secondary" label="Request URL">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="select-text cursor-text">{response.url}</span> <span className="select-text cursor-text">{response.url}</span>
@@ -2,21 +2,26 @@ import type { Color } from "@yaakapp-internal/plugins";
import type { BannerProps } from "@yaakapp-internal/ui"; import type { BannerProps } from "@yaakapp-internal/ui";
import { Banner } from "@yaakapp-internal/ui"; import { Banner } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import type { MouseEvent } from "react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useKeyValue } from "../../hooks/useKeyValue"; import { useKeyValue } from "../../hooks/useKeyValue";
import type { ButtonProps } from "./Button"; import type { ButtonProps } from "./Button";
import { Button } from "./Button"; import { Button } from "./Button";
type DismissibleBannerSize = "sm" | "xs";
export function DismissibleBanner({ export function DismissibleBanner({
children, children,
className, className,
id, id,
size = "sm",
onDismiss, onDismiss,
onShow, onShow,
actions, actions,
...props ...props
}: BannerProps & { }: BannerProps & {
id: string; id: string;
size?: DismissibleBannerSize;
onDismiss?: () => void | Promise<void>; onDismiss?: () => void | Promise<void>;
onShow?: () => void | Promise<void>; onShow?: () => void | Promise<void>;
actions?: { actions?: {
@@ -46,17 +51,36 @@ export function DismissibleBanner({
if (!shouldShow) return null; if (!shouldShow) return null;
const actionSize: ButtonProps["size"] = size === "xs" ? "2xs" : "xs";
const stopParentClick = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
};
return ( return (
<Banner className={classNames(className, "relative")} {...props}> <Banner
className={classNames(
className,
"relative",
size === "xs" && "!px-2 !py-2 text-xs",
)}
{...props}
>
<div className="@container"> <div className="@container">
<div className="grid gap-2 @[34rem]:grid-cols-[minmax(0,1fr)_auto] @[34rem]:items-center @[34rem]:gap-3"> <div
className={classNames(
"grid @[34rem]:grid-cols-[minmax(0,1fr)_auto] @[34rem]:items-center",
size === "xs" ? "gap-1.5 @[34rem]:gap-2" : "gap-2 @[34rem]:gap-3",
)}
>
{children} {children}
<div className="flex flex-wrap gap-1.5 @[34rem]:justify-end"> <div className="flex flex-wrap gap-1.5 @[34rem]:justify-end">
<Button <Button
variant="border" variant="border"
color={props.color} color={props.color}
size="xs" size={actionSize}
onClick={() => { onClick={(event) => {
stopParentClick(event);
setDismissed(true).catch(console.error); setDismissed(true).catch(console.error);
Promise.resolve(onDismiss?.()).catch(console.error); Promise.resolve(onDismiss?.()).catch(console.error);
}} }}
@@ -69,8 +93,11 @@ export function DismissibleBanner({
key={a.label} key={a.label}
variant={a.variant ?? "border"} variant={a.variant ?? "border"}
color={a.color ?? props.color} color={a.color ?? props.color}
size="xs" size={actionSize}
onClick={a.onClick} onClick={(event) => {
stopParentClick(event);
a.onClick();
}}
title={a.label} title={a.label}
> >
{a.label} {a.label}
@@ -31,7 +31,7 @@ export function HttpResponseDurationTag({ response }: Props) {
); );
} }
function formatMillis(ms: number) { export function formatMillis(ms: number) {
if (ms < 1000) { if (ms < 1000) {
return `${ms} ms`; return `${ms} ms`;
} }
+4 -2
View File
@@ -1,14 +1,16 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize"; import { formatSize } from "@yaakapp-internal/lib/formatSize";
import classNames from "classnames";
interface Props { interface Props {
className?: string;
contentLength: number; contentLength: number;
contentLengthCompressed?: number | null; contentLengthCompressed?: number | null;
} }
export function SizeTag({ contentLength, contentLengthCompressed }: Props) { export function SizeTag({ className, contentLength, contentLengthCompressed }: Props) {
return ( return (
<span <span
className="font-mono" className={classNames("font-mono", className)}
title={ title={
`${contentLength} bytes` + `${contentLength} bytes` +
(contentLengthCompressed ? `\n${contentLengthCompressed} bytes compressed` : "") (contentLengthCompressed ? `\n${contentLengthCompressed} bytes compressed` : "")
@@ -1,9 +1,12 @@
import type { HttpResponse } from "@yaakapp-internal/models"; import type { HttpResponse } from "@yaakapp-internal/models";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse";
import { useResponseBodyText } from "../../hooks/useResponseBodyText"; import { useResponseBodyText } from "../../hooks/useResponseBodyText";
import { useSaveResponse } from "../../hooks/useSaveResponse";
import { languageFromContentType } from "../../lib/contentType"; import { languageFromContentType } from "../../lib/contentType";
import { getContentTypeFromHeaders } from "../../lib/model_util"; import { getContentTypeFromHeaders } from "../../lib/model_util";
import type { EditorProps } from "../core/Editor/Editor"; import type { EditorProps } from "../core/Editor/Editor";
import { IconButton } from "../core/IconButton";
import { EmptyStateText } from "../EmptyStateText"; import { EmptyStateText } from "../EmptyStateText";
import { TextViewer } from "./TextViewer"; import { TextViewer } from "./TextViewer";
import { WebPageViewer } from "./WebPageViewer"; import { WebPageViewer } from "./WebPageViewer";
@@ -51,6 +54,9 @@ interface HttpTextViewerProps {
function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) { function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {
const [currentFilter, setCurrentFilter] = useState<string | null>(null); const [currentFilter, setCurrentFilter] = useState<string | null>(null);
const filteredBody = useResponseBodyText({ response, filter: currentFilter }); const filteredBody = useResponseBodyText({ response, filter: currentFilter });
const saveResponse = useSaveResponse(response);
const copyResponse = useCopyHttpResponse(response);
const actionsDisabled = response.state !== "closed" && response.status >= 100;
const filterCallback = useMemo( const filterCallback = useMemo(
() => (filter: string) => { () => (filter: string) => {
@@ -72,6 +78,26 @@ function HttpTextViewer({ response, text, language, pretty, className }: HttpTex
filterStateKey={`response.body.${response.requestId}`} filterStateKey={`response.body.${response.requestId}`}
pretty={pretty} pretty={pretty}
className={className} className={className}
footerActions={[
<IconButton
key="save"
size="sm"
icon="save"
title="Save response to file"
disabled={actionsDisabled}
onClick={() => saveResponse.mutate()}
className="border !border-border-subtle"
/>,
<IconButton
key="copy"
size="sm"
icon="copy"
title="Copy response body"
disabled={actionsDisabled}
onClick={() => copyResponse.mutate()}
className="border !border-border-subtle"
/>,
]}
onFilter={filterCallback} onFilter={filterCallback}
/> />
); );
@@ -1,6 +1,6 @@
import classNames from "classnames"; import classNames from "classnames";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useCallback, useMemo } from "react"; import { Children, useCallback, useMemo } from "react";
import { createGlobalState } from "react-use"; import { createGlobalState } from "react-use";
import { useDebouncedValue } from "@yaakapp-internal/ui"; import { useDebouncedValue } from "@yaakapp-internal/ui";
import { useFormatText } from "../../hooks/useFormatText"; import { useFormatText } from "../../hooks/useFormatText";
@@ -19,6 +19,7 @@ interface Props {
filterStateKey?: string | null; filterStateKey?: string | null;
pretty?: boolean; pretty?: boolean;
className?: string; className?: string;
footerActions?: ReactNode;
onFilter?: (filter: string) => { onFilter?: (filter: string) => {
data: string | null | undefined; data: string | null | undefined;
isPending: boolean; isPending: boolean;
@@ -35,6 +36,7 @@ export function TextViewer({
filterStateKey, filterStateKey,
pretty, pretty,
className, className,
footerActions,
onFilter, onFilter,
}: Props) { }: Props) {
const filterKey = filterStateKey ?? stateKey; const filterKey = filterStateKey ?? stateKey;
@@ -66,7 +68,7 @@ export function TextViewer({
const canFilter = onFilter && (language === "json" || language === "xml" || language === "html"); const canFilter = onFilter && (language === "json" || language === "xml" || language === "html");
const actions = useMemo<ReactNode[]>(() => { const actions = useMemo<ReactNode[]>(() => {
const nodes: ReactNode[] = []; const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions);
if (!canFilter) return nodes; if (!canFilter) return nodes;
@@ -107,6 +109,7 @@ export function TextViewer({
return nodes; return nodes;
}, [ }, [
canFilter, canFilter,
footerActions,
filterKey, filterKey,
filterText, filterText,
filteredResponse.error, filteredResponse.error,
@@ -3,10 +3,12 @@ import { copyToClipboard } from "../lib/copy";
import { getResponseBodyText } from "../lib/responseBody"; import { getResponseBodyText } from "../lib/responseBody";
import { useFastMutation } from "./useFastMutation"; import { useFastMutation } from "./useFastMutation";
export function useCopyHttpResponse(response: HttpResponse) { export function useCopyHttpResponse(response: HttpResponse | null) {
return useFastMutation({ return useFastMutation({
mutationKey: ["copy_http_response", response.id], mutationKey: ["copy_http_response", response?.id],
async mutationFn() { async mutationFn() {
if (response == null) return;
const body = await getResponseBodyText({ response, filter: null }); const body = await getResponseBodyText({ response, filter: null });
copyToClipboard(body); copyToClipboard(body);
}, },
+4 -2
View File
@@ -9,10 +9,12 @@ import { invokeCmd } from "../lib/tauri";
import { showToast } from "../lib/toast"; import { showToast } from "../lib/toast";
import { useFastMutation } from "./useFastMutation"; import { useFastMutation } from "./useFastMutation";
export function useSaveResponse(response: HttpResponse) { export function useSaveResponse(response: HttpResponse | null) {
return useFastMutation({ return useFastMutation({
mutationKey: ["save_response", response.id], mutationKey: ["save_response", response?.id],
mutationFn: async () => { mutationFn: async () => {
if (response == null) return null;
const request = getModel("http_request", response.requestId); const request = getModel("http_request", response.requestId);
if (request == null) return null; if (request == null) return null;