Files
yaak-mountain-loop/apps/yaak-proxy/components/SaveDialog.tsx
T
Gregory SchierandClaude Opus 5.5 c78732ecaf Polish Yaak Proxy's flow list, sidebar and header
Columns keep fixed widths, Path included, and the list scrolls sideways
with the header in step instead of dropping columns or capping Host.
Resize handles sit on every column boundary, a double-click resets one,
and a third click on a sorted header goes back to start time, with empty
values sorting lowest. The search box takes up to 44rem and wraps over
the list while focused, its completions as wide as it. The list/detail
handle fills the gutter. Native title tooltips become Yaak's tooltip,
shared buttons gain an opt-in for it, and folder and program rows in the
sidebar get Reveal in Finder and Copy path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:45:46 -07:00

326 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 in Yaak with this flow's method, URL, headers and body."
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}.`} Yaak picks it up within a second.
</>
),
});
},
},
);
};
if (workspaces.length === 0) {
return (
<Banner color="notice">
Yaak has no workspaces yet. Create one in Yaak, then save here.
</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
leftSlot={
<span className="text-xs text-text-subtlest">Yaak picks it up within a second</span>
}
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">Or keep the flow as a file</span>}
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) },
]}
/>
<div className="pt-6 pb-2">
<div className="text-lg font-semibold mb-1.5">Saving needs Yaak</div>
<div className="text-sm text-text-subtle leading-relaxed">
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.
</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";
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";
}