Confirm clearing Yaak Proxy's traffic in a dialog, with Don't ask again

The trash button and its hotkey open a confirm dialog instead of a
dropdown. Its Don't ask again checkbox saves a preference that Settings
turns back on under Traffic, as Confirm before clearing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Gregory SchierandClaude Opus 5.5 authored and Stetta task tk_mwazr5usoucy1lxf committed 2026-11-10 11:08:40 -07:00
1 parent a4355b956a
commit 62a15af395
4 files changed
+189 -63

No files matched your search

+89 -57
View File
@@ -1,77 +1,109 @@
import { Dropdown, type DropdownItem, IconButton } from "@yaakapp-internal/ui";
import { useAtomValue, useSetAtom } from "jotai";
import { useState } from "react";
import { Checkbox, Dialog, DialogFooter, IconButton, useHotKey } from "@yaakapp-internal/ui";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { type FormEvent, useCallback, useId, useState } from "react";
import { clearFlowsAction, confirmClearFlowsAtom } from "../lib/clearFlows";
import { clearFlowsAtom, flowTotalAtom, lockedAtom } from "../lib/store";
import { useShowToast } from "../lib/toast";
/**
* Clears every flow, once confirmed in its menu, which its hotkey opens too. The list empties at
* once; the flows are deleted in the background. Disabled with none to clear, or while captured
* traffic is locked.
* Clears every flow, from its button or hotkey, once confirmed in a dialog unless "Don't ask
* again" was checked there. The list empties at once; the flows are deleted in the background.
* Disabled with none to clear, or while captured traffic is locked.
*/
export function ClearFlowsButton() {
const total = useAtomValue(flowTotalAtom);
const locked = useAtomValue(lockedAtom);
const clear = useSetAtom(clearFlowsAtom);
const [error, setError] = useState<string | null>(null);
const [confirm, setConfirm] = useAtom(confirmClearFlowsAtom);
const clearFlows = useSetAtom(clearFlowsAtom);
const showToast = useShowToast();
const [confirming, setConfirming] = useState(false);
const action = clearFlowsAction({ total, locked: locked != null, confirm });
const items: DropdownItem[] = [
{
type: "content",
label: (
<div className="px-1.5 py-1 max-w-[17rem]">
<div className="text-sm text-text">
{total === 1 ? "Clear 1 flow?" : `Clear all ${total.toLocaleString()} flows?`}
</div>
<div className="text-xs text-text-subtle mt-0.5">
{error != null ? (
<span className="text-danger">{error}</span>
) : (
"They are deleted from disk with their bodies, for good."
)}
</div>
</div>
),
},
{ type: "separator" },
{
label: "Clear",
icon: "trash",
color: "danger",
onSelect: () => {
setError(null);
clear().catch((err: unknown) => {
const message = (err as { message?: unknown } | null)?.message;
setError(typeof message === "string" ? message : String(err));
});
},
},
];
const clear = useCallback(() => {
clearFlows().catch((err: unknown) => {
const message = (err as { message?: unknown } | null)?.message;
showToast({ message: typeof message === "string" ? message : String(err), color: "danger" });
});
}, [clearFlows, showToast]);
// Nothing to clear, or nothing that can be read while traffic is locked: no menu, nor hotkey.
if (locked != null || total === 0) {
return (
<IconButton
className="pointer-events-auto"
icon="trash"
title={locked != null ? "Clear flows (traffic is locked)" : "Clear flows (none yet)"}
tooltip
size="sm"
iconColor="secondary"
disabled
/>
);
}
const start = useCallback(() => {
if (action === "confirm") setConfirming(true);
else if (action === "clear") clear();
}, [action, clear]);
// Bound even when disabled, so the key never falls through to the backend's clear unasked
useHotKey("flows_clear", start);
return (
<Dropdown items={items} hotKeyAction="flows_clear">
<>
<IconButton
className="pointer-events-auto"
icon="trash"
title="Clear flows"
title={
locked != null
? "Clear flows (traffic is locked)"
: total === 0
? "Clear flows (none yet)"
: "Clear flows"
}
tooltip
size="sm"
iconColor="secondary"
disabled={action === "disabled"}
onClick={start}
/>
</Dropdown>
<Dialog
open={confirming}
onClose={() => setConfirming(false)}
size="sm"
title="Clear traffic?"
>
{confirming && (
<ConfirmClear
total={total}
onCancel={() => setConfirming(false)}
onClear={(dontAskAgain) => {
setConfirming(false);
if (dontAskAgain) setConfirm(false);
clear();
}}
/>
)}
</Dialog>
</>
);
}
function ConfirmClear({
total,
onCancel,
onClear,
}: {
total: number;
onCancel: () => void;
onClear: (dontAskAgain: boolean) => void;
}) {
const formId = useId();
const [dontAskAgain, setDontAskAgain] = useState(false);
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
onClear(dontAskAgain);
};
return (
<form id={formId} className="flex flex-col gap-3" onSubmit={handleSubmit}>
<p>
{total === 1
? "1 flow will be removed."
: `${total.toLocaleString()} flows will be removed.`}
</p>
<Checkbox checked={dontAskAgain} title="Don't ask again" onChange={setDontAskAgain} />
<DialogFooter
inline
actions={[
{ label: "Cancel", onClick: onCancel },
{ label: "Clear", color: "danger", form: formId, autoFocus: true },
]}
/>
</form>
);
}
+12 -6
View File
@@ -1,10 +1,6 @@
import { useQueryClient } from "@tanstack/react-query";
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type {
HttpVersion,
SystemProxyStatus,
TrafficRetention,
} from "@yaakapp-internal/proxy-lib";
import type { HttpVersion, SystemProxyStatus, TrafficRetention } from "@yaakapp-internal/proxy-lib";
import {
Button,
Dialog,
@@ -22,11 +18,12 @@ import {
SettingsSection,
VStack,
} from "@yaakapp-internal/ui";
import { useAtomValue, useSetAtom, useStore } from "jotai";
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
import { useState } from "react";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { useRpcQuery } from "../hooks/useRpcQuery";
import { helperDialogOpenAtom } from "../lib/atoms";
import { confirmClearFlowsAtom } from "../lib/clearFlows";
import {
defaultShell,
environmentBlock,
@@ -103,6 +100,7 @@ export function SettingsProxy() {
<SettingsSection title="Traffic">
<TrafficRow />
{proxyState?.traffic === "keep" && <KeepTrafficRow />}
<ConfirmClearRow />
<ResetRow />
</SettingsSection>
@@ -270,6 +268,14 @@ function KeepTrafficRow() {
);
}
/** Asking before Clear, which its dialog's "Don't ask again" turns off */
function ConfirmClearRow() {
const [confirm, setConfirm] = useAtom(confirmClearFlowsAtom);
return (
<SettingRowBoolean title="Confirm before clearing" checked={confirm} onChange={setConfirm} />
);
}
function ResetRow() {
const [confirming, setConfirming] = useState(false);
const store = useStore();
+56
View File
@@ -0,0 +1,56 @@
import { createStore } from "jotai";
import { beforeEach, describe, expect, test, vi } from "vite-plus/test";
import { clearFlowsAction, confirmClearFlowsAtom } from "./clearFlows";
describe("clearFlowsAction", () => {
test("asks first while the preference is on", () => {
expect(clearFlowsAction({ total: 3, locked: false, confirm: true })).toBe("confirm");
});
test("clears at once once asking is turned off", () => {
expect(clearFlowsAction({ total: 3, locked: false, confirm: false })).toBe("clear");
});
test("does nothing with none to clear or traffic locked, whatever the preference", () => {
for (const confirm of [true, false]) {
expect(clearFlowsAction({ total: 0, locked: false, confirm })).toBe("disabled");
expect(clearFlowsAction({ total: 3, locked: true, confirm })).toBe("disabled");
}
});
});
/** The preference as a new window reads it, once something shows it */
function mounted() {
const store = createStore();
const unsub = store.sub(confirmClearFlowsAtom, () => {});
const value = store.get(confirmClearFlowsAtom);
unsub();
return value;
}
describe("confirmClearFlowsAtom", () => {
let saved: Map<string, string>;
beforeEach(() => {
saved = new Map();
vi.stubGlobal("localStorage", {
getItem: (k: string) => saved.get(k) ?? null,
setItem: (k: string, v: string) => void saved.set(k, v),
removeItem: (k: string) => void saved.delete(k),
});
});
test("is on by default", () => {
expect(createStore().get(confirmClearFlowsAtom)).toBe(true);
});
test("keeps Don't ask again for later launches, until Settings turns it back on", () => {
createStore().set(confirmClearFlowsAtom, false);
expect(saved.get("confirm_clear_flows")).toBe("false");
expect(mounted()).toBe(false);
createStore().set(confirmClearFlowsAtom, true);
expect(saved.get("confirm_clear_flows")).toBe("true");
expect(mounted()).toBe(true);
});
});
+32
View File
@@ -0,0 +1,32 @@
import { atomWithStorage, createJSONStorage } from "jotai/utils";
/**
* Clearing traffic asks first, until "Don't ask again" is checked in its dialog. The switch in
* Settings turns the asking back on.
*/
export const confirmClearFlowsAtom = atomWithStorage<boolean>(
"confirm_clear_flows",
true,
// Read through globalThis so tests, which have no localStorage, can import this
createJSONStorage<boolean>(() => globalThis.localStorage),
{ getOnInit: true },
);
/** What Clear does now: nothing, ask first, or clear at once */
export type ClearFlowsAction = "disabled" | "confirm" | "clear";
export function clearFlowsAction({
total,
locked,
confirm,
}: {
/** Flows there are to clear */
total: number;
/** Captured traffic is locked, so none of it can be read */
locked: boolean;
/** The saved preference to ask before clearing */
confirm: boolean;
}): ClearFlowsAction {
if (locked || total === 0) return "disabled";
return confirm ? "confirm" : "clear";
}