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/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}
/>
+
{
data: T;
event: React.MouseEvent;
+ position?: { x: number, y: number };
items: readonly ContextMenuItem[];
}
diff --git a/src/model/ui/export.ts b/src/model/ui/export.ts
index 2e30910c..91c2a1d0 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,
+ generateHar,
+ ExtendedHarRequest
+} from '../http/har';
+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';
@@ -45,16 +51,45 @@ 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. 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 decoded = requestInput.rawBody.decoded;
+ const sentRequest = buildSentExchangeRequest(requestInput, {
+ encodedLength: decoded.byteLength,
+ decoded
+ });
+
+ const harRequest = generateHarRequest(buildHtkRequest(sentRequest), false, {
+ bodySizeLimit: Infinity
+ });
+
+ 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/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
};
}
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 & {
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();
+ });
+
+});