Files
yaak-mountain-loop/apps/yaak-proxy/components/SaveDialog.tsx
T
Gregory SchierandClaude Fable 5.2 35058cc3be Yaak Proxy: prose pass over the UI copy
Apply the approved wording table: labels stay short, explanations are one
full sentence about the user's traffic, and detail moves behind an info
icon on the first-run card, the body banners, the Settings Helper row and
the rejected card. One word per thing: traffic and flows, recorded,
truncated, domains, searches, the CLI, Retry.

Backend messages that reach the window change with them, with the tests
and harness fixtures that assert on the old text. The two working files
for the pass are removed from the branch.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 08:41:49 -07:00

322 lines
11 KiB
TypeScript

import type {
DropReason,
Flow,
FolderSummary,
WorkspaceSummary,
} from "@yaakapp-internal/proxy-lib";
import {
Banner,
CONTROL_TOOLTIP_DELAY,
Dialog,
DialogFooter,
HttpMethodTagRaw,
LoadingIcon,
PlainInput,
Tooltip,
} 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 <NoYaakDialog flow={flow} onClose={close} />;
return (
<Dialog
open
onClose={close}
size="dynamic"
title="Save as Yaak Request"
description="A new request will be created in Yaak."
className="w-[36rem]"
>
{workspaces.error != null ? (
<Banner color="danger">{workspaces.error.message}</Banner>
) : workspaces.data == null ? (
<LoadingIcon />
) : (
<SaveForm flow={flow} workspaces={workspaces.data.workspaces} onClose={close} />
)}
</Dialog>
);
}
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<string>("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 <b className="font-semibold">{saved.name || unnamed}</b> to {workspace.name}
{folderName == null ? "" : `, in ${folderName}`}
</>
),
});
},
},
);
};
if (workspaces.length === 0) {
return <Banner color="notice">Create a workspace in Yaak first.</Banner>;
}
if (p == null) {
return preview.error != null ? (
<Banner color="danger">{preview.error.message}</Banner>
) : (
<LoadingIcon />
);
}
return (
<form
id={SAVE_FORM_ID}
className="flex flex-col gap-3.5 pb-2"
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
<div className="flex items-center gap-3 h-sm px-2.5 rounded-md border border-border-subtle font-mono text-sm min-w-0">
<HttpMethodTagRaw method={flow.method} colored />
<span className="truncate text-text">{flow.url}</span>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Workspace">
<select
className={inputClasses}
value={workspaceId}
onChange={(e) => setWorkspaceId(e.target.value)}
>
{workspaces.map((w) => (
<option key={w.id} value={w.id}>
{w.name}
</option>
))}
</select>
</Field>
<Field label="Folder">
<select
className={inputClasses}
value={folderId}
onChange={(e) => setFolderId(e.target.value)}
>
<option value="">No folder</option>
{folders.map((f) => (
<option key={f.id} value={f.id}>
{f.name}
</option>
))}
</select>
</Field>
</div>
<PlainInput label="Name" autoFocus defaultValue="" placeholder={unnamed} onChange={setName} />
{p.droppedHeaders.length > 0 && (
<div className="rounded-md border border-border-subtle px-3.5 py-2.5">
<div className="text-sm text-text">
{p.droppedHeaders.length === 1
? "1 header left out"
: `${p.droppedHeaders.length} headers left out`}{" "}
<span className="text-text-subtlest text-xs">
{droppedSummary(p.droppedHeaders.map((h) => h.reason))}
</span>
</div>
<div className="flex flex-wrap gap-1.5 mt-2">
{p.droppedHeaders.map((h) => (
<Tooltip
key={h.name}
wrap
content={`${h.name}: ${h.value}\n${reasonLabel(h.reason)}`}
delay={CONTROL_TOOLTIP_DELAY}
>
<span className="font-mono text-xs leading-[22px] px-1.5 rounded-sm border border-border-subtle text-text-subtle line-through decoration-text-subtlest">
{h.name}
</span>
</Tooltip>
))}
</div>
</div>
)}
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3.5 gap-y-1.5 text-sm">
<span className="text-text-subtle">Kept</span>
<span>
{p.keptHeaders === 1 ? "1 header" : `${p.keptHeaders} headers`}
{p.body == null ? ", and no body" : `, and the body as ${p.body}`}
</span>
{p.warnings.map((w) => (
<NoteRow key={w} warning={w} authorization={p.authorization} />
))}
</div>
{save.error != null && <Banner color="danger">{save.error.message}</Banner>}
<DialogFooter
actions={[
{ label: "Cancel", onClick: onClose },
{
label: "Save Request",
form: SAVE_FORM_ID,
color: "primary",
isLoading: save.isPending,
disabled: workspace == null,
},
]}
/>
</form>
);
}
function NoteRow({ warning, authorization }: { warning: string; authorization: boolean }) {
const flagged = authorization && warning.includes("Authorization");
return (
<>
<span className="text-text-subtle">Note</span>
<span className={flagged ? "text-notice" : "text-text-subtle"}>{warning}</span>
</>
);
}
/** 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 (
<Dialog open onClose={onClose} size="dynamic" className="w-[32rem]">
<DialogFooter
leftSlot={
<span className="text-xs text-text-subtlest">
The flow can be exported as a file instead.
</span>
}
actions={[
{
label: "Export as HAR",
onClick: async () => {
try {
if ((await exportHar({ ids: [flow.id] }, "flow.har")) != null) {
onClose();
showToast({ message: "Exported 1 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) },
]}
/>
<div className="pt-6 pb-2">
<div className="text-lg font-semibold mb-1.5">Yaak Required</div>
<div className="text-sm text-text-subtle leading-relaxed">
This flow will be saved as a request in one of Yaak's workspaces, but Yaak has not been
opened on this computer.
</div>
</div>
</Dialog>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
// oxlint-disable-next-line jsx-a11y/label-has-associated-control -- wraps its control
<label className="flex flex-col gap-1 min-w-0">
<span className="text-xs text-text-subtle">{label}</span>
{children}
</label>
);
}
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 the request.";
case "hop_by_hop":
return "It was 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 the request.";
}
return "Yaak sets these, or they were meant for the proxy.";
}