diff --git a/apps/yaak-proxy/components/ClearFlowsButton.tsx b/apps/yaak-proxy/components/ClearFlowsMenu.tsx similarity index 59% rename from apps/yaak-proxy/components/ClearFlowsButton.tsx rename to apps/yaak-proxy/components/ClearFlowsMenu.tsx index 3e0058f89..687d21546 100644 --- a/apps/yaak-proxy/components/ClearFlowsButton.tsx +++ b/apps/yaak-proxy/components/ClearFlowsMenu.tsx @@ -1,16 +1,26 @@ -import { Dropdown, type DropdownItem, IconButton } from "@yaakapp-internal/ui"; +import { Dropdown, type DropdownItem, type DropdownRef } from "@yaakapp-internal/ui"; import { useAtomValue, useSetAtom } from "jotai"; +import type { Ref } from "react"; import { useState } from "react"; import { clearFlowsAtom, flowTotalAtom, lockedAtom } from "../lib/store"; -/** - * 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. - */ -export function ClearFlowsButton() { +/** Whether there are flows to clear: some, and readable, as they aren't while traffic is locked */ +export function useCanClearFlows(): boolean { const total = useAtomValue(flowTotalAtom); const locked = useAtomValue(lockedAtom); + return locked == null && total > 0; +} + +/** + * Clears every flow, once confirmed in its menu, which its hotkey or the status menu's Clear + * Traffic opens. It has no button of its own: it covers the element it is placed over, without + * taking the pointer, so the menu opens from there. The list empties at once; the flows are + * deleted in the background. Neither menu nor hotkey with nothing to clear, or while captured + * traffic is locked. + */ +export function ClearFlowsMenu({ ref }: { ref?: Ref }) { + const total = useAtomValue(flowTotalAtom); + const canClear = useCanClearFlows(); const clear = useSetAtom(clearFlowsAtom); const [error, setError] = useState(null); @@ -47,31 +57,13 @@ export function ClearFlowsButton() { }, ]; - // Nothing to clear, or nothing that can be read while traffic is locked: no menu, nor hotkey. - if (locked != null || total === 0) { - return ( - - ); - } - return ( - - + + ); } diff --git a/apps/yaak-proxy/components/FlowDetail.tsx b/apps/yaak-proxy/components/FlowDetail.tsx index 60fe3199e..e4ad2a420 100644 --- a/apps/yaak-proxy/components/FlowDetail.tsx +++ b/apps/yaak-proxy/components/FlowDetail.tsx @@ -31,8 +31,8 @@ export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideB Pause or resume recording - Clear flows - + Clear traffic + ) : flow.tunnelled ? ( diff --git a/apps/yaak-proxy/components/ProxyLayout.tsx b/apps/yaak-proxy/components/ProxyLayout.tsx index 0d11364d5..842c9abed 100644 --- a/apps/yaak-proxy/components/ProxyLayout.tsx +++ b/apps/yaak-proxy/components/ProxyLayout.tsx @@ -18,7 +18,6 @@ import { proxyStatusOf, statusTintColor } from "../lib/proxyStatus"; import { lockedAtom, noFlowsAtom, proxyStateAtom, systemProxyEverOnAtom } from "../lib/store"; import { getOsType } from "../lib/tauri"; import { AddColumnDialog } from "./AddColumnDialog"; -import { ClearFlowsButton } from "./ClearFlowsButton"; import { FirstRun } from "./FirstRun"; import { FlowDetail } from "./FlowDetail"; import { FlowList } from "./FlowList"; @@ -96,7 +95,6 @@ export function ProxyLayout() {
- (null); + const clearRef = useRef(null); + const canClear = useCanClearFlows(); + const [copied, setCopied] = useCopied(); const proxyState = useAtomValue(proxyStateAtom); const setup = useProxySetup(); const execute = useRpcMutation("execute_action"); @@ -104,7 +108,23 @@ export function ProxyStatus() { systemProxy.data.helper !== "enabled" && systemProxy.data.helper !== "unavailable"; + const address = port == null ? null : `127.0.0.1:${port}`; const items: DropdownItem[] = [ + { + label: {address}, + hidden: address == null, + keepOpenOnSelect: true, + rightSlot: copied ? ( + Copied + ) : ( + + ), + onSelect: () => { + if (address == null) return; + navigator.clipboard.writeText(address).then(setCopied, console.error); + }, + }, + { type: "separator", hidden: address == null }, { type: "content", label: stateRow }, { label: recordingLabel(recording), @@ -114,6 +134,17 @@ export function ProxyStatus() { hotKeyLabelOnly: true, onSelect: () => execute.mutate({ scope: "global", action: "recording_toggle" }), }, + { + label: "Clear Traffic", + icon: "trash", + disabled: !canClear, + hotKeyAction: "flows_clear", + hotKeyLabelOnly: true, + // Its confirmation opens from the pill once this menu has closed. + onSelect: () => { + setTimeout(() => clearRef.current?.open()); + }, + }, { type: "separator" }, { type: "content", @@ -191,37 +222,36 @@ export function ProxyStatus() { return ( <> - - - {status === "encrypting" ? ( - <> - - Encrypting - {encrypting} - - ) : status === "cli" && other != null ? ( - <> - - CLI - {other.proxyPort != null && ( - :{other.proxyPort} - )} - - ) : status === "recording" || status === "paused" ? ( - <> - - {recording ? "Recording" : "Paused"} - {port != null && :{port}} - - ) : ( - <> - - Not running - - )} - - - + + + + {status === "encrypting" ? ( + <> + + Encrypting + {encrypting} + + ) : status === "cli" && other != null ? ( + <> + + CLI + + ) : status === "recording" || status === "paused" ? ( + <> + + {recording ? "Recording" : "Paused"} + + ) : ( + <> + + Not running + + )} + + + + + {untrusted && ( dropdownRef.current?.open()}> @@ -307,6 +337,17 @@ export function useCopyEnvironment() { } }; } +/** Whether something was just copied, for a moment, and the call that says it was */ +function useCopied(): [boolean, () => void] { + const [copied, setCopied] = useState(false); + useEffect(() => { + if (!copied) return; + const timeout = setTimeout(() => setCopied(false), 1500); + return () => clearTimeout(timeout); + }, [copied]); + return [copied, () => setCopied(true)]; +} + /** Keeps a click on a control inside a menu row from closing the menu, as a row's click does */ function KeepMenuOpen({ children, className }: { children: ReactNode; className?: string }) { return ( diff --git a/apps/yaak-proxy/harness/backend.ts b/apps/yaak-proxy/harness/backend.ts index 4af10973f..97176326a 100644 --- a/apps/yaak-proxy/harness/backend.ts +++ b/apps/yaak-proxy/harness/backend.ts @@ -114,7 +114,19 @@ function answer(cmd: string, payload: unknown): unknown { ], }; case "list_actions": - return { actions: [] }; + // As the engine lists them on macOS + return { + actions: [ + [ + { scope: "global", action: "recording_toggle" }, + { label: "Pause or Resume Recording", defaultHotkey: "Meta+R" }, + ], + [ + { scope: "global", action: "flows_clear" }, + { label: "Clear Traffic", defaultHotkey: "Meta+Shift+K" }, + ], + ], + }; case "list_flows": { const many = Number(new URLSearchParams(location.search).get("many") ?? 0); const all = (many > 0 ? manyFlows(many) : fixtures.map((f) => f.flow)).map((f, i) => ({ diff --git a/apps/yaak-proxy/lib/hotkeys.ts b/apps/yaak-proxy/lib/hotkeys.ts index 06bb9512c..06ccbc8ee 100644 --- a/apps/yaak-proxy/lib/hotkeys.ts +++ b/apps/yaak-proxy/lib/hotkeys.ts @@ -80,25 +80,29 @@ export function useHotkeyContextValue(): HotkeyContextValue { }, [data]); useEffect(() => { + // Heard before anything focused, and kept from it, so an app hotkey wins over the same chord + // bound in an editor, as Cmd+Shift+K deletes a line in the search box's. function onKeyDown(e: KeyboardEvent) { for (const { action, callback, options } of callbacks) { const enable = typeof options.enable === "function" ? options.enable() : options.enable; const binding = bindings.get(action); if (enable === false || binding == null || !matchesEvent(binding.keys, e)) continue; if (!options.allowDefault) e.preventDefault(); + e.stopPropagation(); callback(e); return; } for (const binding of bindings.values()) { if (!matchesEvent(binding.keys, e)) continue; e.preventDefault(); + e.stopPropagation(); void rpc("execute_action", binding.invocation); return; } } - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); + window.addEventListener("keydown", onKeyDown, { capture: true }); + return () => window.removeEventListener("keydown", onKeyDown, { capture: true }); }, [bindings]); return useMemo( diff --git a/crates-proxy/yaak-proxy-lib/src/actions.rs b/crates-proxy/yaak-proxy-lib/src/actions.rs index 4aba47644..ed40a59c9 100644 --- a/crates-proxy/yaak-proxy-lib/src/actions.rs +++ b/crates-proxy/yaak-proxy-lib/src/actions.rs @@ -43,8 +43,9 @@ pub fn all_global_actions() -> Vec<(ActionInvocation, ActionMetadata)> { ( ActionInvocation::Global { action: GlobalAction::FlowsClear }, ActionMetadata { - label: "Clear Flows".into(), - default_hotkey: default_hotkey("Meta+K", "Ctrl+K"), + label: "Clear Traffic".into(), + // Not Cmd+K, which is kept for a command palette. + default_hotkey: default_hotkey("Meta+Shift+K", "Ctrl+Shift+K"), }, ), ]