diff --git a/apps/yaak-client/commands/importCurl.ts b/apps/yaak-client/commands/importCurl.ts new file mode 100644 index 00000000..c264c43f --- /dev/null +++ b/apps/yaak-client/commands/importCurl.ts @@ -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`, + }); + }, +}); diff --git a/apps/yaak-client/components/HttpRequestPane.tsx b/apps/yaak-client/components/HttpRequestPane.tsx index 9c73697b..c6d25c3f 100644 --- a/apps/yaak-client/components/HttpRequestPane.tsx +++ b/apps/yaak-client/components/HttpRequestPane.tsx @@ -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), diff --git a/apps/yaak-client/components/ImportCurl.tsx b/apps/yaak-client/components/ImportCurl.tsx new file mode 100644 index 00000000..5ddd1990 --- /dev/null +++ b/apps/yaak-client/components/ImportCurl.tsx @@ -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() ? : ; +} + +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 ( + + + + ); +} + +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: ( +
+

Curl command detected

+

Create a request from the pasted command?

+
+ ), + action: ({ hide }) => ( + + ), + }); + }; + + 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); +} diff --git a/apps/yaak-client/components/ImportCurlButton.tsx b/apps/yaak-client/components/ImportCurlButton.tsx deleted file mode 100644 index 28575c70..00000000 --- a/apps/yaak-client/components/ImportCurlButton.tsx +++ /dev/null @@ -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 ( - - - - ); -} diff --git a/apps/yaak-client/components/WorkspaceHeader.tsx b/apps/yaak-client/components/WorkspaceHeader.tsx index 5c79057e..aefe5f4e 100644 --- a/apps/yaak-client/components/WorkspaceHeader.tsx +++ b/apps/yaak-client/components/WorkspaceHeader.tsx @@ -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({
- + {showEncryptionSetup ? ( Enter Encryption Key diff --git a/apps/yaak-client/hooks/useImportCurl.ts b/apps/yaak-client/hooks/useImportCurl.ts deleted file mode 100644 index 545aa9af..00000000 --- a/apps/yaak-client/hooks/useImportCurl.ts +++ /dev/null @@ -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`, - }); - }, - }); -}