mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Columns keep fixed widths, Path included, and the list scrolls sideways with the header in step instead of dropping columns or capping Host. Resize handles sit on every column boundary, a double-click resets one, and a third click on a sorted header goes back to start time, with empty values sorting lowest. The search box takes up to 44rem and wraps over the list while focused, its completions as wide as it. The list/detail handle fills the gutter. Native title tooltips become Yaak's tooltip, shared buttons gain an opt-in for it, and folder and program rows in the sidebar get Reveal in Finder and Copy path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
|
import type { Flow, FlowBody } from "@yaakapp-internal/proxy-lib";
|
|
import {
|
|
CONTROL_TOOLTIP_DELAY,
|
|
CountBadge,
|
|
Dropdown,
|
|
EmptyStateText,
|
|
type ExtraPaneTab,
|
|
type HttpBodyResult,
|
|
HttpRequestPaneView,
|
|
HttpResponsePaneView,
|
|
Icon,
|
|
IconButton,
|
|
parseCookieHeaders,
|
|
ResponseCookies,
|
|
type ResponseViewMode,
|
|
SplitLayout,
|
|
Tooltip,
|
|
} from "@yaakapp-internal/ui";
|
|
import classNames from "classnames";
|
|
import { useSetAtom } from "jotai";
|
|
import { useCallback, useMemo, useState } from "react";
|
|
import { BODY_TYPE_BINARY } from "@yaakapp-internal/ui/httpTypes";
|
|
import { findHeader } from "../lib/body";
|
|
import {
|
|
browserTabMemory,
|
|
defaultTabFor,
|
|
type FlowPane,
|
|
learnTab,
|
|
REQUEST_TAB_AUTH,
|
|
REQUEST_TAB_BODY,
|
|
REQUEST_TAB_COOKIES,
|
|
REQUEST_TAB_HEADERS,
|
|
REQUEST_TAB_PARAMS,
|
|
RESPONSE_TAB_BODY,
|
|
RESPONSE_TAB_COOKIES,
|
|
RESPONSE_TAB_HEADERS,
|
|
RESPONSE_TAB_INFO,
|
|
RESPONSE_TAB_MESSAGES,
|
|
RESPONSE_TAB_TIMELINE,
|
|
} from "../lib/defaultTab";
|
|
import { saveDialogFlowAtom, useFlowActions } from "../lib/flowActions";
|
|
import {
|
|
type FlowBodyData,
|
|
flowBodyLoader,
|
|
flowCookiesView,
|
|
flowRequestToView,
|
|
flowResponseToView,
|
|
} from "../lib/flowViews";
|
|
import { messagesTabLabel } from "../lib/messages";
|
|
import { FlowBodyViewer, useFlowBody } from "./FlowBodyViewer";
|
|
import { FlowInfo } from "./FlowInfo";
|
|
import { FlowRequestBody } from "./FlowRequestBody";
|
|
import { FlowMessages } from "./FlowMessages";
|
|
import { FlowTimeline } from "./FlowTimeline";
|
|
import { ProxyEditorHost } from "./ProxyEditorHost";
|
|
|
|
/**
|
|
* One card for a flow: the request half (its URL line and tabs), a hairline, and the response
|
|
* half (its status line and tabs). The halves sit perpendicular to the list: stacked when the
|
|
* list is beside the card, side by side when it is above.
|
|
*/
|
|
export function FlowCard({ flow, listBeside }: { flow: Flow; listBeside: boolean }) {
|
|
return (
|
|
<ProxyEditorHost>
|
|
<SplitLayout
|
|
storageKey={listBeside ? "proxy_card_stacked" : "proxy_card_side"}
|
|
layout={listBeside ? "vertical" : "horizontal"}
|
|
minHeightPx={120}
|
|
minWidthPx={240}
|
|
firstSlot={({ style }) => (
|
|
<div style={style}>
|
|
<FlowRequestHalf flow={flow} />
|
|
</div>
|
|
)}
|
|
secondSlot={({ style, orientation }) => (
|
|
<div style={style}>
|
|
<FlowResponseHalf
|
|
flow={flow}
|
|
className={orientation === "vertical" ? "border-t" : "border-l"}
|
|
/>
|
|
</div>
|
|
)}
|
|
/>
|
|
</ProxyEditorHost>
|
|
);
|
|
}
|
|
|
|
/** The tab a half opens on, and learning from the ones chosen by hand */
|
|
function useShapeTab(pane: FlowPane, flow: Flow, available: string[]) {
|
|
const memory = browserTabMemory();
|
|
// Read again for every flow, so a tab learned on one opens the next of its shape
|
|
const defaultTab = defaultTabFor(pane, flow, available, memory);
|
|
const onTabChange = useCallback(
|
|
(tab: string) => learnTab(pane, flow, tab, memory),
|
|
[pane, flow, memory],
|
|
);
|
|
return { defaultTab, onTabChange };
|
|
}
|
|
|
|
/** A body read through the engine's body RPC, as the shared panes' loaders ask for it */
|
|
function useLoadedBody(
|
|
flow: Flow,
|
|
which: FlowBody,
|
|
enabled = true,
|
|
): HttpBodyResult<FlowBodyData | null> {
|
|
const size = which === "request" ? flow.requestSize : flow.responseSize;
|
|
const load = enabled && size > 0;
|
|
const body = useFlowBody(flow, which, load);
|
|
if (!load) return { data: null, isLoading: false, error: null };
|
|
return { data: body.data ?? undefined, isLoading: body.isLoading, error: body.error };
|
|
}
|
|
|
|
function FlowRequestHalf({ flow }: { flow: Flow }) {
|
|
const body = useLoadedBody(flow, "request");
|
|
const request = useMemo(() => flowRequestToView(flow, body.data ?? null), [flow, body.data]);
|
|
const loader = useMemo(
|
|
() =>
|
|
flowBodyLoader({
|
|
useBody: () => useLoadedBody(flow, "request"),
|
|
contentType: findHeader(flow.requestHeaders, "content-type"),
|
|
}),
|
|
[flow],
|
|
);
|
|
const cookies = useMemo(() => flowCookiesView(flow), [flow]);
|
|
const sentCount = useMemo(
|
|
() => parseCookieHeaders(cookies.cookieHeaders).sentCookies.length,
|
|
[cookies],
|
|
);
|
|
const extraTabs = useMemo<ExtraPaneTab[]>(
|
|
() => [
|
|
{
|
|
tab: {
|
|
value: REQUEST_TAB_COOKIES,
|
|
label: "Cookies",
|
|
empty: sentCount === 0,
|
|
rightSlot: <CountBadge count={sentCount} />,
|
|
},
|
|
content: <ResponseCookies response={cookies} side="sent" />,
|
|
},
|
|
],
|
|
[cookies, sentCount],
|
|
);
|
|
const available = [
|
|
REQUEST_TAB_BODY,
|
|
REQUEST_TAB_PARAMS,
|
|
REQUEST_TAB_HEADERS,
|
|
...(request.auth == null ? [] : [REQUEST_TAB_AUTH]),
|
|
REQUEST_TAB_COOKIES,
|
|
];
|
|
const { defaultTab, onTabChange } = useShapeTab("request", flow, available);
|
|
|
|
return (
|
|
<HttpRequestPaneView
|
|
className="px-3"
|
|
request={request}
|
|
fullHeight
|
|
dimEmptyTabs
|
|
forceUpdateKey={`${flow.updatedAt}.${body.data == null ? "" : "loaded"}`}
|
|
tabsStorageKey="proxy_request_tabs"
|
|
bodyLoader={loader}
|
|
extraTabs={extraTabs}
|
|
defaultTab={defaultTab}
|
|
onTabChange={onTabChange}
|
|
urlLine={<UrlLine flow={flow} />}
|
|
renderBody={(editor) => (
|
|
<FlowRequestBody
|
|
body={body}
|
|
editor={editor}
|
|
// The proxy's own viewer, with its hexdump and save, rather than a size alone
|
|
binaryViewer={
|
|
request.bodyType === BODY_TYPE_BINARY ? (
|
|
<FlowBodyViewer flow={flow} which="request" pretty />
|
|
) : null
|
|
}
|
|
requestSize={flow.requestSize}
|
|
/>
|
|
)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/** The method and URL, at the status line's height so the two header lines align */
|
|
function UrlLine({ flow }: { flow: Flow }) {
|
|
return (
|
|
// The whole URL on hover, when it runs past the card
|
|
<Tooltip wrap onlyWhenTruncated content={flow.url} delay={CONTROL_TOOLTIP_DELAY}>
|
|
<div
|
|
className={classNames(
|
|
"flex items-center gap-2 min-h-[calc(var(--height-sm)+0.25rem)] -mb-1.5",
|
|
"font-mono text-sm whitespace-nowrap overflow-x-auto hide-scrollbars",
|
|
)}
|
|
>
|
|
<span className="text-text-subtle shrink-0">{flow.method}</span>
|
|
<span className="text-text select-text cursor-text">{flow.url}</span>
|
|
</div>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }) {
|
|
const [viewMode, setViewMode] = useState<ResponseViewMode>("pretty");
|
|
// A stream's body is its messages, read by the Messages tab; reading it whole again on every
|
|
// message would only hold copies of it
|
|
const loadBody = flow.streamKind == null;
|
|
const body = useLoadedBody(flow, "response", loadBody);
|
|
const response = useMemo(() => flowResponseToView(flow, body.data ?? null), [flow, body.data]);
|
|
const loader = useMemo(
|
|
() =>
|
|
flowBodyLoader({
|
|
useBody: () => useLoadedBody(flow, "response", loadBody),
|
|
contentType: findHeader(flow.responseHeaders, "content-type"),
|
|
}),
|
|
[flow, loadBody],
|
|
);
|
|
const openSave = useSetAtom(saveDialogFlowAtom);
|
|
const flowActions = useFlowActions();
|
|
const messagesLabel = messagesTabLabel(flow);
|
|
|
|
const extraTabs = useMemo<ExtraPaneTab[]>(
|
|
() => [
|
|
{
|
|
tab: { value: RESPONSE_TAB_TIMELINE, label: "Timeline" },
|
|
content: <FlowTimeline flow={flow} />,
|
|
},
|
|
{ tab: { value: RESPONSE_TAB_INFO, label: "Info" }, content: <FlowInfo flow={flow} /> },
|
|
...(messagesLabel == null
|
|
? []
|
|
: [
|
|
{
|
|
tab: {
|
|
value: RESPONSE_TAB_MESSAGES,
|
|
label: messagesLabel,
|
|
empty: flow.messageCount === 0,
|
|
rightSlot: (
|
|
<CountBadge
|
|
count={flow.messageCount}
|
|
color="info"
|
|
showZero
|
|
className="tabular-nums"
|
|
/>
|
|
),
|
|
},
|
|
content: <FlowMessages flow={flow} />,
|
|
},
|
|
]),
|
|
],
|
|
[flow, messagesLabel],
|
|
);
|
|
const available = [
|
|
RESPONSE_TAB_BODY,
|
|
RESPONSE_TAB_HEADERS,
|
|
RESPONSE_TAB_COOKIES,
|
|
RESPONSE_TAB_TIMELINE,
|
|
RESPONSE_TAB_INFO,
|
|
...(messagesLabel == null ? [] : [RESPONSE_TAB_MESSAGES]),
|
|
];
|
|
const { defaultTab, onTabChange } = useShapeTab("response", flow, available);
|
|
const saved = flow.savedRequestId != null;
|
|
|
|
return (
|
|
<HttpResponsePaneView
|
|
bare
|
|
requestShownApart
|
|
dimEmptyTabs
|
|
className={classNames(className, "border-border-subtle")}
|
|
response={response}
|
|
body={loader}
|
|
requestBody={null}
|
|
emptyState={<EmptyStateText>Waiting for a response</EmptyStateText>}
|
|
viewMode={viewMode}
|
|
onViewModeChange={setViewMode}
|
|
tabsStorageKey="proxy_response_tabs"
|
|
activeTabKey={flow.id}
|
|
defaultTab={defaultTab}
|
|
onTabChange={onTabChange}
|
|
extraTabs={extraTabs}
|
|
renderBodyViewer={() => (
|
|
<FlowBodyViewer flow={flow} which="response" pretty={viewMode === "pretty"} />
|
|
)}
|
|
statusLineCenter={
|
|
flow.responseTruncated ? (
|
|
<span
|
|
className={classNames(
|
|
"justify-self-end flex items-center gap-1.5 h-5 px-2.5 ml-3 rounded-full",
|
|
"border border-notice/50 text-notice font-sans text-xs",
|
|
)}
|
|
>
|
|
<Icon icon="scissors" size="xs" />
|
|
{body.data == null ? "Body cut" : `First ${formatSize(body.data.storedSize)} kept`}
|
|
</span>
|
|
) : undefined
|
|
}
|
|
statusLineRight={
|
|
<div className="flex items-center gap-0.5 m-0.5">
|
|
<IconButton
|
|
size="sm"
|
|
icon="bookmark"
|
|
iconColor={saved ? "primary" : "secondary"}
|
|
title={saved ? "Saved to Yaak" : "Save to Yaak"}
|
|
tooltip
|
|
onClick={() => openSave(flow)}
|
|
/>
|
|
<Dropdown items={flowActions(flow).slice(2)}>
|
|
<IconButton size="sm" icon="ellipsis" iconColor="secondary" title="Actions" tooltip />
|
|
</Dropdown>
|
|
</div>
|
|
}
|
|
/>
|
|
);
|
|
}
|