mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
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:
1 parent
bba92c5a23
commit
a4061d5dee
7 files changed
+122
-74
No files matched your search
+23
-31
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user