Move Clear into the status menu on Cmd+Shift+K, and the port into the menu as a copyable address

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_mwaz7asqhsi6jowm committed 2026-11-10 11:06:03 -07:00
1 parent bba92c5a23
commit a4061d5dee
7 files changed
+122 -74

No files matched your search

@@ -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<DropdownRef> }) {
const total = useAtomValue(flowTotalAtom);
const canClear = useCanClearFlows();
const clear = useSetAtom(clearFlowsAtom);
const [error, setError] = useState<string | null>(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 (
<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
/>
);
}
return (
<Dropdown items={items} hotKeyAction="flows_clear">
<IconButton
className="pointer-events-auto"
icon="trash"
title="Clear flows"
tooltip
size="sm"
iconColor="secondary"
/>
<Dropdown
ref={ref}
items={canClear ? items : []}
hotKeyAction={canClear ? "flows_clear" : undefined}
>
<span aria-hidden className="absolute inset-0 pointer-events-none" />
</Dropdown>
);
}
+2 -2
View File
@@ -31,8 +31,8 @@ export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideB
<Keys keys={["↑", "↓"]} />
<span>Pause or resume recording</span>
<Keys keys={["⌘", "R"]} />
<span>Clear flows</span>
<Keys keys={["⌘", "K"]} />
<span>Clear traffic</span>
<Keys keys={["⌘", "⇧", "K"]} />
</div>
</EmptyState>
) : flow.tunnelled ? (
@@ -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() {
</div>
</div>
<div data-tauri-drag-region className="flex items-center justify-end gap-1 pr-1">
<ClearFlowsButton />
<IconButton
className="pointer-events-auto"
icon={flowLayout === "horizontal" ? "columns_2" : "rows_2"}
+75 -34
View File
@@ -12,7 +12,7 @@ import {
import classNames from "classnames";
import { useAtom, useAtomValue } from "jotai";
import type { HTMLAttributes, ReactNode, Ref } from "react";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { useRpcEvent } from "../hooks/useRpcEvent";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { useRpcQuery } from "../hooks/useRpcQuery";
@@ -24,15 +24,19 @@ import { proxyStateAtom, systemProxyEverOnAtom } from "../lib/store";
import { helperPollInterval, recordingLabel, systemProxySwitch } from "../lib/systemProxy";
import { onWindowFocus } from "../lib/tauri";
import { useShowToast } from "../lib/toast";
import { ClearFlowsMenu, useCanClearFlows } from "./ClearFlowsMenu";
/**
* The header's state pill and its menu of four rows: the state, Pause or Resume Recording,
* Override system proxy, and the certificate. The engine runs for as long as the app does, so the
* The header's state pill and its menu: the listening address, the state, Pause or Resume
* Recording, Clear Traffic, Override system proxy, and the certificate. The engine runs for as long as the app does, so the
* pill shows whether flows are being stored. A certificate that is not trusted steps out of the
* menu beside the pill until it is. Everything set once and left alone lives in Settings.
*/
export function ProxyStatus() {
const dropdownRef = useRef<DropdownRef>(null);
const clearRef = useRef<DropdownRef>(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: <span className="font-mono">{address}</span>,
hidden: address == null,
keepOpenOnSelect: true,
rightSlot: copied ? (
<span className="text-xs text-text-subtle">Copied</span>
) : (
<Icon icon="copy" size="sm" className="text-text-subtlest" />
),
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 (
<>
<Dropdown ref={dropdownRef} items={items}>
<Pill color={status === "stopped" ? "warning" : undefined}>
{status === "encrypting" ? (
<>
<LoadingIcon size="xs" />
<span className="text-text">Encrypting</span>
<span className="font-mono text-text-subtle">{encrypting}</span>
</>
) : status === "cli" && other != null ? (
<>
<StateDot state="cli" />
<span className="text-text">CLI</span>
{other.proxyPort != null && (
<span className="font-mono text-text-subtle">:{other.proxyPort}</span>
)}
</>
) : status === "recording" || status === "paused" ? (
<>
<StateDot state={status} />
<span className="text-text">{recording ? "Recording" : "Paused"}</span>
{port != null && <span className="font-mono text-text-subtle">:{port}</span>}
</>
) : (
<>
<Icon icon="alert_triangle" size="xs" />
Not running
</>
)}
<Icon icon="chevron_down" size="xs" className="text-text-subtlest" />
</Pill>
</Dropdown>
<span className="relative flex">
<Dropdown ref={dropdownRef} items={items}>
<Pill color={status === "stopped" ? "warning" : undefined}>
{status === "encrypting" ? (
<>
<LoadingIcon size="xs" />
<span className="text-text">Encrypting</span>
<span className="font-mono text-text-subtle">{encrypting}</span>
</>
) : status === "cli" && other != null ? (
<>
<StateDot state="cli" />
<span className="text-text">CLI</span>
</>
) : status === "recording" || status === "paused" ? (
<>
<StateDot state={status} />
<span className="text-text">{recording ? "Recording" : "Paused"}</span>
</>
) : (
<>
<Icon icon="alert_triangle" size="xs" />
Not running
</>
)}
<Icon icon="chevron_down" size="xs" className="text-text-subtlest" />
</Pill>
</Dropdown>
<ClearFlowsMenu ref={clearRef} />
</span>
{untrusted && (
<Pill color="warning" onClick={() => dropdownRef.current?.open()}>
<Icon icon="shield_off" size="xs" />
@@ -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 (
+13 -1
View File
@@ -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) => ({
+6 -2
View File
@@ -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(