From 62a15af3954f2c16c4be9313e5749ce52b6a8fb3 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 10 Nov 2026 11:07:08 -0700 Subject: [PATCH] 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 --- .../components/ClearFlowsButton.tsx | 146 +++++++++++------- apps/yaak-proxy/components/SettingsProxy.tsx | 18 ++- apps/yaak-proxy/lib/clearFlows.test.ts | 56 +++++++ apps/yaak-proxy/lib/clearFlows.ts | 32 ++++ 4 files changed, 189 insertions(+), 63 deletions(-) create mode 100644 apps/yaak-proxy/lib/clearFlows.test.ts create mode 100644 apps/yaak-proxy/lib/clearFlows.ts diff --git a/apps/yaak-proxy/components/ClearFlowsButton.tsx b/apps/yaak-proxy/components/ClearFlowsButton.tsx index 3e0058f89..e12f4b175 100644 --- a/apps/yaak-proxy/components/ClearFlowsButton.tsx +++ b/apps/yaak-proxy/components/ClearFlowsButton.tsx @@ -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(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: ( -
-
- {total === 1 ? "Clear 1 flow?" : `Clear all ${total.toLocaleString()} flows?`} -
-
- {error != null ? ( - {error} - ) : ( - "They are deleted from disk with their bodies, for good." - )} -
-
- ), - }, - { 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 ( - - ); - } + 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 ( - + <> - + setConfirming(false)} + size="sm" + title="Clear traffic?" + > + {confirming && ( + setConfirming(false)} + onClear={(dontAskAgain) => { + setConfirming(false); + if (dontAskAgain) setConfirm(false); + clear(); + }} + /> + )} + + + ); +} + +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) => { + e.preventDefault(); + onClear(dontAskAgain); + }; + return ( +
+

+ {total === 1 + ? "1 flow will be removed." + : `${total.toLocaleString()} flows will be removed.`} +

+ + + ); } diff --git a/apps/yaak-proxy/components/SettingsProxy.tsx b/apps/yaak-proxy/components/SettingsProxy.tsx index fdc785a73..94359b1eb 100644 --- a/apps/yaak-proxy/components/SettingsProxy.tsx +++ b/apps/yaak-proxy/components/SettingsProxy.tsx @@ -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() { {proxyState?.traffic === "keep" && } + @@ -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 ( + + ); +} + function ResetRow() { const [confirming, setConfirming] = useState(false); const store = useStore(); diff --git a/apps/yaak-proxy/lib/clearFlows.test.ts b/apps/yaak-proxy/lib/clearFlows.test.ts new file mode 100644 index 000000000..be0684065 --- /dev/null +++ b/apps/yaak-proxy/lib/clearFlows.test.ts @@ -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; + + 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); + }); +}); diff --git a/apps/yaak-proxy/lib/clearFlows.ts b/apps/yaak-proxy/lib/clearFlows.ts new file mode 100644 index 000000000..c3524b2aa --- /dev/null +++ b/apps/yaak-proxy/lib/clearFlows.ts @@ -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( + "confirm_clear_flows", + true, + // Read through globalThis so tests, which have no localStorage, can import this + createJSONStorage(() => 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"; +}