mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-09 06:15:08 +02:00
Improve response history menu (#492)
This commit is contained in:
@@ -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 •{" "}
|
|
||||||
<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">•</span>
|
||||||
|
<span className="font-mono">{r.elapsed >= 0 ? formatMillis(r.elapsed) : "n/a"}</span>
|
||||||
|
<span className="text-text-subtlest">•</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">→</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 •{" "}
|
|
||||||
<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`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user