mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Tint the proxy's header by its status, sharing Yaak's environment tint
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
29a173a07e
commit
bba92c5a23
8 files changed
+195
-34
No files matched your search
@@ -1,10 +1,10 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
|
||||
import { Banner, HeaderSize, HStack, SidebarLayout } from "@yaakapp-internal/ui";
|
||||
import { Banner, HeaderSize, HeaderTint, HStack, SidebarLayout } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import * as m from "motion/react-m";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
getActiveCookieJar,
|
||||
useEnsureActiveCookieJar,
|
||||
@@ -61,12 +61,6 @@ export function Workspace() {
|
||||
const activeEnvironment = useAtomValue(activeEnvironmentAtom);
|
||||
const [floating, setFloating] = useState(false);
|
||||
|
||||
const environmentBgStyle = useMemo(() => {
|
||||
if (activeEnvironment?.color == null) return undefined;
|
||||
const background = `linear-gradient(to right, ${activeEnvironment.color} 15%, transparent 40%)`;
|
||||
return { background };
|
||||
}, [activeEnvironment?.color]);
|
||||
|
||||
if (workspaces.length === 0) {
|
||||
return <RedirectToLatestWorkspace />;
|
||||
}
|
||||
@@ -81,13 +75,7 @@ export function Workspace() {
|
||||
useNativeTitlebar={settings.useNativeTitlebar}
|
||||
interfaceScale={settings.interfaceScale}
|
||||
>
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div style={environmentBgStyle} className="absolute inset-0 opacity-[0.07]" />
|
||||
<div
|
||||
style={environmentBgStyle}
|
||||
className="absolute left-0 right-0 -bottom-px h-px opacity-20"
|
||||
/>
|
||||
</div>
|
||||
<HeaderTint color={activeEnvironment?.color ?? null} />
|
||||
<WorkspaceHeader className="pointer-events-none" floatingSidebar={floating} />
|
||||
</HeaderSize>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
HeaderSize,
|
||||
HeaderTint,
|
||||
IconButton,
|
||||
SidebarLayout,
|
||||
SplitLayout,
|
||||
@@ -13,7 +14,8 @@ import { useLocalStorage } from "react-use";
|
||||
import { flowLayoutAtom } from "../lib/atoms";
|
||||
import { setupIncomplete } from "../lib/firstRun";
|
||||
import { useMenuExportHar } from "../lib/flowActions";
|
||||
import { lockedAtom, noFlowsAtom, systemProxyEverOnAtom } from "../lib/store";
|
||||
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";
|
||||
@@ -43,6 +45,7 @@ export function ProxyLayout() {
|
||||
);
|
||||
const [floating, setFloating] = useState(false);
|
||||
const isHidden = floating ? (floatingSidebarHidden ?? true) : (sidebarHidden ?? false);
|
||||
const tint = statusTintColor(proxyStatusOf(useAtomValue(proxyStateAtom)));
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -60,6 +63,7 @@ export function ProxyLayout() {
|
||||
interfaceScale={1}
|
||||
className="x-theme-appHeader bg-surface"
|
||||
>
|
||||
<HeaderTint color={tint} animate />
|
||||
{/* Yaak's silhouette: the sidebar toggle and the state pill where Yaak has the toggle and
|
||||
the environment, the search where it names the request, then its icons. Only the
|
||||
controls take the pointer: everything around them, the traffic lights' corner
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useRpcQuery } from "../hooks/useRpcQuery";
|
||||
import { helperRegisteredAtAtom } from "../lib/atoms";
|
||||
import { encryptingPercent } from "../lib/encrypting";
|
||||
import { environmentExports } from "../lib/env";
|
||||
import { proxyStatusOf } from "../lib/proxyStatus";
|
||||
import { proxyStateAtom, systemProxyEverOnAtom } from "../lib/store";
|
||||
import { helperPollInterval, recordingLabel, systemProxySwitch } from "../lib/systemProxy";
|
||||
import { onWindowFocus } from "../lib/tauri";
|
||||
@@ -44,25 +45,26 @@ export function ProxyStatus() {
|
||||
const other = proxyState?.otherOwner ?? null;
|
||||
const port = proxyState?.port ?? other?.proxyPort ?? null;
|
||||
const untrusted = caStatus.data != null && !caStatus.data.trusted;
|
||||
// Traffic stored before encryption is being encrypted; the engine starts once it is.
|
||||
const encrypting = !running && other == null ? encryptingPercent(proxyState) : null;
|
||||
// The same status the header's tint takes its colour from
|
||||
const status = proxyStatusOf(proxyState);
|
||||
const encrypting = status === "encrypting" ? encryptingPercent(proxyState) : null;
|
||||
|
||||
const stateRow =
|
||||
encrypting != null ? (
|
||||
status === "encrypting" ? (
|
||||
<MenuRow
|
||||
icon={<LoadingIcon size="sm" className="text-text-subtle" />}
|
||||
title={`Encrypting stored traffic, ${encrypting}`}
|
||||
description="Traffic captured before this version is being encrypted on disk. Recording starts when it's done."
|
||||
/>
|
||||
) : other != null ? (
|
||||
) : status === "cli" && other != null ? (
|
||||
<MenuRow
|
||||
icon={<StateDot state="cli" />}
|
||||
title="Running from the CLI"
|
||||
description={`Started by yaak proxy start, pid ${other.pid}, since ${timeOf(other.startedAt)}.${other.proxyPort == null ? "" : ` Port ${other.proxyPort}.`}`}
|
||||
/>
|
||||
) : running ? (
|
||||
) : status === "recording" || status === "paused" ? (
|
||||
<MenuRow
|
||||
icon={<StateDot state={recording ? "recording" : "paused"} />}
|
||||
icon={<StateDot state={status} />}
|
||||
title={recording ? `Recording on port ${port}` : `Paused on port ${port}`}
|
||||
description={
|
||||
recording
|
||||
@@ -190,20 +192,14 @@ export function ProxyStatus() {
|
||||
return (
|
||||
<>
|
||||
<Dropdown ref={dropdownRef} items={items}>
|
||||
<Pill
|
||||
color={
|
||||
!running && other == null && encrypting == null && proxyState != null
|
||||
? "warning"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{encrypting != null ? (
|
||||
<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>
|
||||
</>
|
||||
) : other != null ? (
|
||||
) : status === "cli" && other != null ? (
|
||||
<>
|
||||
<StateDot state="cli" />
|
||||
<span className="text-text">CLI</span>
|
||||
@@ -211,9 +207,9 @@ export function ProxyStatus() {
|
||||
<span className="font-mono text-text-subtle">:{other.proxyPort}</span>
|
||||
)}
|
||||
</>
|
||||
) : running ? (
|
||||
) : status === "recording" || status === "paused" ? (
|
||||
<>
|
||||
<StateDot state={recording ? "recording" : "paused"} />
|
||||
<StateDot state={status} />
|
||||
<span className="text-text">{recording ? "Recording" : "Paused"}</span>
|
||||
{port != null && <span className="font-mono text-text-subtle">:{port}</span>}
|
||||
</>
|
||||
|
||||
@@ -41,6 +41,31 @@ export const HARNESS_STATE: GetProxyStateResponse = {
|
||||
otherOwner: null,
|
||||
};
|
||||
|
||||
/** The engine's state as `?status=paused`, `stopped`, `failed` or `cli` asks; recording otherwise */
|
||||
function harnessState(status: string | null): GetProxyStateResponse {
|
||||
const stopped = { ...HARNESS_STATE, state: "stopped" as const, port: null };
|
||||
switch (status) {
|
||||
case "paused":
|
||||
return { ...HARNESS_STATE, recording: false };
|
||||
case "stopped":
|
||||
return stopped;
|
||||
case "failed":
|
||||
return { ...stopped, startError: "Port 9090 is in use by another process." };
|
||||
case "cli":
|
||||
return {
|
||||
...stopped,
|
||||
otherOwner: {
|
||||
owner: "cli",
|
||||
pid: 4242,
|
||||
startedAt: new Date().toISOString(),
|
||||
proxyPort: 9090,
|
||||
},
|
||||
};
|
||||
default:
|
||||
return HARNESS_STATE;
|
||||
}
|
||||
}
|
||||
|
||||
function answer(cmd: string, payload: unknown): unknown {
|
||||
const req = (payload ?? {}) as Record<string, unknown>;
|
||||
switch (cmd) {
|
||||
@@ -62,7 +87,7 @@ function answer(cmd: string, payload: unknown): unknown {
|
||||
case "app_icon":
|
||||
return appIconPng;
|
||||
case "get_proxy_state":
|
||||
return HARNESS_STATE;
|
||||
return harnessState(new URLSearchParams(location.search).get("status"));
|
||||
case "get_storage_size":
|
||||
return { bytes: 412 * 1024 * 1024 };
|
||||
case "ca_status":
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { GetProxyStateResponse } from "@yaakapp-internal/proxy-lib";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { proxyStatusOf, statusTintColor } from "./proxyStatus";
|
||||
|
||||
const base: GetProxyStateResponse = {
|
||||
state: "running",
|
||||
port: 9090,
|
||||
configuredPort: 9090,
|
||||
recording: true,
|
||||
systemProxyAtLaunch: true,
|
||||
httpVersion: "auto",
|
||||
captureBodyLimit: 10 * 1024 * 1024,
|
||||
traffic: "keep",
|
||||
keepTrafficBytes: null,
|
||||
startError: null,
|
||||
encrypting: null,
|
||||
locked: null,
|
||||
otherOwner: null,
|
||||
};
|
||||
|
||||
const tint = (patch: Partial<GetProxyStateResponse> | null) =>
|
||||
statusTintColor(proxyStatusOf(patch == null ? null : { ...base, ...patch }));
|
||||
|
||||
const cli: GetProxyStateResponse["otherOwner"] = {
|
||||
owner: "cli",
|
||||
pid: 42,
|
||||
startedAt: "2026-11-10T10:00:00Z",
|
||||
proxyPort: 9091,
|
||||
};
|
||||
|
||||
describe("proxyStatusOf and statusTintColor", () => {
|
||||
test("recording takes the success colour", () => {
|
||||
expect(proxyStatusOf(base)).toBe("recording");
|
||||
expect(tint({})).toBe("var(--success)");
|
||||
});
|
||||
|
||||
test("paused takes the warning colour", () => {
|
||||
expect(proxyStatusOf({ ...base, recording: false })).toBe("paused");
|
||||
expect(tint({ recording: false })).toBe("var(--warning)");
|
||||
});
|
||||
|
||||
test("not running takes the danger colour", () => {
|
||||
expect(proxyStatusOf({ ...base, state: "stopped", port: null })).toBe("stopped");
|
||||
expect(tint({ state: "stopped", port: null })).toBe("var(--danger)");
|
||||
});
|
||||
|
||||
test("failing to start takes the danger colour", () => {
|
||||
expect(tint({ state: "stopped", port: null, startError: "Port 9090 is in use" })).toBe(
|
||||
"var(--danger)",
|
||||
);
|
||||
});
|
||||
|
||||
test("locked traffic takes the danger colour", () => {
|
||||
const locked = { message: "Traffic is locked", keychain: true } as NonNullable<
|
||||
GetProxyStateResponse["locked"]
|
||||
>;
|
||||
expect(tint({ state: "stopped", port: null, locked })).toBe("var(--danger)");
|
||||
});
|
||||
|
||||
test("an engine the CLI owns takes the info colour", () => {
|
||||
expect(proxyStatusOf({ ...base, state: "stopped", port: null, otherOwner: cli })).toBe("cli");
|
||||
expect(tint({ state: "stopped", port: null, otherOwner: cli })).toBe("var(--info)");
|
||||
});
|
||||
|
||||
test("no tint while encrypting or before the state is read", () => {
|
||||
expect(proxyStatusOf({ ...base, state: "stopped", encrypting: 0.4 })).toBe("encrypting");
|
||||
expect(tint({ state: "stopped", encrypting: 0.4 })).toBeNull();
|
||||
expect(tint(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { GetProxyStateResponse } from "@yaakapp-internal/proxy-lib";
|
||||
|
||||
/**
|
||||
* What the engine is doing, as the header shows it: the pill's dot and the header's tint both
|
||||
* read this, so they can't disagree.
|
||||
*/
|
||||
export type ProxyStatusKind = "recording" | "paused" | "cli" | "stopped" | "encrypting";
|
||||
|
||||
/** The status of a proxy state, or null while it hasn't been read yet */
|
||||
export function proxyStatusOf(
|
||||
state: GetProxyStateResponse | null | undefined,
|
||||
): ProxyStatusKind | null {
|
||||
if (state == null) return null;
|
||||
// Traffic stored before encryption is being encrypted; the engine starts once it is.
|
||||
if (state.state !== "running" && state.otherOwner == null && state.encrypting != null) {
|
||||
return "encrypting";
|
||||
}
|
||||
if (state.otherOwner != null) return "cli";
|
||||
if (state.locked != null || state.state !== "running") return "stopped";
|
||||
return state.recording === false ? "paused" : "recording";
|
||||
}
|
||||
|
||||
/** The header's tint for a status, from the theme's colours; null for none */
|
||||
export function statusTintColor(status: ProxyStatusKind | null): string | null {
|
||||
switch (status) {
|
||||
case "recording":
|
||||
return "var(--success)";
|
||||
case "paused":
|
||||
return "var(--warning)";
|
||||
case "stopped":
|
||||
return "var(--danger)";
|
||||
case "cli":
|
||||
return "var(--info)";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import classNames from "classnames";
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
interface HeaderTintProps {
|
||||
/** Any CSS colour, or null for no tint */
|
||||
color: string | null;
|
||||
/** Fade between colours when the colour changes */
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
// Drawn in currentColor so a change of colour can fade: a gradient itself can't transition.
|
||||
const GRADIENT: CSSProperties = {
|
||||
background: "linear-gradient(to right, currentColor 15%, transparent 40%)",
|
||||
};
|
||||
|
||||
/**
|
||||
* A colour wash from a header's left edge, with a line of it along the bottom border, placed
|
||||
* inside a `HeaderSize`. It takes no pointer, so clicks and window drags reach the header.
|
||||
*/
|
||||
export function HeaderTint({ color, animate }: HeaderTintProps) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
style={{ color: isColor(color) ? color : "transparent" }}
|
||||
className={classNames(
|
||||
"absolute inset-0 pointer-events-none",
|
||||
animate && "transition-[color] duration-200",
|
||||
)}
|
||||
>
|
||||
<div style={GRADIENT} className="absolute inset-0 opacity-[0.07]" />
|
||||
<div style={GRADIENT} className="absolute left-0 right-0 -bottom-px h-px opacity-20" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** An unreadable colour draws nothing, rather than falling back to the inherited text colour */
|
||||
function isColor(color: string | null): color is string {
|
||||
if (color == null || color === "") return false;
|
||||
return typeof CSS === "undefined" || CSS.supports("color", color);
|
||||
}
|
||||
@@ -136,6 +136,7 @@ export type { FormattableEditor } from "./components/FormatAction";
|
||||
export { FormatAction, formatEditor } from "./components/FormatAction";
|
||||
export { FormattedError } from "./components/FormattedError";
|
||||
export { HeaderSize } from "./components/HeaderSize";
|
||||
export { HeaderTint } from "./components/HeaderTint";
|
||||
export type { HeadersTableProps } from "./components/HeadersTable";
|
||||
export { HeadersTable } from "./components/HeadersTable";
|
||||
export { Heading } from "./components/Heading";
|
||||
|
||||
Reference in new issue
Block a user