From e91384ec18b8e90ee454b42b631b825af73237f1 Mon Sep 17 00:00:00 2001 From: nuno <59452877+komen205@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:13:47 +0100 Subject: [PATCH 1/3] Add copy-as-code-snippet for requests on the Send page Adds a copy button to the Send request line that opens a menu of code snippet formats, mirroring the View page's export context menu. The snippet is generated directly from the in-progress request input, so requests can be exported as code while editing, before they're sent. Co-Authored-By: Claude Fable 5 --- src/components/send/request-pane.tsx | 68 +++++++++++++++++- src/components/send/send-request-line.tsx | 16 +++++ src/model/http/har.ts | 49 +++++++++++++ src/model/ui/export.ts | 43 +++++++++-- test/unit/model/ui/export.spec.ts | 87 +++++++++++++++++++++++ 5 files changed, 256 insertions(+), 7 deletions(-) create mode 100644 test/unit/model/ui/export.spec.ts diff --git a/src/components/send/request-pane.tsx b/src/components/send/request-pane.tsx index a7bc14ff..ad4b3049 100644 --- a/src/components/send/request-pane.tsx +++ b/src/components/send/request-pane.tsx @@ -7,13 +7,23 @@ import * as HarFormat from 'har-format'; import { RawHeaders } from '../../types'; +import { AccountStore } from '../../model/account/account-store'; import { RulesStore } from '../../model/rules/rules-store'; import { UiStore } from '../../model/ui/ui-store'; import { RequestInput } from '../../model/send/send-request-model'; import { EditableContentType } from '../../model/events/content-types'; +import { ContextMenuItem } from '../../model/ui/context-menu'; +import { generateCodeSnippetFromRequestInput } from '../../model/ui/export'; +import { + getCodeSnippetFormatKey, + getCodeSnippetFormatName, + getCodeSnippetOptionFromKey, + snippetExportOptions, + SnippetOption +} from '../../model/ui/snippet-formats'; import { ContainerSizedEditor } from '../editor/base-editor'; -import { useHotkeys } from '../../util/ui'; +import { useHotkeys, copyToClipboard } from '../../util/ui'; import { SendCardContainer } from './send-card-section'; import { SendRequestLine } from './send-request-line'; @@ -49,10 +59,12 @@ const RequestPaneKeyboardShortcuts = (props: { @inject('rulesStore') @inject('uiStore') +@inject('accountStore') @observer export class RequestPane extends React.Component<{ rulesStore?: RulesStore, uiStore?: UiStore, + accountStore?: AccountStore, editorNode: portals.HtmlPortalNode, @@ -106,6 +118,7 @@ export class RequestPane extends React.Component<{ isSending={isSending} sendRequest={sendRequest} updateFromHar={this.props.updateFromHar} + showCopyAsSnippetMenu={this.showCopyAsSnippetMenu} /> { + const { requestInput } = this.props; + + try { + const snippet = generateCodeSnippetFromRequestInput(requestInput, snippetOption); + await copyToClipboard(snippet); + } catch (e: any) { + console.log(e); + alert(`Could not copy this request as a code snippet:\n\n${e.message || e}`); + } + }; + + private showCopyAsSnippetMenu = (event: React.MouseEvent) => { + const uiStore = this.props.uiStore!; + const isPaidUser = this.props.accountStore!.user.isPaidUser(); + + const preferredFormat = uiStore.exportSnippetFormat + ? getCodeSnippetOptionFromKey(uiStore.exportSnippetFormat) + : undefined; + + const menuItems: Array> = [ + ...(!isPaidUser ? [ + { type: 'option', label: 'With Pro:', enabled: false, callback: () => {} } + ] as const : []), + // If you have a preferred default format, we show that option at the top level: + ...(preferredFormat && isPaidUser ? [{ + type: 'option' as const, + label: `Copy as ${getCodeSnippetFormatName(preferredFormat)} Snippet`, + callback: () => this.copyRequestAsSnippet(preferredFormat) + }] : []), + { + type: 'submenu', + enabled: isPaidUser, + label: `Copy as Code Snippet`, + items: Object.keys(snippetExportOptions).map((snippetGroupName) => ({ + type: 'submenu' as const, + label: snippetGroupName, + items: snippetExportOptions[snippetGroupName].map((snippetOption) => ({ + type: 'option' as const, + label: getCodeSnippetFormatName(snippetOption), + callback: action(() => { + // When you pick an option here, it updates your preferred default option + uiStore.exportSnippetFormat = getCodeSnippetFormatKey(snippetOption); + this.copyRequestAsSnippet(snippetOption); + }) + })) + })) + } + ]; + + uiStore.handleContextMenuEvent(event, menuItems); + }; + } \ No newline at end of file diff --git a/src/components/send/send-request-line.tsx b/src/components/send/send-request-line.tsx index 664595f8..93790d1a 100644 --- a/src/components/send/send-request-line.tsx +++ b/src/components/send/send-request-line.tsx @@ -10,6 +10,7 @@ import { getMethodColor } from '../../model/events/categorization'; import { Ctrl } from '../../util/ui'; import { Button, Select, TextInput } from '../common/inputs'; +import { IconButton } from '../common/icon-button'; type MethodName = keyof typeof Method; const validMethods = Object.values(Method) @@ -95,6 +96,13 @@ const UrlInput = styled(TextInput)` } `; +const CopySnippetButton = styled(IconButton)` + flex-shrink: 0; + padding: 5px 12px; + + font-size: ${p => p.theme.textSize}; +`; + const SendButton = styled(Button)` padding: 4px 18px 5px; border-radius: 0; @@ -122,6 +130,8 @@ export const SendRequestLine = (props: { isSending: boolean; sendRequest: () => void; + + showCopyAsSnippetMenu: (event: React.MouseEvent) => void; }) => { const updateMethodFromEvent = React.useCallback((changeEvent: React.ChangeEvent) => { props.updateMethod(changeEvent.target.value); @@ -198,6 +208,12 @@ export const SendRequestLine = (props: { onChange={updateUrlFromEvent} onPaste={onPaste} /> + ({ + name: paramKey, + value: paramValue + }) + ), + headersSize: -1, + bodySize: request.decodedBody.byteLength + }; + + try { + requestEntry.postData = generateHarPostBody( + UTF8Decoder.decode(request.decodedBody), + getHeaderValue(request.rawHeaders, 'content-type') || 'application/octet-stream' + ); + } catch (e) { + if (e instanceof TypeError) { + requestEntry._requestBodyStatus = 'discarded:not-representable'; + requestEntry._content = { + text: request.decodedBody.toString('base64'), + size: request.decodedBody.byteLength, + encoding: 'base64' + }; + } else { + throw e; + } + } + + return requestEntry; +} + type TextBody = { mimeType: string, text: string diff --git a/src/model/ui/export.ts b/src/model/ui/export.ts index 2e30910c..ad690bef 100644 --- a/src/model/ui/export.ts +++ b/src/model/ui/export.ts @@ -3,7 +3,13 @@ import * as HTTPSnippet from "@httptoolkit/httpsnippet"; import { saveFile } from "../../util/ui"; import { HttpExchangeView } from "../../types"; -import { generateHarRequest, generateHar } from '../http/har'; +import { + generateHarRequest, + generateHarRequestFromRequestData, + generateHar, + ExtendedHarRequest +} from '../http/har'; +import { RequestInput } from '../send/send-request-model'; import { simplifyHarRequestForSnippetExport } from './snippet-export-sanitization'; import { SnippetOption } from './snippet-formats'; @@ -45,16 +51,41 @@ export function generateCodeSnippet( } // First, we need to get a HAR that appropriately represents this request as we - // want to export it. All snippet-specific preprocessing (header filtering, - // body placeholders) lives in snippet-export-sanitization.ts, so that this - // export and the bulk ZIP export share identical behaviour: + // want to export it: const harRequest = generateHarRequest(exchange.request, false, { bodySizeLimit: Infinity }); + + return generateCodeSnippetFromHarRequest(harRequest, snippetFormat); +}; + +// Generates a code snippet for a not-yet-sent request input, e.g. while editing +// a request on the Send page. +export function generateCodeSnippetFromRequestInput( + requestInput: RequestInput, + snippetFormat: SnippetOption +): string { + const harRequest = generateHarRequestFromRequestData({ + method: requestInput.method, + url: requestInput.url, + rawHeaders: requestInput.headers, + decodedBody: requestInput.rawBody.decoded + }); + + return generateCodeSnippetFromHarRequest(harRequest, snippetFormat); +}; + +function generateCodeSnippetFromHarRequest( + harRequest: ExtendedHarRequest, + snippetFormat: SnippetOption +): string { + // All snippet-specific preprocessing (header filtering, body placeholders) lives in + // snippet-export-sanitization.ts, so that this export and the bulk ZIP export share + // identical behaviour: const harSnippetBase = simplifyHarRequestForSnippetExport(harRequest); - // Then, we convert that HAR to code for the given target: + // We convert the HAR to code for the given target: return new HTTPSnippet(harSnippetBase) .convert(snippetFormat.target, snippetFormat.client) .trim(); -}; \ No newline at end of file +}; diff --git a/test/unit/model/ui/export.spec.ts b/test/unit/model/ui/export.spec.ts new file mode 100644 index 00000000..b131ee57 --- /dev/null +++ b/test/unit/model/ui/export.spec.ts @@ -0,0 +1,87 @@ +import { expect } from "../../../test-setup"; + +import { RequestInput } from "../../../../src/model/send/send-request-model"; +import { generateCodeSnippetFromRequestInput } from "../../../../src/model/ui/export"; +import { getCodeSnippetOptionFromKey } from "../../../../src/model/ui/snippet-formats"; + +const curlFormat = getCodeSnippetOptionFromKey('shell~~curl'); + +describe("Code snippet generation from Send request inputs", () => { + + it("should generate a curl snippet for a simple GET request", () => { + const requestInput = new RequestInput({ + method: 'GET', + url: 'https://example.com/path?a=b', + headers: [ + ['host', 'example.com'], + ['accept', 'application/json'] + ], + requestContentType: 'text', + rawBody: Buffer.from([]) + }); + + const snippet = generateCodeSnippetFromRequestInput(requestInput, curlFormat); + + expect(snippet).to.include('curl'); + expect(snippet).to.include('https://example.com/path?a=b'); + expect(snippet).to.include("--header 'accept: application/json'"); + }); + + it("should generate a curl snippet including the body for a POST request", () => { + const requestInput = new RequestInput({ + method: 'POST', + url: 'https://example.com/upload', + headers: [ + ['host', 'example.com'], + ['content-type', 'application/json'], + ['content-length', '18'] + ], + requestContentType: 'json', + rawBody: Buffer.from('{"hello":"world"}') + }); + + const snippet = generateCodeSnippetFromRequestInput(requestInput, curlFormat); + + expect(snippet).to.include('--request POST'); + expect(snippet).to.include('https://example.com/upload'); + expect(snippet).to.include("--header 'content-type: application/json'"); + expect(snippet).to.include('{"hello":"world"}'); + + // Content-length is dropped, as clients can calculate it themselves: + expect(snippet).to.not.include('content-length'); + }); + + it("should drop content-encoding headers and use the decoded body", () => { + const requestInput = new RequestInput({ + method: 'POST', + url: 'https://example.com/', + headers: [ + ['host', 'example.com'], + ['content-type', 'text/plain'], + ['content-encoding', 'gzip'] + ], + requestContentType: 'text', + rawBody: Buffer.from('plain text body') + }); + + const snippet = generateCodeSnippetFromRequestInput(requestInput, curlFormat); + + expect(snippet).to.include('plain text body'); + expect(snippet).to.not.include('content-encoding'); + }); + + it("should fail clearly given an invalid URL", () => { + const requestInput = new RequestInput({ + method: 'GET', + url: 'not-a-real-url', + headers: [], + requestContentType: 'text', + rawBody: Buffer.from([]) + }); + + expect(() => + generateCodeSnippetFromRequestInput(requestInput, curlFormat) + ).to.throw(); + }); + +}); From efe090e1ccefaac954fe8d4046a40b2dfe2b6b6b Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Thu, 25 Jun 2026 20:41:37 +0200 Subject: [PATCH 2/3] Attach dropdown to button, not mouse position --- src/components/html-context-menu.tsx | 3 ++- src/model/ui/context-menu.ts | 1 + src/model/ui/ui-store.ts | 12 +++++++++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/components/html-context-menu.tsx b/src/components/html-context-menu.tsx index a7807bb0..a016307f 100644 --- a/src/components/html-context-menu.tsx +++ b/src/components/html-context-menu.tsx @@ -32,7 +32,8 @@ export class HtmlContextMenu extends React.Component<{ setTimeout(() => { contextMenu.show({ id: 'menu', - event: menuState.event + event: menuState.event, + position: menuState.position }); }, 10); })); diff --git a/src/model/ui/context-menu.ts b/src/model/ui/context-menu.ts index a0e5cc2b..8b2bea36 100644 --- a/src/model/ui/context-menu.ts +++ b/src/model/ui/context-menu.ts @@ -4,6 +4,7 @@ import { UnreachableCheck } from "../../util/error"; export interface ContextMenuState { data: T; event: React.MouseEvent; + position?: { x: number, y: number }; items: readonly ContextMenuItem[]; } diff --git a/src/model/ui/ui-store.ts b/src/model/ui/ui-store.ts index 7788cadc..29d170df 100644 --- a/src/model/ui/ui-store.ts +++ b/src/model/ui/ui-store.ts @@ -583,8 +583,17 @@ export class UiStore { event.preventDefault(); + // Right-click menus open at the cursor, other menus (e.g. button dropdowns) anchor + // to the bottom of the triggering element instead: + const anchorPosition = event.type === 'contextmenu' + ? undefined + : (() => { + const bounds = event.currentTarget.getBoundingClientRect(); + return { x: bounds.left, y: bounds.bottom }; + })(); + if (DesktopApi.openContextMenu) { - const position = { x: event.pageX, y: event.pageY }; + const position = anchorPosition ?? { x: event.pageX, y: event.pageY }; this.contextMenuState = undefined; // Should be set already, but let's be explicit DesktopApi.openContextMenu({ @@ -604,6 +613,7 @@ export class UiStore { this.contextMenuState = { data, event, + position: anchorPosition, items }; } From 3be07ed64d9ea3bd3aac7cf543700ff196fe6d9d Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Fri, 26 Jun 2026 17:58:51 +0200 Subject: [PATCH 3/3] Simplify & commonize the HAR setup --- src/model/http/har.ts | 49 ---------------------------- src/model/http/http-exchange.ts | 4 +-- src/model/send/send-request-model.ts | 33 +++++++++++++++++-- src/model/send/send-store.ts | 22 +++---------- src/model/ui/export.ts | 20 +++++++----- src/types.d.ts | 2 +- 6 files changed, 50 insertions(+), 80 deletions(-) diff --git a/src/model/http/har.ts b/src/model/http/har.ts index c5143882..7e15315a 100644 --- a/src/model/http/har.ts +++ b/src/model/http/har.ts @@ -250,55 +250,6 @@ export function generateHarRequest( return requestEntry; } -// Generates a HAR request for request data that hasn't (yet) been sent, e.g. an -// in-progress request input on the Send page. Unlike generateHarRequest above, the -// body here is always already decoded & available synchronously. -export function generateHarRequestFromRequestData(request: { - method: string, - url: string, - rawHeaders: RawHeaders, - decodedBody: Buffer -}): ExtendedHarRequest { - const parsedUrl = new URL(request.url); - const headers = asHtkHeaders(asHarHeaders(request.rawHeaders)); - - const requestEntry: ExtendedHarRequest = { - method: request.method, - url: parsedUrl.toString(), - httpVersion: 'HTTP/1.1', // All sent requests are HTTP/1.1 for now - cookies: asHarRequestCookies(headers), - headers: asHarHeaders(request.rawHeaders), - queryString: Array.from(parsedUrl.searchParams.entries()).map( - ([paramKey, paramValue]) => ({ - name: paramKey, - value: paramValue - }) - ), - headersSize: -1, - bodySize: request.decodedBody.byteLength - }; - - try { - requestEntry.postData = generateHarPostBody( - UTF8Decoder.decode(request.decodedBody), - getHeaderValue(request.rawHeaders, 'content-type') || 'application/octet-stream' - ); - } catch (e) { - if (e instanceof TypeError) { - requestEntry._requestBodyStatus = 'discarded:not-representable'; - requestEntry._content = { - text: request.decodedBody.toString('base64'), - size: request.decodedBody.byteLength, - encoding: 'base64' - }; - } else { - throw e; - } - } - - return requestEntry; -} - type TextBody = { mimeType: string, text: string diff --git a/src/model/http/http-exchange.ts b/src/model/http/http-exchange.ts index 5615ddbc..e3938989 100644 --- a/src/model/http/http-exchange.ts +++ b/src/model/http/http-exchange.ts @@ -76,7 +76,7 @@ function tryParseUrl(url: string): ParsedUrl | undefined { const unparseableUrl = Object.assign(new URL("unknown://unparseable.invalid/"), { parseable: false } as const); -function addRequestMetadata(request: InputRequest): HtkRequest { +export function buildHtkRequest(request: InputRequest): HtkRequest { try { return Object.assign(request, { parsedUrl: request.url @@ -112,7 +112,7 @@ export class HttpExchange extends HTKEventBase implements HttpExchangeView { ) { super(); - this.request = addRequestMetadata(request); + this.request = buildHtkRequest(request); this.timingEvents = request.timingEvents; this.tags = this.request.tags; diff --git a/src/model/send/send-request-model.ts b/src/model/send/send-request-model.ts index 0fb916d7..92b76c8f 100644 --- a/src/model/send/send-request-model.ts +++ b/src/model/send/send-request-model.ts @@ -1,9 +1,16 @@ +import * as _ from 'lodash'; import * as Mockttp from 'mockttp'; import * as serializr from 'serializr'; import { observable } from 'mobx'; import * as HarFormat from 'har-format'; -import { HttpExchange, RawHeaders, HttpExchangeView } from "../../types"; +import { + HttpExchange, + HttpExchangeView, + RawHeaders, + SentRequest, + TimingEvents +} from "../../types"; import { ObservablePromise } from '../../util/observable'; import { EditableContentType, getEditableContentType, getEditableContentTypeFromViewable } from "../events/content-types"; @@ -13,7 +20,7 @@ import { syncFormattingToContentType, syncUrlToHeaders } from '../http/editable-request-parts'; -import { getHeaderValue, h2HeadersToH1 } from '../http/headers'; +import { getHeaderValue, h2HeadersToH1, rawHeadersToHeaders } from '../http/headers'; import { parseHarRequest } from '../http/har'; // This is our model of a Request for sending. Smilar to the API model, @@ -154,6 +161,28 @@ export function buildRequestInputFromHarRequest(requestData: HarFormat.Request): }); } +export function buildSentExchangeRequest( + requestInput: RequestInput, + body: SentRequest['body'] +): SentRequest { + const url = new URL(requestInput.url); + + return { + id: crypto.randomUUID(), + httpVersion: '1.1', // All sent requests are HTTP/1.1 for now + matchedRuleId: false, + method: requestInput.method, + url: requestInput.url, + protocol: url.protocol.slice(0, -1), + path: url.pathname, + headers: rawHeadersToHeaders(requestInput.headers), + rawHeaders: _.cloneDeep(requestInput.headers), + body, + timingEvents: { startTime: Date.now() } as TimingEvents, + tags: ['httptoolkit:manually-sent-request'] + }; +} + // These are the types that the sever client API expects. They are _not_ the same as // the Input type above, which is more flexible and includes various UI concerns that // we don't need to share with the server to actually send the request. diff --git a/src/model/send/send-store.ts b/src/model/send/send-store.ts index 9a07e660..3801fff8 100644 --- a/src/model/send/send-store.ts +++ b/src/model/send/send-store.ts @@ -28,6 +28,7 @@ import { HttpExchange } from '../http/http-exchange'; import { ResponseHeadEvent, ResponseStreamEvent } from './send-response-model'; import { buildRequestInputFromExchange, + buildSentExchangeRequest, ClientProxyConfig, RequestInput, SendRequest, @@ -153,8 +154,6 @@ export class SendStore { sendRequest.pendingSend.promise.then(clearPending, clearPending); }); - const exchangeId = crypto.randomUUID(); - const passthroughOptions = this.rulesStore.activePassthroughOptions; const url = new URL(requestInput.url); @@ -192,22 +191,9 @@ export class SendStore { abortController.signal ); - const exchange = this.eventStore.recordSentRequest({ - id: exchangeId, - httpVersion: '1.1', - matchedRuleId: false, - method: requestInput.method, - url: requestInput.url, - protocol: url.protocol.slice(0, -1), - path: url.pathname, - headers: rawHeadersToHeaders(requestInput.headers), - rawHeaders: _.cloneDeep(requestInput.headers), - body: { buffer: encodedBody }, - timingEvents: { - startTime: Date.now() - } as TimingEvents, - tags: ['httptoolkit:manually-sent-request'] - }); + const exchange = this.eventStore.recordSentRequest( + buildSentExchangeRequest(requestInput, { buffer: encodedBody }) + ); // Keep the exchange up to date as response data arrives: trackResponseEvents(responseStream, exchange) diff --git a/src/model/ui/export.ts b/src/model/ui/export.ts index ad690bef..91c2a1d0 100644 --- a/src/model/ui/export.ts +++ b/src/model/ui/export.ts @@ -5,11 +5,11 @@ import { saveFile } from "../../util/ui"; import { HttpExchangeView } from "../../types"; import { generateHarRequest, - generateHarRequestFromRequestData, generateHar, ExtendedHarRequest } from '../http/har'; -import { RequestInput } from '../send/send-request-model'; +import { buildHtkRequest } from '../http/http-exchange'; +import { RequestInput, buildSentExchangeRequest } from '../send/send-request-model'; import { simplifyHarRequestForSnippetExport } from './snippet-export-sanitization'; import { SnippetOption } from './snippet-formats'; @@ -60,16 +60,20 @@ export function generateCodeSnippet( }; // Generates a code snippet for a not-yet-sent request input, e.g. while editing -// a request on the Send page. +// a request on the Send page. We build the same HtkRequest a real send would produce, +// so this goes through exactly the same HAR generation as exported captured requests. export function generateCodeSnippetFromRequestInput( requestInput: RequestInput, snippetFormat: SnippetOption ): string { - const harRequest = generateHarRequestFromRequestData({ - method: requestInput.method, - url: requestInput.url, - rawHeaders: requestInput.headers, - decodedBody: requestInput.rawBody.decoded + const decoded = requestInput.rawBody.decoded; + const sentRequest = buildSentExchangeRequest(requestInput, { + encodedLength: decoded.byteLength, + decoded + }); + + const harRequest = generateHarRequest(buildHtkRequest(sentRequest), false, { + bodySizeLimit: Infinity }); return generateCodeSnippetFromHarRequest(harRequest, snippetFormat); diff --git a/src/types.d.ts b/src/types.d.ts index d4077d98..a2b81871 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -58,7 +58,7 @@ export type HarResponse = Omit & { body: HarBody; timingEvents: TimingEvents }; export type SentRequest = Omit & - { matchedRuleId: false, body: { buffer: Buffer } }; + { matchedRuleId: false, body: { buffer: Buffer } | HarBody }; export type SentRequestResponse = Omit & { body: { buffer: Buffer } }; export type SentRequestError = Pick & {