Skip to content

Commit 8013cb1

Browse files
komen205claude
andcommitted
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 <noreply@anthropic.com>
1 parent b6a7c78 commit 8013cb1

5 files changed

Lines changed: 253 additions & 3 deletions

File tree

src/components/send/request-pane.tsx

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,23 @@ import * as HarFormat from 'har-format';
77

88
import { RawHeaders } from '../../types';
99

10+
import { AccountStore } from '../../model/account/account-store';
1011
import { RulesStore } from '../../model/rules/rules-store';
1112
import { UiStore } from '../../model/ui/ui-store';
1213
import { RequestInput } from '../../model/send/send-request-model';
1314
import { EditableContentType } from '../../model/events/content-types';
15+
import { ContextMenuItem } from '../../model/ui/context-menu';
16+
import {
17+
generateCodeSnippetFromRequestInput,
18+
getCodeSnippetFormatKey,
19+
getCodeSnippetFormatName,
20+
getCodeSnippetOptionFromKey,
21+
snippetExportOptions,
22+
SnippetOption
23+
} from '../../model/ui/export';
1424

1525
import { ContainerSizedEditor } from '../editor/base-editor';
16-
import { useHotkeys } from '../../util/ui';
26+
import { useHotkeys, copyToClipboard } from '../../util/ui';
1727

1828
import { SendCardContainer } from './send-card-section';
1929
import { SendRequestLine } from './send-request-line';
@@ -49,10 +59,12 @@ const RequestPaneKeyboardShortcuts = (props: {
4959

5060
@inject('rulesStore')
5161
@inject('uiStore')
62+
@inject('accountStore')
5263
@observer
5364
export class RequestPane extends React.Component<{
5465
rulesStore?: RulesStore,
5566
uiStore?: UiStore,
67+
accountStore?: AccountStore,
5668

5769
editorNode: portals.HtmlPortalNode<typeof ContainerSizedEditor>,
5870

@@ -106,6 +118,7 @@ export class RequestPane extends React.Component<{
106118
isSending={isSending}
107119
sendRequest={sendRequest}
108120
updateFromHar={this.props.updateFromHar}
121+
showCopyAsSnippetMenu={this.showCopyAsSnippetMenu}
109122
/>
110123
<SendRequestHeadersCard
111124
{...this.cardProps.requestHeaders}
@@ -152,4 +165,57 @@ export class RequestPane extends React.Component<{
152165
requestInput.rawBody.updateDecodedBody(input);
153166
}
154167

168+
private copyRequestAsSnippet = async (snippetOption: SnippetOption) => {
169+
const { requestInput } = this.props;
170+
171+
try {
172+
const snippet = generateCodeSnippetFromRequestInput(requestInput, snippetOption);
173+
await copyToClipboard(snippet);
174+
} catch (e: any) {
175+
console.log(e);
176+
alert(`Could not copy this request as a code snippet:\n\n${e.message || e}`);
177+
}
178+
};
179+
180+
private showCopyAsSnippetMenu = (event: React.MouseEvent) => {
181+
const uiStore = this.props.uiStore!;
182+
const isPaidUser = this.props.accountStore!.user.isPaidUser();
183+
184+
const preferredFormat = uiStore.exportSnippetFormat
185+
? getCodeSnippetOptionFromKey(uiStore.exportSnippetFormat)
186+
: undefined;
187+
188+
const menuItems: Array<ContextMenuItem<void>> = [
189+
...(!isPaidUser ? [
190+
{ type: 'option', label: 'With Pro:', enabled: false, callback: () => {} }
191+
] as const : []),
192+
// If you have a preferred default format, we show that option at the top level:
193+
...(preferredFormat && isPaidUser ? [{
194+
type: 'option' as const,
195+
label: `Copy as ${getCodeSnippetFormatName(preferredFormat)} Snippet`,
196+
callback: () => this.copyRequestAsSnippet(preferredFormat)
197+
}] : []),
198+
{
199+
type: 'submenu',
200+
enabled: isPaidUser,
201+
label: `Copy as Code Snippet`,
202+
items: Object.keys(snippetExportOptions).map((snippetGroupName) => ({
203+
type: 'submenu' as const,
204+
label: snippetGroupName,
205+
items: snippetExportOptions[snippetGroupName].map((snippetOption) => ({
206+
type: 'option' as const,
207+
label: getCodeSnippetFormatName(snippetOption),
208+
callback: action(() => {
209+
// When you pick an option here, it updates your preferred default option
210+
uiStore.exportSnippetFormat = getCodeSnippetFormatKey(snippetOption);
211+
this.copyRequestAsSnippet(snippetOption);
212+
})
213+
}))
214+
}))
215+
}
216+
];
217+
218+
uiStore.handleContextMenuEvent(event, menuItems);
219+
};
220+
155221
}

src/components/send/send-request-line.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { getMethodColor } from '../../model/events/categorization';
1010

1111
import { Ctrl } from '../../util/ui';
1212
import { Button, Select, TextInput } from '../common/inputs';
13+
import { IconButton } from '../common/icon-button';
1314

1415
type MethodName = keyof typeof Method;
1516
const validMethods = Object.values(Method)
@@ -95,6 +96,13 @@ const UrlInput = styled(TextInput)`
9596
}
9697
`;
9798

99+
const CopySnippetButton = styled(IconButton)`
100+
flex-shrink: 0;
101+
padding: 5px 12px;
102+
103+
font-size: ${p => p.theme.textSize};
104+
`;
105+
98106
const SendButton = styled(Button)`
99107
padding: 4px 18px 5px;
100108
border-radius: 0;
@@ -122,6 +130,8 @@ export const SendRequestLine = (props: {
122130

123131
isSending: boolean;
124132
sendRequest: () => void;
133+
134+
showCopyAsSnippetMenu: (event: React.MouseEvent) => void;
125135
}) => {
126136
const updateMethodFromEvent = React.useCallback((changeEvent: React.ChangeEvent<HTMLSelectElement>) => {
127137
props.updateMethod(changeEvent.target.value);
@@ -198,6 +208,12 @@ export const SendRequestLine = (props: {
198208
onChange={updateUrlFromEvent}
199209
onPaste={onPaste}
200210
/>
211+
<CopySnippetButton
212+
title='Copy this request as a code snippet'
213+
icon={['far', 'copy']}
214+
disabled={!props.url}
215+
onClick={props.showCopyAsSnippetMenu}
216+
/>
201217
<SendButton
202218
type='submit'
203219
disabled={props.isSending}

src/model/http/har.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,55 @@ export function generateHarRequest(
250250
return requestEntry;
251251
}
252252

253+
// Generates a HAR request for request data that hasn't (yet) been sent, e.g. an
254+
// in-progress request input on the Send page. Unlike generateHarRequest above, the
255+
// body here is always already decoded & available synchronously.
256+
export function generateHarRequestFromRequestData(request: {
257+
method: string,
258+
url: string,
259+
rawHeaders: RawHeaders,
260+
decodedBody: Buffer
261+
}): ExtendedHarRequest {
262+
const parsedUrl = new URL(request.url);
263+
const headers = asHtkHeaders(asHarHeaders(request.rawHeaders));
264+
265+
const requestEntry: ExtendedHarRequest = {
266+
method: request.method,
267+
url: parsedUrl.toString(),
268+
httpVersion: 'HTTP/1.1', // All sent requests are HTTP/1.1 for now
269+
cookies: asHarRequestCookies(headers),
270+
headers: asHarHeaders(request.rawHeaders),
271+
queryString: Array.from(parsedUrl.searchParams.entries()).map(
272+
([paramKey, paramValue]) => ({
273+
name: paramKey,
274+
value: paramValue
275+
})
276+
),
277+
headersSize: -1,
278+
bodySize: request.decodedBody.byteLength
279+
};
280+
281+
try {
282+
requestEntry.postData = generateHarPostBody(
283+
UTF8Decoder.decode(request.decodedBody),
284+
getHeaderValue(request.rawHeaders, 'content-type') || 'application/octet-stream'
285+
);
286+
} catch (e) {
287+
if (e instanceof TypeError) {
288+
requestEntry._requestBodyStatus = 'discarded:not-representable';
289+
requestEntry._content = {
290+
text: request.decodedBody.toString('base64'),
291+
size: request.decodedBody.byteLength,
292+
encoding: 'base64'
293+
};
294+
} else {
295+
throw e;
296+
}
297+
}
298+
299+
return requestEntry;
300+
}
301+
253302
type TextBody = {
254303
mimeType: string,
255304
text: string

src/model/ui/export.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import * as HTTPSnippet from "@httptoolkit/httpsnippet";
44
import { saveFile } from "../../util/ui";
55

66
import { HttpExchangeView } from "../../types";
7-
import { generateHarRequest, generateHar, ExtendedHarRequest } from '../http/har';
7+
import {
8+
generateHarRequest,
9+
generateHarRequestFromRequestData,
10+
generateHar,
11+
ExtendedHarRequest
12+
} from '../http/har';
13+
import { RequestInput } from '../send/send-request-model';
814

915
export const exportHar = async (exchange: HttpExchangeView) => {
1016
const harContent = JSON.stringify(
@@ -48,9 +54,33 @@ export function generateCodeSnippet(
4854
const harRequest = generateHarRequest(exchange.request, false, {
4955
bodySizeLimit: Infinity
5056
});
57+
58+
return generateCodeSnippetFromHarRequest(harRequest, snippetFormat);
59+
};
60+
61+
// Generates a code snippet for a not-yet-sent request input, e.g. while editing
62+
// a request on the Send page.
63+
export function generateCodeSnippetFromRequestInput(
64+
requestInput: RequestInput,
65+
snippetFormat: SnippetOption
66+
): string {
67+
const harRequest = generateHarRequestFromRequestData({
68+
method: requestInput.method,
69+
url: requestInput.url,
70+
rawHeaders: requestInput.headers,
71+
decodedBody: requestInput.rawBody.decoded
72+
});
73+
74+
return generateCodeSnippetFromHarRequest(harRequest, snippetFormat);
75+
};
76+
77+
function generateCodeSnippetFromHarRequest(
78+
harRequest: ExtendedHarRequest,
79+
snippetFormat: SnippetOption
80+
): string {
5181
const harSnippetBase = simplifyHarForSnippetExport(harRequest);
5282

53-
// Then, we convert that HAR to code for the given target:
83+
// We convert the HAR to code for the given target:
5484
return new HTTPSnippet(harSnippetBase)
5585
.convert(snippetFormat.target, snippetFormat.client)
5686
.trim();

test/unit/model/ui/export.spec.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { expect } from "../../../test-setup";
2+
3+
import { RequestInput } from "../../../../src/model/send/send-request-model";
4+
import {
5+
generateCodeSnippetFromRequestInput,
6+
getCodeSnippetOptionFromKey
7+
} from "../../../../src/model/ui/export";
8+
9+
const curlFormat = getCodeSnippetOptionFromKey('shell~~curl');
10+
11+
describe("Code snippet generation from Send request inputs", () => {
12+
13+
it("should generate a curl snippet for a simple GET request", () => {
14+
const requestInput = new RequestInput({
15+
method: 'GET',
16+
url: 'https://example.com/path?a=b',
17+
headers: [
18+
['host', 'example.com'],
19+
['accept', 'application/json']
20+
],
21+
requestContentType: 'text',
22+
rawBody: Buffer.from([])
23+
});
24+
25+
const snippet = generateCodeSnippetFromRequestInput(requestInput, curlFormat);
26+
27+
expect(snippet).to.include('curl');
28+
expect(snippet).to.include('https://example.com/path?a=b');
29+
expect(snippet).to.include("--header 'accept: application/json'");
30+
});
31+
32+
it("should generate a curl snippet including the body for a POST request", () => {
33+
const requestInput = new RequestInput({
34+
method: 'POST',
35+
url: 'https://example.com/upload',
36+
headers: [
37+
['host', 'example.com'],
38+
['content-type', 'application/json'],
39+
['content-length', '18']
40+
],
41+
requestContentType: 'json',
42+
rawBody: Buffer.from('{"hello":"world"}')
43+
});
44+
45+
const snippet = generateCodeSnippetFromRequestInput(requestInput, curlFormat);
46+
47+
expect(snippet).to.include('--request POST');
48+
expect(snippet).to.include('https://example.com/upload');
49+
expect(snippet).to.include("--header 'content-type: application/json'");
50+
expect(snippet).to.include('{"hello":"world"}');
51+
52+
// Content-length is dropped, as clients can calculate it themselves:
53+
expect(snippet).to.not.include('content-length');
54+
});
55+
56+
it("should drop content-encoding headers and use the decoded body", () => {
57+
const requestInput = new RequestInput({
58+
method: 'POST',
59+
url: 'https://example.com/',
60+
headers: [
61+
['host', 'example.com'],
62+
['content-type', 'text/plain'],
63+
['content-encoding', 'gzip']
64+
],
65+
requestContentType: 'text',
66+
rawBody: Buffer.from('plain text body')
67+
});
68+
69+
const snippet = generateCodeSnippetFromRequestInput(requestInput, curlFormat);
70+
71+
expect(snippet).to.include('plain text body');
72+
expect(snippet).to.not.include('content-encoding');
73+
});
74+
75+
it("should fail clearly given an invalid URL", () => {
76+
const requestInput = new RequestInput({
77+
method: 'GET',
78+
url: 'not-a-real-url',
79+
headers: [],
80+
requestContentType: 'text',
81+
rawBody: Buffer.from([])
82+
});
83+
84+
expect(() =>
85+
generateCodeSnippetFromRequestInput(requestInput, curlFormat)
86+
).to.throw();
87+
});
88+
89+
});

0 commit comments

Comments
 (0)