diff --git a/apps/yaak-client/components/HttpResponsePane.tsx b/apps/yaak-client/components/HttpResponsePane.tsx index 8f8cb2a9..645de078 100644 --- a/apps/yaak-client/components/HttpResponsePane.tsx +++ b/apps/yaak-client/components/HttpResponsePane.tsx @@ -4,10 +4,12 @@ import classNames from "classnames"; import type { ComponentType, CSSProperties } from "react"; import { lazy, Suspense, useMemo } from "react"; import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse"; +import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse"; import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents"; import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse"; import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText"; import { useResponseViewMode } from "../hooks/useResponseViewMode"; +import { useSaveResponse } from "../hooks/useSaveResponse"; import { useTimelineViewMode } from "../hooks/useTimelineViewMode"; import { getMimeTypeFromContentType } from "../lib/contentType"; import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util"; @@ -78,6 +80,8 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { activeResponse?.state === "closed" && redirectDropWarning != null; const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]); + const saveResponse = useSaveResponse(activeResponse ?? null); + const copyResponse = useCopyHttpResponse(activeResponse ?? null); const tabs = useMemo( () => [ @@ -93,6 +97,22 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { ? [] : [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]), ], + itemsAfter: [ + { + label: "Save to File", + onSelect: saveResponse.mutate, + leftSlot: , + hidden: activeResponse == null || !!activeResponse.error, + disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100, + }, + { + label: "Copy Body", + onSelect: copyResponse.mutate, + leftSlot: , + 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, + activeResponse?.error, activeResponse?.requestContentLength, activeResponse?.requestHeaders.length, + activeResponse?.state, + activeResponse?.status, cookieCounts.sent, cookieCounts.received, + copyResponse.mutate, mimeType, responseEvents.data?.length, + saveResponse.mutate, setViewMode, viewMode, timelineViewMode, diff --git a/apps/yaak-client/components/RecentGrpcConnectionsDropdown.tsx b/apps/yaak-client/components/RecentGrpcConnectionsDropdown.tsx index e48af5d7..224731fb 100644 --- a/apps/yaak-client/components/RecentGrpcConnectionsDropdown.tsx +++ b/apps/yaak-client/components/RecentGrpcConnectionsDropdown.tsx @@ -1,10 +1,17 @@ import type { GrpcConnection } from "@yaakapp-internal/models"; import { deleteModel } from "@yaakapp-internal/models"; 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 { 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"; interface Props { @@ -20,6 +27,63 @@ export function RecentGrpcConnectionsDropdown({ }: Props) { const deleteAllConnections = useDeleteGrpcConnections(activeConnection?.requestId); 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: No recent connections, + }); + hasShownRecentEmptyState = true; + } + + if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) { + connectionHistoryItems.push({ + type: "separator", + label: {historyGroup}, + }); + lastHistoryGroup = historyGroup; + } + + connectionHistoryItems.push({ + label: ( + + {formatMillis(c.elapsed)} + + ), + leftSlot: activeConnection?.id === c.id ? : , + onSelect: () => onPinnedConnectionId(c.id), + }); + } + + if (!hasRecentConnections && !hasShownRecentEmptyState) { + connectionHistoryItems.push({ + type: "content", + label: No recent connections, + }); + } return ( ({ - label: ( - - {formatDistanceToNowStrict(`${c.createdAt}Z`)} ago •{" "} - {c.elapsed}ms - - ), - leftSlot: activeConnection?.id === c.id ? : , - onSelect: () => onPinnedConnectionId(c.id), - })), + ...connectionHistoryItems, ]} > ({ + namespace: "global", + key: ["dismiss-banner", movedActionsBannerId], + fallback: false, + }); const latestResponseId = responses[0]?.id ?? "n/a"; - const saveResponse = useSaveResponse(activeResponse); - const copyResponse = useCopyHttpResponse(activeResponse); + const responseHistoryItems: DropdownItem[] = []; + 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: No recent requests, + }); + hasShownRecentEmptyState = true; + } + + if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) { + responseHistoryItems.push({ + type: "separator", + label: {historyGroup}, + }); + lastHistoryGroup = historyGroup; + } + + responseHistoryItems.push({ + label: ( + + + + {r.elapsed >= 0 ? formatMillis(r.elapsed) : "n/a"} + + + + ), + leftSlot: activeResponse?.id === r.id ? : , + onSelect: () => onPinnedResponseId(r.id), + }); + } + + if (!hasRecentResponses && !hasShownRecentEmptyState) { + responseHistoryItems.push({ + type: "content", + label: No recent requests, + }); + } return ( , - hidden: responses.length === 0 || !!activeResponse.error, - disabled: activeResponse.state !== "closed" && activeResponse.status >= 100, - }, - { - label: "Copy Body", - onSelect: copyResponse.mutate, - leftSlot: , - hidden: responses.length === 0 || !!activeResponse.error, - disabled: activeResponse.state !== "closed" && activeResponse.status >= 100, - }, { label: "Delete", leftSlot: , onSelect: () => deleteModel(activeResponse), }, + { + label: "Delete all", + leftSlot: , + onSelect: deleteAllResponses.mutate, + disabled: responses.length === 0, + }, { label: "Unpin Response", onSelect: () => onPinnedResponseId(activeResponse.id), @@ -55,25 +124,25 @@ export const RecentHttpResponsesDropdown = function ResponsePane({ hidden: latestResponseId === activeResponse.id, disabled: responses.length === 0, }, - { type: "separator", label: "History" }, { - label: `Delete ${responses.length} ${pluralize("Response", responses.length)}`, - onSelect: deleteAllResponses.mutate, - hidden: responses.length === 0, - disabled: responses.length === 0, - }, - { type: "separator" }, - ...responses.map((r: HttpResponse) => ({ + type: "content", + hidden: dismissedMovedActions === true, label: ( - - - {" "} - {r.elapsed >= 0 ? `${r.elapsed}ms` : "n/a"} - + +

Copy and save actions moved to the Response tab menu.

+
), - leftSlot: activeResponse?.id === r.id ? : , - onSelect: () => onPinnedResponseId(r.id), - })), + }, + { + type: "separator", + label: "Recent", + }, + ...responseHistoryItems, ]} > No recent connections, + }); + hasShownRecentEmptyState = true; + } + + if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) { + connectionHistoryItems.push({ + type: "separator", + label: {historyGroup}, + }); + lastHistoryGroup = historyGroup; + } + + connectionHistoryItems.push({ + label: ( + + {formatMillis(c.elapsed)} + + ), + leftSlot: activeConnection?.id === c.id ? : , + onSelect: () => onPinnedConnectionId(c.id), + }); + } + + if (!hasRecentConnections && !hasShownRecentEmptyState) { + connectionHistoryItems.push({ + type: "content", + label: No recent connections, + }); + } return ( ({ - label: ( - - {formatDistanceToNowStrict(`${c.createdAt}Z`)} ago •{" "} - {c.elapsed}ms - - ), - leftSlot: activeConnection?.id === c.id ? : , - onSelect: () => onPinnedConnectionId(c.id), - })), + ...connectionHistoryItems, ]} > Info}> + + +
{response.url} diff --git a/apps/yaak-client/components/core/DismissibleBanner.tsx b/apps/yaak-client/components/core/DismissibleBanner.tsx index 38eca586..f2d18712 100644 --- a/apps/yaak-client/components/core/DismissibleBanner.tsx +++ b/apps/yaak-client/components/core/DismissibleBanner.tsx @@ -2,21 +2,26 @@ import type { Color } from "@yaakapp-internal/plugins"; import type { BannerProps } from "@yaakapp-internal/ui"; import { Banner } from "@yaakapp-internal/ui"; import classNames from "classnames"; +import type { MouseEvent } from "react"; import { useEffect } from "react"; import { useKeyValue } from "../../hooks/useKeyValue"; import type { ButtonProps } from "./Button"; import { Button } from "./Button"; +type DismissibleBannerSize = "sm" | "xs"; + export function DismissibleBanner({ children, className, id, + size = "sm", onDismiss, onShow, actions, ...props }: BannerProps & { id: string; + size?: DismissibleBannerSize; onDismiss?: () => void | Promise; onShow?: () => void | Promise; actions?: { @@ -46,17 +51,36 @@ export function DismissibleBanner({ if (!shouldShow) return null; + const actionSize: ButtonProps["size"] = size === "xs" ? "2xs" : "xs"; + const stopParentClick = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + }; + return ( - +
-
+
{children}