mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
The search box now composes a filter and Enter applies it. The main window's view (filter, selected flow, card tabs) is a place: a session stack of them backs Back and Forward (⌘[ ⌘], mouse buttons, a Go menu), the current one survives a reload, and the last filter comes back at launch. Filters applied with Enter go into a new filter_history table, listed from the magnifier and recalled with Up in the empty box. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
324 lines
10 KiB
TypeScript
324 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 { useAtomValue, useSetAtom, useStore } from "jotai";
|
|
import { useCallback, useMemo, useState } from "react";
|
|
import { BODY_TYPE_BINARY } from "@yaakapp-internal/ui/httpTypes";
|
|
import { placeTabsAtom } from "../lib/atoms";
|
|
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 { goToPlace } from "../lib/navigation";
|
|
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. The tab chosen is part of
|
|
* the window's place, so going back to it, or reloading, opens it again.
|
|
*/
|
|
function useShapeTab(pane: FlowPane, flow: Flow, available: string[]) {
|
|
const store = useStore();
|
|
const memory = browserTabMemory();
|
|
const activeTab = useAtomValue(placeTabsAtom)[pane];
|
|
// 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);
|
|
goToPlace(store, "update", { tabs: { ...store.get(placeTabsAtom), [pane]: tab } });
|
|
},
|
|
[pane, flow, memory, store],
|
|
);
|
|
return { activeTab, 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 { activeTab, 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}
|
|
activeTab={activeTab}
|
|
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 { activeTab, 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}
|
|
activeTab={activeTab}
|
|
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>
|
|
}
|
|
/>
|
|
);
|
|
}
|