mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
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:
1 parent
a4355b956a
commit
62a15af395
4 files changed
+189
-63
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
Reference in new issue
Block a user