Import Curl from a paste in the browser build (#538)

This commit is contained in:
Gregory Schier
2026-08-14 11:54:56 -07:00
committed by GitHub
parent d3055c64b3
commit 85aed740a8
6 changed files with 171 additions and 115 deletions
+52
View File
@@ -0,0 +1,52 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModelById } from "@yaakapp-internal/models";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
import { jotaiStore } from "../lib/jotai";
import { invokeCmd } from "../lib/tauri";
import { showToast } from "../lib/toast";
export function looksLikeCurl(text: string) {
return text.trim().startsWith("curl ");
}
export const importCurl = createFastMutation<
void,
string,
{ overwriteRequestId?: string; command: string }
>({
mutationKey: ["import_curl"],
mutationFn: async ({ overwriteRequestId, command }) => {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
const importedRequest: HttpRequest = await invokeCmd("cmd_curl_to_request", {
command,
workspaceId,
});
let verb: string;
if (overwriteRequestId == null) {
verb = "Created";
await createRequestAndNavigate(importedRequest);
} else {
verb = "Updated";
await patchModelById(importedRequest.model, overwriteRequestId, (r: HttpRequest) => ({
...importedRequest,
id: r.id,
createdAt: r.createdAt,
workspaceId: r.workspaceId,
folderId: r.folderId,
name: r.name,
sortPriority: r.sortPriority,
}));
setTimeout(() => wasUpdatedExternally(overwriteRequestId), 100);
}
showToast({
color: "success",
message: `${verb} request from Curl`,
});
},
});
@@ -5,11 +5,11 @@ import classNames from "classnames";
import { atom, useAtomValue } from "jotai";
import type { CSSProperties } from "react";
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
import { importCurl, looksLikeCurl } from "../commands/importCurl";
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
import { useAuthTab } from "../hooks/useAuthTab";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useHeadersTab } from "../hooks/useHeadersTab";
import { useImportCurl } from "../hooks/useImportCurl";
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
@@ -278,7 +278,6 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
const updateKey = useRequestUpdateKey(activeRequestId);
const { mutate: importCurl } = useImportCurl();
const handleBodyChange = useCallback(
(body: HttpRequest["body"]) => patchModelDebounced(activeRequest, { body }),
@@ -299,8 +298,8 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const handlePaste = useCallback(
async (e: ClipboardEvent, text: string) => {
if (text.startsWith("curl ")) {
importCurl({ overwriteRequestId: activeRequestId, command: text });
if (looksLikeCurl(text)) {
importCurl.mutate({ overwriteRequestId: activeRequestId, command: text });
} else {
const patch = prepareImportQuerystring(text);
if (patch != null) {
@@ -322,7 +321,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
}
}
},
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh, importCurl],
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh],
);
const handleSend = useCallback(
() => sendRequest(activeRequest.id ?? null),
+113
View File
@@ -0,0 +1,113 @@
import { isTauri } from "@tauri-apps/api/core";
import { readText } from "@tauri-apps/plugin-clipboard-manager";
import { Icon } from "@yaakapp-internal/ui";
import * as m from "motion/react-m";
import { useEffect, useState } from "react";
import { importCurl, looksLikeCurl } from "../commands/importCurl";
import { useWindowFocus } from "../hooks/useWindowFocus";
import { showToast } from "../lib/toast";
import { Button } from "./core/Button";
/**
* Offers to create a request from a Curl command on the clipboard. The desktop app can
* read the clipboard whenever the window is focused, but a browser can't without
* prompting for permission, so it waits for the user to paste instead.
*/
export function ImportCurl() {
return isTauri() ? <ImportCurlButton /> : <ImportCurlOnPaste />;
}
function ImportCurlButton() {
const focused = useWindowFocus();
const [clipboardText, setClipboardText] = useState("");
const [isLoading, setIsLoading] = useState(false);
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
useEffect(() => {
void readText().then(setClipboardText);
}, [focused]);
if (!looksLikeCurl(clipboardText)) {
return null;
}
return (
<m.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.5 }}
>
<Button
size="2xs"
variant="border"
color="success"
className="rounded-full"
rightSlot={<Icon icon="import" size="sm" />}
isLoading={isLoading}
title="Import Curl command from clipboard"
onClick={async () => {
setIsLoading(true);
try {
await importCurl.mutateAsync({ command: clipboardText });
setClipboardText(""); // Hide the button until the clipboard changes
} catch (e) {
console.log("Failed to import curl", e);
} finally {
setIsLoading(false);
}
}}
>
Import Curl
</Button>
</m.div>
);
}
function ImportCurlOnPaste() {
useEffect(() => {
const handlePaste = (e: ClipboardEvent) => {
const command = e.clipboardData?.getData("text/plain") ?? "";
if (!looksLikeCurl(command)) return;
// Editable targets keep the text as text. The URL editor imports it itself.
if (isEditable(e.target)) return;
showToast({
id: "curl-paste",
color: "success",
message: (
<div>
<h2 className="font-semibold">Curl command detected</h2>
<p className="text-text-subtle text-sm">Create a request from the pasted command?</p>
</div>
),
action: ({ hide }) => (
<Button
size="xs"
color="success"
className="mr-auto min-w-20"
rightSlot={<Icon icon="import" size="sm" />}
onClick={() => {
hide();
importCurl.mutate({ command });
}}
>
Create Request
</Button>
),
});
};
document.addEventListener("paste", handlePaste);
return () => document.removeEventListener("paste", handlePaste);
}, []);
return null;
}
function isEditable(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
if (target.closest(".cm-editor") != null) return true;
return ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName);
}
@@ -1,56 +0,0 @@
import { clear, readText } from "@tauri-apps/plugin-clipboard-manager";
import * as m from "motion/react-m";
import { useEffect, useState } from "react";
import { useImportCurl } from "../hooks/useImportCurl";
import { useWindowFocus } from "../hooks/useWindowFocus";
import { Button } from "./core/Button";
import { Icon } from "@yaakapp-internal/ui";
export function ImportCurlButton() {
const focused = useWindowFocus();
const [clipboardText, setClipboardText] = useState("");
const importCurl = useImportCurl();
const [isLoading, setIsLoading] = useState(false);
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
useEffect(() => {
void readText().then(setClipboardText);
}, [focused]);
if (!clipboardText?.trim().startsWith("curl ")) {
return null;
}
return (
<m.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.5 }}
>
<Button
size="2xs"
variant="border"
color="success"
className="rounded-full"
rightSlot={<Icon icon="import" size="sm" />}
isLoading={isLoading}
title="Import Curl command from clipboard"
onClick={async () => {
setIsLoading(true);
try {
await importCurl.mutateAsync({ command: clipboardText });
await clear(); // Clear the clipboard so the button goes away
setClipboardText("");
} catch (e) {
console.log("Failed to import curl", e);
} finally {
setIsLoading(false);
}
}}
>
Import Curl
</Button>
</m.div>
);
}
@@ -10,7 +10,7 @@ import { CookieDropdown } from "./CookieDropdown";
import { IconButton } from "./core/IconButton";
import { PillButton } from "./core/PillButton";
import { EnvironmentActionsDropdown } from "./EnvironmentActionsDropdown";
import { ImportCurlButton } from "./ImportCurlButton";
import { ImportCurl } from "./ImportCurl";
import { LicenseBadge } from "./LicenseBadge";
import { RecentRequestsDropdown } from "./RecentRequestsDropdown";
import { SettingsDropdown } from "./SettingsDropdown";
@@ -56,7 +56,7 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({
<RecentRequestsDropdown />
</div>
<div className="flex-1 flex gap-1 items-center h-full justify-end pointer-events-none pr-1">
<ImportCurlButton />
<ImportCurl />
{showEncryptionSetup ? (
<PillButton color="danger" onClick={setupOrConfigureEncryption}>
Enter Encryption Key
-52
View File
@@ -1,52 +0,0 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModelById } from "@yaakapp-internal/models";
import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
import { jotaiStore } from "../lib/jotai";
import { invokeCmd } from "../lib/tauri";
import { showToast } from "../lib/toast";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
import { useFastMutation } from "./useFastMutation";
import { wasUpdatedExternally } from "./useRequestUpdateKey";
export function useImportCurl() {
return useFastMutation({
mutationKey: ["import_curl"],
mutationFn: async ({
overwriteRequestId,
command,
}: {
overwriteRequestId?: string;
command: string;
}) => {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
const importedRequest: HttpRequest = await invokeCmd("cmd_curl_to_request", {
command,
workspaceId,
});
let verb: string;
if (overwriteRequestId == null) {
verb = "Created";
await createRequestAndNavigate(importedRequest);
} else {
verb = "Updated";
await patchModelById(importedRequest.model, overwriteRequestId, (r: HttpRequest) => ({
...importedRequest,
id: r.id,
createdAt: r.createdAt,
workspaceId: r.workspaceId,
folderId: r.folderId,
name: r.name,
sortPriority: r.sortPriority,
}));
setTimeout(() => wasUpdatedExternally(overwriteRequestId), 100);
}
showToast({
color: "success",
message: `${verb} request from Curl`,
});
},
});
}