import type { DropReason, Flow, FolderSummary, WorkspaceSummary, } from "@yaakapp-internal/proxy-lib"; import { Banner, Dialog, DialogFooter, HttpMethodTagRaw, LoadingIcon, PlainInput, } from "@yaakapp-internal/ui"; import { unnamedRequestName } from "@yaakapp-internal/ui/resolvedModelName"; import { openUrl } from "@tauri-apps/plugin-opener"; import { useAtom } from "jotai"; import { useEffect, useMemo, useState } from "react"; import { useLocalStorage } from "react-use"; import { useRpcMutation } from "../hooks/useRpcMutation"; import { useRpcQuery } from "../hooks/useRpcQuery"; import { exportHar, saveDialogFlowAtom } from "../lib/flowActions"; import { useShowToast } from "../lib/toast"; const DOWNLOAD_URL = "https://yaak.app/download"; const SAVE_FORM_ID = "save-flow-to-yaak"; /** Save as Yaak request, for the flow in `saveDialogFlowAtom` */ export function SaveDialog() { const [flow, setFlow] = useAtom(saveDialogFlowAtom); const workspaces = useRpcQuery("list_workspaces", {}, { enabled: flow != null, staleTime: 0 }); const close = () => setFlow(null); if (flow == null) return null; if (workspaces.data?.yaakFound === false) return ; return ( {workspaces.error != null ? ( {workspaces.error.message} ) : workspaces.data == null ? ( ) : ( )} ); } function SaveForm({ flow, workspaces, onClose, }: { flow: Flow; workspaces: WorkspaceSummary[]; onClose: () => void; }) { const showToast = useShowToast(); const preview = useRpcQuery("preview_save", { flowId: flow.id }, { staleTime: 0 }); const [lastWorkspaceId, setLastWorkspaceId] = useLocalStorage("save_workspace_id"); const [workspaceId, setWorkspaceId] = useState( workspaces.find((w) => w.id === lastWorkspaceId)?.id ?? workspaces[0]?.id ?? "", ); const [folderId, setFolderId] = useState(""); const [name, setName] = useState(""); const save = useRpcMutation("save_flow"); const workspace = workspaces.find((w) => w.id === workspaceId); const folders = useMemo(() => folderOptions(workspace?.folders ?? []), [workspace]); useEffect(() => setFolderId(""), [workspaceId]); const p = preview.data; // Unnamed unless one is typed, as Yaak leaves a new request, which then shows its URL. const unnamed = p == null ? "" : unnamedRequestName({ model: "http_request", url: p.requestUrl, bodyType: p.bodyType }); const folderName = folders.find((f) => f.id === folderId)?.name; const submit = () => { if (workspace == null) return; save.mutate( { flowId: flow.id, workspaceId: workspace.id, ...(folderId === "" ? {} : { folderId }), ...(name.trim() === "" ? {} : { name }), }, { onSuccess: (saved) => { setLastWorkspaceId(workspace.id); onClose(); showToast({ message: ( <> Saved {saved.name || unnamed} to {workspace.name} {folderName == null ? "." : `, in ${folderName}.`} Yaak picks it up within a second. > ), }); }, }, ); }; if (workspaces.length === 0) { return ( Yaak has no workspaces yet. Create one in Yaak, then save here. ); } if (p == null) { return preview.error != null ? ( {preview.error.message} ) : ( ); } return ( { e.preventDefault(); submit(); }} > {flow.url} setWorkspaceId(e.target.value)} > {workspaces.map((w) => ( {w.name} ))} setFolderId(e.target.value)} > No folder {folders.map((f) => ( {f.name} ))} {p.droppedHeaders.length > 0 && ( {p.droppedHeaders.length === 1 ? "1 header left out" : `${p.droppedHeaders.length} headers left out`}{" "} {droppedSummary(p.droppedHeaders.map((h) => h.reason))} {p.droppedHeaders.map((h) => ( {h.name} ))} )} Kept {p.keptHeaders === 1 ? "1 header" : `${p.keptHeaders} headers`} {p.body == null ? ", and no body" : `, and the body as ${p.body}`} {p.warnings.map((w) => ( ))} {save.error != null && {save.error.message}} Yaak picks it up within a second } actions={[ { label: "Cancel", onClick: onClose }, { label: "Save request", form: SAVE_FORM_ID, color: "primary", isLoading: save.isPending, disabled: workspace == null, }, ]} /> ); } function NoteRow({ warning, authorization }: { warning: string; authorization: boolean }) { const flagged = authorization && warning.includes("Authorization"); return ( <> Note {warning} > ); } /** Without Yaak there is nothing to save into: say so, and offer HAR as a way to keep the flow */ function NoYaakDialog({ flow, onClose }: { flow: Flow; onClose: () => void }) { const showToast = useShowToast(); return ( Or keep the flow as a file} actions={[ { label: "Export HAR", onClick: async () => { try { if ((await exportHar({ ids: [flow.id] }, "flow.har")) != null) { onClose(); showToast({ message: "Exported the flow as HAR" }); } } catch (err) { showToast({ message: `Failed to export HAR: ${String(err)}`, color: "danger" }); } }, }, { label: "Download Yaak", color: "primary", onClick: () => void openUrl(DOWNLOAD_URL) }, ]} /> Saving needs Yaak This flow becomes a request in one of Yaak's workspaces, and Yaak has not been opened on this Mac. Yaak is free for personal use and opens the request with the same viewers you see here. ); } function Field({ label, children }: { label: string; children: React.ReactNode }) { return ( // oxlint-disable-next-line jsx-a11y/label-has-associated-control -- wraps its control {label} {children} ); } const inputClasses = "h-sm w-full min-w-0 px-2 rounded-md border border-border bg-surface text-text text-sm outline-hidden focus:border-border-focus"; /** Folders by their path within the workspace, "Stripe / Payments", in path order */ function folderOptions(folders: FolderSummary[]): { id: string; name: string }[] { const byId = new Map(folders.map((f) => [f.id, f])); const pathOf = (f: FolderSummary): string => { const names = [f.name]; const seen = new Set([f.id]); let parent = f.folderId == null ? undefined : byId.get(f.folderId); while (parent != null && !seen.has(parent.id)) { names.unshift(parent.name); seen.add(parent.id); parent = parent.folderId == null ? undefined : byId.get(parent.folderId); } return names.join(" / "); }; return folders .map((f) => ({ id: f.id, name: pathOf(f) })) .sort((a, b) => a.name.localeCompare(b.name)); } function reasonLabel(reason: DropReason): string { switch (reason) { case "set_by_sending": return "Yaak sets it when it sends"; case "hop_by_hop": return "Only meant for the proxy"; case "body_decoded": return "The body is saved decoded"; } } function droppedSummary(reasons: DropReason[]): string { const unique = [...new Set(reasons)]; if (unique.length === 1 && unique[0] === "set_by_sending") { return "Yaak sets these itself when it sends"; } return "Yaak sets these itself, or they only meant something to the proxy"; }