Files
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

228 lines
8.3 KiB
TypeScript

import type { ExportHarRequest, Flow, FlowBody } from "@yaakapp-internal/proxy-lib";
import type { DropdownItem } from "@yaakapp-internal/ui";
import { save } from "@tauri-apps/plugin-dialog";
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import { atom, useAtomValue, useSetAtom } from "jotai";
import { useCallback, useEffect } from "react";
import { base64ToBytes, bodyFileName } from "./body";
import { curlBodyText, toCurl } from "./curl";
import type { FlowQuery } from "./query";
import { rpc } from "./rpc";
import { activeQueryAtom, lockedAtom } from "./store";
import { subscribe } from "./tauri";
import { useShowToast } from "./toast";
/** The flow the save dialog is open for */
export const saveDialogFlowAtom = atom<Flow | null>(null);
/** A body as text, decoding what is not UTF-8 lossily, or null when there is none */
async function bodyText(flow: Flow, which: FlowBody): Promise<string | null> {
const res = await rpc("get_flow_body", { flowId: flow.id, which });
if (res.decodeError != null) throw new Error(res.decodeError);
const bytes = base64ToBytes(res.data);
return bytes.length === 0 ? null : new TextDecoder().decode(bytes);
}
/** Writes a flow's body to a file the person picks, as the viewer shows it */
export function useSaveFlowBody(): (flow: Flow, which: FlowBody) => Promise<void> {
const showToast = useShowToast();
return useCallback(
async (flow: Flow, which: FlowBody) => {
try {
const path = await save({ defaultPath: bodyFileName(flow, which) });
if (path == null) return;
const saved = await rpc("save_flow_body", { flowId: flow.id, which, path });
const notes = [
saved.raw ? "as received, since it failed to decode" : null,
saved.truncated ? "truncated at the capture limit" : null,
].filter((n) => n != null);
showToast({
message: `Saved ${formatSize(saved.size)}${notes.length > 0 ? `, ${notes.join(", ")}` : ""}`,
});
} catch (err) {
showToast({ message: `Failed to save the ${which} body: ${String(err)}`, color: "danger" });
}
},
[showToast],
);
}
/**
* Writes flows to a HAR file the person picks: `ids`, or every flow `filter` covers. Returns how
* many were written, or null when the save panel was cancelled.
*/
export async function exportHar(
req: Omit<ExportHarRequest, "path">,
defaultName: string,
): Promise<number | null> {
const path = await save({
defaultPath: defaultName,
filters: [{ name: "HTTP Archive", extensions: ["har"] }],
});
if (path == null) return null;
return rpc("export_har", { ...req, path });
}
/** Exports every flow `query` covers, or all of them, as HAR, saying how many it wrote */
function useExportHarOf(): (query: FlowQuery | null) => Promise<void> {
const showToast = useShowToast();
return useCallback(
async (query: FlowQuery | null) => {
try {
const written = await exportHar(query == null ? {} : { filter: query }, "flows.har");
if (written != null) {
showToast({
message: written === 1 ? "Exported 1 flow as HAR" : `Exported ${written} flows as HAR`,
});
}
} catch (err) {
showToast({ message: `Failed to export HAR: ${String(err)}`, color: "danger" });
}
},
[showToast],
);
}
/**
* Exporting every flow the list covers as HAR, all of them or the search's, for the list's and
* the search box's menus: it acts on flows, so it lives where they are chosen.
*/
export function useExportShownHar(): () => DropdownItem {
const exportOf = useExportHarOf();
const query = useAtomValue(activeQueryAtom);
// Nothing can be read to export while captured traffic is locked.
const locked = useAtomValue(lockedAtom) != null;
return useCallback(
() => ({
label: query == null ? "Export All Flows as HAR" : "Export Matching Flows as HAR",
icon: "download",
disabled: locked,
onSelect: () => exportOf(query),
}),
[query, locked, exportOf],
);
}
/** File's Export All Flows as HAR, which the app menu sends as an event */
export function useMenuExportHar() {
const exportOf = useExportHarOf();
const locked = useAtomValue(lockedAtom) != null;
useEffect(
() =>
subscribe<null>(MENU_EXPORT_HAR_EVENT, () => {
if (!locked) void exportOf(null);
}),
[exportOf, locked],
);
}
/** `EXPORT_HAR_EVENT` in the app's `window_menu.rs` */
const MENU_EXPORT_HAR_EVENT = "menu_export_har";
/** What a flow's context menu and the detail pane's menu offer */
export function useFlowActions(): (flow: Flow) => DropdownItem[] {
const showToast = useShowToast();
const openSave = useSetAtom(saveDialogFlowAtom);
const saveBody = useSaveFlowBody();
return useCallback(
(flow: Flow) => {
const copy = async (label: string, text: () => Promise<string | null> | string) => {
try {
const value = await text();
if (value == null) {
showToast({ message: `There is no ${label} to copy`, color: "info" });
return;
}
await navigator.clipboard.writeText(value);
showToast({ message: "Copied to clipboard" });
} catch (err) {
showToast({ message: `Failed to copy: ${String(err)}`, color: "danger" });
}
};
const noResponse = (flow.error != null || flow.cancelled) && flow.status == null;
// A failed TLS handshake, recorded without a request, has nothing to replay.
const noRequest = flow.method === "";
return [
{
label: flow.savedRequestId == null ? "Save as Yaak Request" : "Save to Yaak Again",
icon: "bookmark",
disabled: noRequest,
onSelect: () => openSave(flow),
},
{ type: "separator" },
{
label: "Copy as cURL",
icon: "square_terminal",
disabled: noRequest,
onSelect: async () => {
let body: string | null = null;
if (flow.requestSize > 0) {
let res;
try {
res = await rpc("get_flow_body", { flowId: flow.id, which: "request" });
} catch (err) {
showToast({ message: `Failed to copy cURL: ${String(err)}`, color: "danger" });
return;
}
if (res.decodeError != null) {
showToast({ message: `Failed to copy cURL: ${res.decodeError}`, color: "danger" });
return;
}
body = curlBodyText(base64ToBytes(res.data), res.truncated);
// A command that sends other bytes than the app did would mislead.
if (body == null) {
showToast({
message: "The body is binary or truncated. Export as HAR instead.",
color: "info",
});
return;
}
}
await copy("cURL", () => toCurl(flow, body));
},
},
{ label: "Copy URL", icon: "copy", onSelect: () => copy("URL", () => flow.url) },
{
label: "Copy Request Body",
icon: "copy",
disabled: flow.requestSize === 0,
onSelect: () => copy("request body", () => bodyText(flow, "request")),
},
{
label: "Copy Response Body",
icon: "copy",
disabled: noResponse,
onSelect: () => copy("response body", () => bodyText(flow, "response")),
},
{ type: "separator" },
{
label: "Save Request Body to File",
icon: "download",
disabled: flow.requestSize === 0,
onSelect: () => saveBody(flow, "request"),
},
{
label: "Save Response Body to File",
icon: "download",
disabled: noResponse || flow.responseSize === 0,
onSelect: () => saveBody(flow, "response"),
},
{
label: "Export as HAR",
icon: "download",
onSelect: async () => {
try {
const written = await exportHar({ ids: [flow.id] }, "flow.har");
if (written != null) showToast({ message: "Exported 1 flow as HAR" });
} catch (err) {
showToast({ message: `Failed to export HAR: ${String(err)}`, color: "danger" });
}
},
},
];
},
[openSave, saveBody, showToast],
);
}