mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
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>
326 lines
11 KiB
TypeScript
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";
|
|
}
|