Rescue the proxy GUI slice (#785)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Gregory SchierandClaude Opus 5.5 authored and GitHub committed 2026-10-26 06:38:34 -07:00
1 parent 6132171791
commit 686294939b
50 files changed
+3394 -1596

No files matched your search

Generated
+3
View File
@@ -11755,6 +11755,7 @@ dependencies = [
name = "yaak-proxy-lib"
version = "0.0.0"
dependencies = [
"base64 0.23.1",
"bytes",
"chrono",
"flate2",
@@ -11774,10 +11775,12 @@ dependencies = [
"tokio",
"ts-rs",
"yaak-database",
"yaak-filter",
"yaak-http",
"yaak-models",
"yaak-proxy",
"yaak-rpc",
"yaak-templates",
]
[[package]]
+11 -52
View File
@@ -1,60 +1,19 @@
import { Banner, Button, InlineCode } from "@yaakapp-internal/ui";
import type { ErrorInfo, ReactNode } from "react";
import { Component, useEffect } from "react";
import { ErrorBoundary as BaseErrorBoundary } from "@yaakapp-internal/ui";
import type { ReactNode } from "react";
import { useEffect } from "react";
import { showDialog } from "../lib/dialog";
import RouteError from "./RouteError";
interface ErrorBoundaryProps {
name: string;
children: ReactNode;
export function ErrorBoundary({ name, children }: { name: string; children: ReactNode }) {
return (
<BaseErrorBoundary name={name} onShowError={showErrorDialog}>
{children}
</BaseErrorBoundary>
);
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.warn("Error caught by ErrorBoundary:", error, info);
}
render() {
if (this.state.hasError) {
return (
<Banner color="danger" className="flex items-center gap-2 overflow-auto">
<div>
Error rendering <InlineCode>{this.props.name}</InlineCode> component
</div>
<Button
className="inline-flex"
variant="border"
color="danger"
size="2xs"
onClick={() => {
showDialog({
id: "error-boundary",
render: () => <RouteError error={this.state.error} />,
});
}}
>
Show
</Button>
</Banner>
);
}
return this.props.children;
}
function showErrorDialog(error: unknown) {
showDialog({ id: "error-boundary", render: () => <RouteError error={error} /> });
}
export function ErrorBoundaryTestThrow() {
File diff suppressed because it is too large. Load diff
+1 -45
View File
@@ -1,45 +1 @@
import { HStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { HotkeyAction } from "../../hooks/useHotKey";
import { useFormattedHotkey } from "../../hooks/useHotKey";
interface Props {
action: HotkeyAction | null;
className?: string;
variant?: "text" | "with-bg";
}
export function Hotkey({ action, className, variant }: Props) {
const labelParts = useFormattedHotkey(action);
if (labelParts === null) {
return null;
}
return <HotkeyRaw labelParts={labelParts} className={className} variant={variant} />;
}
interface HotkeyRawProps {
labelParts: string[];
className?: string;
variant?: "text" | "with-bg";
}
export function HotkeyRaw({ labelParts, className, variant }: HotkeyRawProps) {
return (
<HStack
className={classNames(
className,
variant === "with-bg" &&
"rounded-sm bg-surface-highlight px-1 border border-border text-text-subtle",
variant === "text" && "text-text-subtlest",
)}
>
{labelParts.map((char, index) => (
// oxlint-disable-next-line react/no-array-index-key
<div key={index} className="min-w-[1em] text-center">
{char}
</div>
))}
</HStack>
);
}
export { Hotkey, HotkeyRaw } from "@yaakapp-internal/ui";
+2 -77
View File
@@ -1,77 +1,2 @@
import type { Color } from "@yaakapp-internal/plugins";
import type { IconProps } from "@yaakapp-internal/ui";
import { IconButton } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { ReactNode } from "react";
/**
* A single control attached to a labelled separator, rendered between the label
* and the rule.
*
* Declared rather than passed as a node so the separator keeps ownership of the
* things that are easy to get wrong by hand: matching the label's colour, and
* staying out of the rule's way when the label is long.
*/
export interface SeparatorAction {
icon: IconProps["icon"];
/** Tooltip and accessible name. Required — the control is icon-only. */
title: string;
onClick: () => void;
}
interface Props {
orientation?: "horizontal" | "vertical";
dashed?: boolean;
className?: string;
children?: ReactNode;
color?: Color;
action?: SeparatorAction;
}
export function Separator({
color,
className,
dashed,
orientation = "horizontal",
children,
action,
}: Props) {
return (
<div role="presentation" className={classNames(className, "flex items-center w-full")}>
{children && (
<div className="text-sm text-text-subtlest mr-2 whitespace-nowrap">{children}</div>
)}
{action && (
<IconButton
size="2xs"
iconSize="xs"
className="shrink-0 mr-2 -ml-1"
// Forced, because the button itself sets `text-text` at full strength.
iconClassName="text-text-subtlest!"
icon={action.icon}
title={action.title}
onClick={action.onClick}
/>
)}
<div
className={classNames(
"opacity-60",
// Keep a stub of the line visible no matter how long the label is —
// `w-full` alone gets squeezed to nothing by a wide label.
orientation === "horizontal" && "min-w-8",
color == null && "border-border",
color === "primary" && "border-primary",
color === "secondary" && "border-secondary",
color === "success" && "border-success",
color === "notice" && "border-notice",
color === "warning" && "border-warning",
color === "danger" && "border-danger",
color === "info" && "border-info",
dashed && "border-dashed",
orientation === "horizontal" && "w-full h-0 border-t",
orientation === "vertical" && "h-full w-0 border-l",
)}
/>
</div>
);
}
export type { SeparatorAction } from "@yaakapp-internal/ui";
export { Separator } from "@yaakapp-internal/ui";
+1 -41
View File
@@ -1,41 +1 @@
import type { RefObject } from "react";
import { useEffect, useRef } from "react";
/**
* Get notified when a mouse click happens outside the target ref
* @param ref The element to be notified when a mouse click happens outside it
* @param onClickAway
* @param ignored Optional outside element to ignore (useful for dropdown triggers)
*/
export function useClickOutside(
ref: RefObject<HTMLElement | null>,
onClickAway: (event: MouseEvent) => void,
ignored?: RefObject<HTMLElement | null>,
) {
const savedCallback = useRef(onClickAway);
useEffect(() => {
savedCallback.current = onClickAway;
}, [onClickAway]);
useEffect(() => {
const handler = (event: MouseEvent) => {
if (ref.current == null || !(event.target instanceof HTMLElement)) {
return;
}
const isIgnored = ignored?.current?.contains(event.target);
const clickedOutside = !ref.current.contains(event.target);
if (!isIgnored && clickedOutside) {
savedCallback.current(event);
}
};
// NOTE: We're using mousedown instead of click to handle some edge cases like when a context
// menu is open with the ctrl key.
document.addEventListener("mousedown", handler, { capture: true });
document.addEventListener("contextmenu", handler, { capture: true });
return () => {
document.removeEventListener("mousedown", handler);
document.removeEventListener("contextmenu", handler);
};
}, [ignored, ref]);
}
export { useClickOutside } from "@yaakapp-internal/ui";
+43 -84
View File
@@ -1,11 +1,26 @@
import { platform } from "@yaakapp-internal/platform";
import { debounce } from "@yaakapp-internal/lib";
import { settingsAtom } from "@yaakapp-internal/models";
import {
formatHotkey,
type HotkeyContextValue,
type HotKeyOptions,
useFormattedHotkey,
useHotKey,
} from "@yaakapp-internal/ui";
import { atom, useAtomValue } from "jotai";
import { useEffect } from "react";
import { capitalize } from "../lib/capitalize";
import { useEffect, useMemo } from "react";
import { jotaiStore } from "../lib/jotai";
export type { HotKeyOptions };
export { useFormattedHotkey, useHotKey };
declare module "@yaakapp-internal/ui" {
interface HotkeyActionRegistry {
action: HotkeyAction;
}
}
const HOLD_KEYS = ["Shift", "Control", "Command", "Alt", "Meta"];
const SINGLE_WHITELIST = ["Delete", "Enter", "Backspace"];
@@ -188,12 +203,6 @@ export const hotkeyActions: HotkeyAction[] = (
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
});
export type HotKeyOptions = {
enable?: boolean | (() => boolean);
priority?: number;
allowDefault?: boolean;
};
interface Callback {
action: HotkeyAction;
callback: (e: KeyboardEvent) => void;
@@ -210,25 +219,34 @@ const clearCurrentKeysDebounced = debounce(() => {
jotaiStore.set(currentKeysAtom, new Set([]));
}, 5000);
export function useHotKey(
action: HotkeyAction | null,
function registerHotkey(
action: HotkeyAction,
callback: (e: KeyboardEvent) => void,
options: HotKeyOptions = {},
options: HotKeyOptions,
) {
useEffect(() => {
if (action == null) return;
jotaiStore.set(callbacksAtom, (prev) => {
const without = prev.filter((cb) => {
const isTheSame = cb.action === action && cb.options.priority === options.priority;
return !isTheSame;
});
const newCb: Callback = { action, callback, options };
return [...without, newCb];
jotaiStore.set(callbacksAtom, (prev) => {
const without = prev.filter((cb) => {
const isTheSame = cb.action === action && cb.options.priority === options.priority;
return !isTheSame;
});
return () => {
jotaiStore.set(callbacksAtom, (prev) => prev.filter((cb) => cb.callback !== callback));
};
}, [action, callback, options]);
const newCb: Callback = { action, callback, options };
return [...without, newCb];
});
return () => {
jotaiStore.set(callbacksAtom, (prev) => prev.filter((cb) => cb.callback !== callback));
};
}
export function useHotkeyContextValue(): HotkeyContextValue {
const hotkeys = useAtomValue(hotkeysAtom);
return useMemo(
() => ({
os: platform.osType(),
bindingFor: (action: HotkeyAction) => hotkeys[action] ?? null,
register: registerHotkey,
}),
[hotkeys],
);
}
export function useSubscribeHotKeys() {
@@ -330,66 +348,7 @@ export function getHotkeyScope(action: HotkeyAction): string {
}
export function formatHotkeyString(trigger: string): string[] {
const os = platform.osType();
const parts = trigger.split("+");
const labelParts: string[] = [];
for (const p of parts) {
if (os === "macos") {
if (p === "Meta") {
labelParts.push("⌘");
} else if (p === "Shift") {
labelParts.push("⇧");
} else if (p === "Control") {
labelParts.push("⌃");
} else if (p === "Alt") {
labelParts.push("⌥");
} else if (p === "Enter") {
// U+21A9 has an emoji presentation, which Chromium's font fallback picks
// (a blue glyph among monochrome ones). U+FE0E forces the text form.
labelParts.push("↩︎");
} else if (p === "Tab") {
labelParts.push("⇥");
} else if (p === "Backspace") {
labelParts.push("⌫");
} else if (p === "Delete") {
labelParts.push("⌦");
} else if (p === "Minus") {
labelParts.push("-");
} else if (p === "Plus") {
labelParts.push("+");
} else if (p === "Equal") {
labelParts.push("=");
} else if (p === "Space") {
labelParts.push("Space");
} else {
labelParts.push(capitalize(p));
}
} else {
if (p === "Control") {
labelParts.push("Ctrl");
} else if (p === "Space") {
labelParts.push("Space");
} else {
labelParts.push(capitalize(p));
}
}
}
if (os === "macos") {
return labelParts;
}
return [labelParts.join("+")];
}
export function useFormattedHotkey(action: HotkeyAction | null): string[] | null {
const hotkeys = useAtomValue(hotkeysAtom);
const trigger = action != null ? (hotkeys[action]?.[0] ?? null) : null;
if (trigger == null) {
return null;
}
return formatHotkeyString(trigger);
return formatHotkey(trigger, platform.osType());
}
function compareKeys(keysA: string[], keysB: string[]) {
+1 -22
View File
@@ -1,22 +1 @@
import type { ReactNode } from "react";
/**
* Get the text content from a ReactNode
* https://stackoverflow.com/questions/50428910/get-text-content-from-node-in-react
*/
export function getNodeText(node: ReactNode): string {
if (typeof node === "string" || typeof node === "number") {
return String(node);
}
if (Array.isArray(node)) {
return node.map(getNodeText).join("");
}
if (typeof node === "object" && node) {
// oxlint-disable-next-line no-explicit-any
return getNodeText((node as any).props.children);
}
return "";
}
export { getNodeText } from "@yaakapp-internal/ui";
+20 -12
View File
@@ -1,12 +1,14 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { createRootRoute, Outlet } from "@tanstack/react-router";
import { platform } from "@yaakapp-internal/platform";
import { HotkeyProvider } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { Provider as JotaiProvider } from "jotai";
import { LazyMotion, MotionConfig } from "motion/react";
import { lazy, Suspense } from "react";
import { lazy, type ReactNode, Suspense } from "react";
import { GlobalHooks } from "../components/GlobalHooks";
import RouteError from "../components/RouteError";
import { useHotkeyContextValue } from "../hooks/useHotKey";
import { jotaiStore } from "../lib/jotai";
import { queryClient } from "../lib/queryClient";
@@ -27,22 +29,28 @@ function RouteComponent() {
return (
<JotaiProvider store={jotaiStore}>
<QueryClientProvider client={queryClient}>
<LazyMotion strict features={motionFeatures}>
<MotionConfig transition={{ duration: 0.1 }}>
<Suspense>
<Toasts />
<Dialogs />
<ContextMenus />
</Suspense>
<Layout />
<GlobalHooks />
</MotionConfig>
</LazyMotion>
<Hotkeys>
<LazyMotion strict features={motionFeatures}>
<MotionConfig transition={{ duration: 0.1 }}>
<Suspense>
<Toasts />
<Dialogs />
<ContextMenus />
</Suspense>
<Layout />
<GlobalHooks />
</MotionConfig>
</LazyMotion>
</Hotkeys>
</QueryClientProvider>
</JotaiProvider>
);
}
function Hotkeys({ children }: { children: ReactNode }) {
return <HotkeyProvider value={useHotkeyContextValue()}>{children}</HotkeyProvider>;
}
function Layout() {
return (
<div
@@ -1,76 +0,0 @@
import type { Flow } from "@yaakapp-internal/proxy-lib";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
TruncatedWideTableCell,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
interface Props {
exchanges: Flow[];
style?: React.CSSProperties;
className?: string;
}
export function ExchangesTable({ exchanges, style, className }: Props) {
if (exchanges.length === 0) {
return <p className="text-text-subtlest text-sm">No traffic yet</p>;
}
return (
<div className={className} style={style}>
<Table scrollable className="px-2">
<TableHead>
<TableRow>
<TableHeaderCell>Method</TableHeaderCell>
<TableHeaderCell>URL</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Type</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{exchanges.map((ex) => (
<TableRow key={ex.id}>
<TableCell className="font-mono text-2xs">{ex.method}</TableCell>
<TruncatedWideTableCell className="font-mono text-2xs">
{ex.url}
</TruncatedWideTableCell>
<TableCell>
<StatusBadge status={ex.status} error={ex.error} />
</TableCell>
<TableCell className="text-text-subtle text-xs">
{getContentType(ex.contentType)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
function StatusBadge({ status, error }: { status: number | null; error: string | null }) {
if (error) return <span className="text-xs text-danger">Error</span>;
if (status == null) return <span className="text-xs text-text-subtlest">—</span>;
const color =
status >= 500
? "text-danger"
: status >= 400
? "text-warning"
: status >= 300
? "text-notice"
: "text-success";
return <span className={classNames("text-xs font-mono", color)}>{status}</span>;
}
function getContentType(ct: string | null): string {
if (ct == null) return "—";
// Strip parameters (e.g. "; charset=utf-8")
return ct.split(";")[0]?.trim() ?? ct;
}
@@ -0,0 +1,183 @@
import { useQuery } from "@tanstack/react-query";
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow, FlowBody } from "@yaakapp-internal/proxy-lib";
import {
Banner,
CsvViewer,
ImageViewer,
languageFromContentType,
LoadingIcon,
MultipartViewer,
SvgViewer,
TextViewer,
WebPageViewer,
} from "@yaakapp-internal/ui";
import type { CodeEditorProps } from "@yaakapp-internal/ui/editor";
import type { ReactNode } from "react";
import { useCallback, useEffect, useState } from "react";
import {
base64ToBytes,
bytesToText,
filterText,
findHeader,
formatText,
mimeEssence,
} from "../lib/body";
import { rpc } from "../lib/rpc";
interface Props {
flow: Flow;
which: FlowBody;
pretty: boolean;
}
export function useFlowBody(flow: Flow, which: FlowBody, enabled = true) {
return useQuery({
enabled,
queryKey: ["get_flow_body", flow.id, which, flow.updatedAt],
queryFn: async () => {
const res = await rpc("get_flow_body", { flowId: flow.id, which });
return {
bytes: base64ToBytes(res.data),
truncated: res.truncated,
storedSize: res.storedSize,
};
},
});
}
export function FlowBodyViewer({ flow, which, pretty }: Props) {
const body = useFlowBody(flow, which);
if (body.isPending) {
return (
<EmptyState>
<LoadingIcon />
</EmptyState>
);
}
if (body.error) {
return <Banner color="danger">{body.error.message}</Banner>;
}
const { bytes, truncated, storedSize } = body.data;
if (bytes.length === 0) {
return <EmptyState>{which === "request" ? "No request body" : "Empty"}</EmptyState>;
}
const headers = which === "request" ? flow.requestHeaders : flow.responseHeaders;
const contentType = findHeader(headers, "content-type");
const mime = mimeEssence(contentType) ?? "";
const wireSize = which === "request" ? flow.requestSize : flow.responseSize;
const wireTruncated = which === "request" ? flow.requestTruncated : flow.responseTruncated;
let viewer: ReactNode;
if (mime.startsWith("image/svg")) {
viewer = <SvgViewer text={bytesToText(bytes, truncated) ?? ""} />;
} else if (mime.startsWith("image/")) {
viewer = <ImageBody bytes={bytes} mime={mime} />;
} else if (mime.startsWith("multipart/") && pretty) {
const boundary = contentType?.match(/;\s*boundary="?([^";]+)"?/i)?.[1] ?? "unknown";
viewer = (
<MultipartViewer
data={bytes}
boundary={boundary}
idPrefix={`${flow.id}.${which}`}
textViewerProps={{ formatText }}
/>
);
} else if (/csv|tab-separated/.test(mime) && pretty) {
viewer = <CsvViewer text={bytesToText(bytes, truncated)} className="pb-2" />;
} else {
const text = bytesToText(bytes, truncated);
const language = languageFromContentType(contentType, text);
if (text == null) {
viewer = <EmptyState>Binary body, {formatSize(bytes.length)}</EmptyState>;
} else if (language === "html" && pretty && which === "response") {
viewer = <WebPageViewer html={text} baseUrl={flow.url} />;
} else {
viewer = (
<FlowTextViewer flow={flow} which={which} text={text} language={language} pretty={pretty} />
);
}
}
if (!truncated) return viewer;
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)] gap-2">
<Banner color="notice" className="text-sm">
{wireTruncated
? `Showing the first ${formatSize(storedSize)} of ${formatSize(wireSize)}, the capture limit. The app received all of it.`
: `Showing the first ${formatSize(bytes.length)} of the decoded body, the capture limit. The app received all of it.`}
</Banner>
<div className="min-h-0">{viewer}</div>
</div>
);
}
/** A text body, filtered with the syntax for the language detected from it, not just its header */
function FlowTextViewer({
flow,
which,
text,
language,
pretty,
}: Props & { text: string; language: CodeEditorProps["language"] }) {
const runFilter = useCallback(
(expression: string) => filterText(flow.id, which, expression, language),
[flow.id, which, language],
);
return (
<TextViewer
text={text}
language={language}
stateKey={`flow.${flow.id}.${which}`}
pretty={pretty}
className="-mr-2 bg-surface"
formatText={formatText}
filterText={runFilter}
/>
);
}
function ImageBody({ bytes, mime }: { bytes: Uint8Array<ArrayBuffer>; mime: string }) {
const [size, setSize] = useState<{ width: number; height: number } | null>(null);
useEffect(() => {
let cancelled = false;
createImageBitmap(new Blob([bytes], { type: mime })).then(
(bitmap) => {
if (!cancelled) setSize({ width: bitmap.width, height: bitmap.height });
bitmap.close();
},
() => {
if (!cancelled) setSize(null);
},
);
return () => {
cancelled = true;
};
}, [bytes, mime]);
return (
<div className="h-full grid grid-rows-[minmax(0,1fr)_auto] gap-2 pb-2">
<div className="min-h-0 grid place-items-center rounded-lg border border-border-subtle overflow-hidden p-3 bg-[repeating-conic-gradient(var(--color-surface-highlight)_0_25%,transparent_0_50%)] bg-size-[16px_16px]">
<ImageViewer data={bytes.buffer} mimeType={mime} className="object-contain" />
</div>
<div className="text-center text-xs text-text-subtle font-mono">
{size != null && `${size.width} × ${size.height} • `}
{mime} • {formatSize(bytes.length)}
</div>
</div>
);
}
export function EmptyState({ children }: { children: ReactNode }) {
return (
<div className="h-full w-full grid place-items-center text-center text-text-subtlest text-sm px-6">
<div>{children}</div>
</div>
);
}
+292
View File
@@ -0,0 +1,292 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow } from "@yaakapp-internal/proxy-lib";
import {
Banner,
Button,
CountBadge,
Dropdown,
type DropdownItem,
formatMillis,
HStack,
HttpStatusTagRaw,
Icon,
SizeTag,
type TabButtonRenderProps,
type TabItem,
TabContent,
Tabs,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { atom, useAtomValue } from "jotai";
import type { CSSProperties } from "react";
import { useMemo, useState } from "react";
import { selectedFlowIdAtom } from "../lib/atoms";
import { findHeader, mimeEssence } from "../lib/body";
import { statusReason } from "../lib/statusReason";
import { flowsAtom } from "../lib/store";
import { EmptyState, FlowBodyViewer, useFlowBody } from "./FlowBodyViewer";
import { FlowHeaders, FlowQuery, queryParams } from "./FlowHeaders";
const selectedFlowAtom = atom((get) => {
const id = get(selectedFlowIdAtom);
return id == null ? null : (get(flowsAtom).find((f) => f.id === id) ?? null);
});
const TAB_RESPONSE = "response";
const TAB_REQUEST = "request";
const TAB_HEADERS = "headers";
const TAB_QUERY = "query";
export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideBySide: boolean }) {
const flow = useAtomValue(selectedFlowAtom);
return (
<div
style={style}
className={classNames("min-h-0 min-w-0 pb-2", sideBySide ? "pr-2 pt-2" : "px-2")}
>
<div
className={classNames(
"x-theme-responsePane",
"h-full bg-surface rounded-md border border-border-subtle overflow-hidden",
)}
>
{flow == null ? (
<EmptyState>
<div className="grid grid-cols-[auto_auto] gap-x-6 gap-y-2 text-left">
<span>Select a flow</span>
<Keys keys={["↑", "↓"]} />
<span>Start the proxy</span>
<Keys keys={["⌘", "⇧", "P"]} />
<span>Stop the proxy</span>
<Keys keys={["⌘", "⇧", "S"]} />
</div>
</EmptyState>
) : (
<FlowPane flow={flow} />
)}
</div>
</div>
);
}
function FlowPane({ flow }: { flow: Flow }) {
const [pretty, setPretty] = useState(true);
const responseBody = useFlowBody(flow, "response", flow.responseTruncated);
const failed = flow.error != null && flow.status == null;
const mime = mimeEssence(findHeader(flow.responseHeaders, "content-type"));
const queryCount = useMemo(() => queryParams(flow.url).length, [flow.url]);
const tabs = useMemo<TabItem[]>(
() => [
{
value: TAB_RESPONSE,
renderButton: ({ buttonProps, isActive }) => (
<ViewModeButton
buttonProps={buttonProps}
isActive={isActive}
pretty={pretty}
onChangePretty={setPretty}
prettyLabel={mime === "text/html" ? "Preview" : "Response"}
allowRaw={!mime?.startsWith("image/")}
/>
),
},
{
value: TAB_REQUEST,
label: "Request",
rightSlot: flow.requestSize > 0 ? <CountBadge count={true} /> : null,
},
{
value: TAB_HEADERS,
label: "Headers",
rightSlot: (
<CountBadge
count={flow.requestHeaders.length}
count2={flow.responseHeaders.length}
showZero
/>
),
},
{
value: TAB_QUERY,
label: "Query",
rightSlot: <CountBadge count={queryCount} showZero />,
},
],
[
pretty,
mime,
flow.requestSize,
flow.requestHeaders.length,
flow.responseHeaders.length,
queryCount,
],
);
return (
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
<div
className={classNames(
"grid grid-cols-[auto_minmax(4rem,1fr)] items-center h-sm -mb-1.5",
"cursor-default select-none whitespace-nowrap w-full px-3",
"overflow-x-auto font-mono text-sm text-text-subtle hide-scrollbars",
)}
>
<HStack space={2} className="w-full shrink-0">
<HttpStatusTagRaw
showReason
status={flow.status ?? 0}
statusReason={flow.status == null ? null : statusReason(flow.status)}
/>
{flow.elapsedMs != null && (
<>
<span>&bull;</span>
<span
title={
flow.elapsedHeadersMs == null
? undefined
: `HEADERS: ${formatMillis(flow.elapsedHeadersMs)}\nTOTAL: ${formatMillis(flow.elapsedMs)}`
}
>
{formatMillis(flow.elapsedMs)}
</span>
</>
)}
{!failed && (
<>
<span>&bull;</span>
<SizeTag contentLength={flow.responseSize} />
</>
)}
</HStack>
{flow.responseTruncated ? (
<span
className={classNames(
"justify-self-end flex items-center gap-1.5 h-5 px-2.5 ml-3 rounded-full",
"border border-notice/50 text-notice font-sans text-xs",
)}
>
<Icon icon="scissors" size="xs" />
{responseBody.data == null
? "Body cut"
: `First ${formatSize(responseBody.data.storedSize)} kept`}
</span>
) : (
<span />
)}
</div>
<div className="overflow-hidden flex flex-col min-h-0">
{failed && (
<Banner color="danger" className="mx-3 mt-1 shrink-0 font-mono text-sm">
{flow.error}
</Banner>
)}
<Tabs
tabs={tabs}
label="Flow"
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
tabListClassName="mt-0.5 -mb-1.5"
>
<TabContent value={TAB_RESPONSE}>
{failed ? (
<EmptyState>
<div className="text-text-subtle mb-1">No response</div>
<div className="text-xs">
The request never got a response. The Request tab has what the app sent.
</div>
</EmptyState>
) : (
<FlowBodyViewer flow={flow} which="response" pretty={pretty} />
)}
</TabContent>
<TabContent value={TAB_REQUEST}>
<FlowBodyViewer flow={flow} which="request" pretty />
</TabContent>
<TabContent value={TAB_HEADERS}>
<FlowHeaders flow={flow} />
</TabContent>
<TabContent value={TAB_QUERY}>
<FlowQuery flow={flow} />
</TabContent>
</Tabs>
</div>
</div>
);
}
interface ViewModeButtonProps {
buttonProps: TabButtonRenderProps["buttonProps"];
isActive: boolean;
pretty: boolean;
onChangePretty: (pretty: boolean) => void;
prettyLabel: string;
allowRaw: boolean;
}
/** The client's Response tab: a click on the active tab opens its view menu */
function ViewModeButton({
buttonProps,
isActive,
pretty,
onChangePretty,
prettyLabel,
allowRaw,
}: ViewModeButtonProps) {
const showPretty = pretty || !allowRaw;
const items: DropdownItem[] = [
{
label: prettyLabel,
leftSlot: <CheckMark visible={showPretty} />,
onSelect: () => onChangePretty(true),
},
{
label: `${prettyLabel} (Raw)`,
hidden: !allowRaw,
leftSlot: <CheckMark visible={!showPretty} />,
onSelect: () => onChangePretty(false),
},
];
return (
<Dropdown items={items}>
<Button
{...buttonProps}
onClick={(e) => {
if (isActive) return;
e.preventDefault();
buttonProps.onClick?.(e);
}}
rightSlot={
<Icon
size="sm"
icon="chevron_down"
className={classNames("ml-1", isActive ? "text-text-subtle" : "text-text-subtlest")}
/>
}
>
{showPretty ? prettyLabel : "Raw"}
</Button>
</Dropdown>
);
}
function CheckMark({ visible }: { visible: boolean }) {
return <Icon icon={visible ? "check" : "empty"} size="sm" />;
}
function Keys({ keys }: { keys: string[] }) {
return (
<span className="flex gap-1 justify-end">
{keys.map((k) => (
<kbd
key={k}
className="min-w-5 h-5 px-1 grid place-items-center rounded-sm border border-border-subtle font-sans text-xs text-text-subtle"
>
{k}
</kbd>
))}
</span>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow } from "@yaakapp-internal/proxy-lib";
import {
DetailsBanner,
formatMillis,
HeadersTable,
KeyValueRow,
KeyValueRows,
} from "@yaakapp-internal/ui";
import { useMemo } from "react";
import { EmptyState } from "./FlowBodyViewer";
const startedFormat = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3,
timeZoneName: "short",
});
const bytesFormat = new Intl.NumberFormat("en-US");
export function FlowHeaders({ flow }: { flow: Flow }) {
const versions =
flow.upstreamHttpVersion == null
? `${flow.clientHttpVersion} from the app`
: `${flow.clientHttpVersion} from the app, ${flow.upstreamHttpVersion} to the server`;
return (
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
<DetailsBanner defaultOpen storageKey="flow.info" summary={<h2>Info</h2>}>
<KeyValueRows>
<KeyValueRow label="Started">
{startedFormat.format(new Date(`${flow.startedAt}Z`))}
</KeyValueRow>
<KeyValueRow label="URL">{flow.url}</KeyValueRow>
<KeyValueRow label="Remote Address">{flow.remoteAddr ?? <Missing />}</KeyValueRow>
<KeyValueRow label="Version">{versions}</KeyValueRow>
<KeyValueRow label="Timing">
{flow.elapsedMs == null ? (
<Missing />
) : flow.elapsedHeadersMs == null ? (
`${formatMillis(flow.elapsedMs)} in all`
) : (
`${formatMillis(flow.elapsedMs)} in all, headers after ${formatMillis(flow.elapsedHeadersMs)}`
)}
</KeyValueRow>
<KeyValueRow label="Size">
<span>
{formatSize(flow.requestSize)} sent, {formatSize(flow.responseSize)} received{" "}
<span className="text-text-subtlest">
({bytesFormat.format(flow.responseSize)} B)
</span>
</span>
</KeyValueRow>
</KeyValueRows>
</DetailsBanner>
<HeadersTable
title="Request Headers"
headers={flow.requestHeaders}
labelColor="primary"
storageKey="flow.request_headers"
/>
<HeadersTable
defaultOpen
title="Response Headers"
headers={flow.responseHeaders}
labelColor="info"
storageKey="flow.response_headers"
/>
</div>
);
}
export function FlowQuery({ flow }: { flow: Flow }) {
const params = useMemo(() => queryParams(flow.url), [flow.url]);
if (params.length === 0) {
return <EmptyState>No query parameters</EmptyState>;
}
return (
<div className="overflow-auto h-full pb-4">
<KeyValueRows>
{params.map(([name, value], i) => (
// oxlint-disable-next-line react/no-array-index-key -- a name can repeat
<KeyValueRow key={i} label={name} labelColor="primary">
{value}
</KeyValueRow>
))}
</KeyValueRows>
</div>
);
}
export function queryParams(url: string): [string, string][] {
try {
return [...new URL(url).searchParams.entries()];
} catch {
return [];
}
}
function Missing() {
return <span className="text-text-subtlest">--</span>;
}
+281
View File
@@ -0,0 +1,281 @@
import { useVirtualizer } from "@tanstack/react-virtual";
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow } from "@yaakapp-internal/proxy-lib";
import { formatMillis, HttpMethodTagRaw, HttpStatusTagRaw, Icon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtom, useAtomValue } from "jotai";
import type { CSSProperties, KeyboardEvent } from "react";
import { memo, useCallback, useLayoutEffect, useRef, useState } from "react";
import { selectedFlowIdAtom } from "../lib/atoms";
import { shortContentType } from "../lib/body";
import { countNewFlows } from "../lib/follow";
import { flowsAtom } from "../lib/store";
const ROW_HEIGHT = 24;
const BOTTOM_PADDING = 6;
interface Props {
flows: Flow[];
sideBySide: boolean;
style?: CSSProperties;
className?: string;
}
/** Follows new traffic until a flow is selected or the list is scrolled, then holds */
export function FlowList({ flows, sideBySide, style, className }: Props) {
const [selectedId, setSelectedId] = useAtom(selectedFlowIdAtom);
const scrollRef = useRef<HTMLDivElement>(null);
const ignoreScroll = useRef(false);
const [following, setFollowing] = useState(true);
const [heldAt, setHeldAt] = useState<string | null>(null);
// The newest flow across all traffic, not just this list, so a filter change keeps the hold
const allFlows = useAtomValue(flowsAtom);
const newestStartedAt = allFlows[allFlows.length - 1]?.startedAt ?? null;
const virtualizer = useVirtualizer({
count: flows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => ROW_HEIGHT,
getItemKey: (index) => flows[index]?.id ?? index,
paddingEnd: BOTTOM_PADDING,
overscan: 40,
});
const setScrollTop = useCallback((top: number) => {
const el = scrollRef.current;
if (el == null) return;
const max = el.scrollHeight - el.clientHeight;
const next = Math.max(0, Math.min(top, max));
if (Math.abs(el.scrollTop - next) < 1) return;
ignoreScroll.current = true;
el.scrollTop = next;
}, []);
const hold = useCallback(() => {
if (!following) return;
setFollowing(false);
setHeldAt(newestStartedAt);
}, [following, newestStartedAt]);
const follow = useCallback(() => {
setFollowing(true);
setHeldAt(null);
setScrollTop(Number.MAX_SAFE_INTEGER);
}, [setScrollTop]);
useLayoutEffect(() => {
if (following) setScrollTop(Number.MAX_SAFE_INTEGER);
}, [following, flows.length, setScrollTop]);
const handleScroll = useCallback(() => {
if (ignoreScroll.current) {
ignoreScroll.current = false;
return;
}
const el = scrollRef.current;
if (el == null) return;
const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 2;
if (atEnd) {
setFollowing(true);
setHeldAt(null);
} else {
hold();
}
}, [hold]);
const select = useCallback(
(index: number) => {
const flow = flows[index];
if (flow == null) return;
setSelectedId(flow.id);
hold();
const el = scrollRef.current;
if (el == null) return;
const top = index * ROW_HEIGHT;
if (top < el.scrollTop) setScrollTop(top);
else if (top + ROW_HEIGHT > el.scrollTop + el.clientHeight) {
setScrollTop(top + ROW_HEIGHT - el.clientHeight);
}
},
[flows, hold, setSelectedId, setScrollTop],
);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
e.preventDefault();
const index = flows.findIndex((f) => f.id === selectedId);
if (index === -1) select(e.key === "ArrowDown" ? 0 : flows.length - 1);
else select(e.key === "ArrowDown" ? index + 1 : index - 1);
},
[flows, select, selectedId],
);
const newCount = following ? 0 : countNewFlows(flows, heldAt);
const columns = sideBySide
? "grid-cols-[0.9rem_2.6rem_minmax(0,2fr)_minmax(0,3fr)_2.4rem_3.6rem]"
: "grid-cols-[0.9rem_2.6rem_13rem_minmax(0,1fr)_2.4rem_5rem_4.5rem_3.8rem]";
return (
<div
style={style}
className={classNames(className, "grid grid-rows-[auto_minmax(0,1fr)] min-h-0 text-sm")}
>
<div
className={classNames(
columns,
"grid items-center gap-x-3 h-[26px] px-1.5 mx-1.5",
"text-text-subtlest border-b border-surface-highlight",
)}
>
<span />
<span>Method</span>
<span>Host</span>
<span>Path</span>
<span>Status</span>
{!sideBySide && <span>Type</span>}
{!sideBySide && <span className="text-right">Size</span>}
<span className="text-right">Time</span>
</div>
<div className="relative min-h-0">
<div
ref={scrollRef}
role="listbox"
aria-label="Flows"
tabIndex={0}
onScroll={handleScroll}
onKeyDown={handleKeyDown}
className="group/flows h-full overflow-y-auto px-1.5 outline-hidden"
>
{flows.length === 0 ? (
<p className="text-text-subtlest text-sm px-3 py-3">No traffic yet</p>
) : (
<div className="relative" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((item) => {
const flow = flows[item.index];
if (flow == null) return null;
return (
<FlowRow
key={flow.id}
flow={flow}
index={item.index}
start={item.start}
columns={columns}
sideBySide={sideBySide}
selected={flow.id === selectedId}
onSelect={select}
/>
);
})}
</div>
)}
</div>
{newCount > 0 && (
<button
type="button"
onClick={follow}
className={classNames(
"absolute bottom-3 left-1/2 -translate-x-1/2",
"flex items-center gap-1.5 h-6 px-3 rounded-full",
"bg-surface border border-border shadow-lg text-text text-xs",
"hover:bg-surface-highlight",
)}
>
<Icon icon="arrow_down_to_line" size="sm" color="primary" />
{newCount === 1 ? "1 new flow" : `${newCount} new flows`}
</button>
)}
</div>
</div>
);
}
interface RowProps {
flow: Flow;
index: number;
start: number;
columns: string;
sideBySide: boolean;
selected: boolean;
onSelect: (index: number) => void;
}
const FlowRow = memo(function FlowRow({
flow,
index,
start,
columns,
sideBySide,
selected,
onSelect,
}: RowProps) {
const { host, path, query } = splitUrl(flow.url);
const failed = flow.error != null && flow.status == null;
const type = shortContentType(flow.contentType);
return (
<div
role="option"
aria-selected={selected}
onMouseDown={() => onSelect(index)}
style={{ height: ROW_HEIGHT, transform: `translateY(${start}px)` }}
className={classNames(
columns,
"absolute top-0 inset-x-0 grid items-center gap-x-3 px-1.5 rounded-md whitespace-nowrap",
"[&>*]:overflow-hidden [&>*]:text-ellipsis [&>*]:min-w-0",
selected
? "text-text bg-surface-highlight group-focus-within/flows:bg-surface-active"
: index % 2 === 1 && "bg-surface-highlight/30",
)}
>
<span className="flex items-center justify-center">
{failed ? (
<Icon icon="circle_alert" size="xs" color="danger" />
) : flow.responseTruncated ? (
<Icon icon="scissors" size="xs" color="notice" title="Body cut at the capture limit" />
) : null}
</span>
<HttpMethodTagRaw method={flow.method} short colored />
<span className="text-text-subtle">{host}</span>
<span>
{path}
<span className="text-text-subtlest">{query}</span>
</span>
<HttpStatusTagRaw status={flow.status ?? 0} short />
{!sideBySide &&
(failed ? (
<span className="text-danger" title={flow.error ?? undefined}>
{flow.error}
</span>
) : (
<span className="text-text-subtle">
{type ?? <span className="text-text-subtlest">--</span>}
</span>
))}
{!sideBySide && (
<span
className={classNames(
"font-mono text-right",
flow.responseTruncated ? "text-notice" : "text-text-subtle",
)}
>
{failed ? <span className="text-text-subtlest">--</span> : formatSize(flow.responseSize)}
</span>
)}
<span className="font-mono text-right text-text-subtle">
{flow.elapsedMs == null ? "--" : formatMillis(flow.elapsedMs)}
</span>
</div>
);
});
function splitUrl(url: string): { host: string; path: string; query: string } {
try {
const u = new URL(url);
return { host: u.host, path: u.pathname, query: u.search };
} catch {
return { host: "", path: url, query: "" };
}
}
+38 -40
View File
@@ -1,17 +1,20 @@
import { HeaderSize, IconButton, SidebarLayout, SplitLayout } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { useAtom, useAtomValue } from "jotai";
import { useState } from "react";
import { useLocalStorage } from "react-use";
import { useRpcQueryWithEvent } from "../hooks/useRpcQueryWithEvent";
import { flowLayoutAtom } from "../lib/atoms";
import { getOsType } from "../lib/tauri";
import { ActionIconButton } from "./ActionIconButton";
import { ExchangesTable } from "./ExchangesTable";
import { filteredExchangesAtom, Sidebar } from "./Sidebar";
import { FlowDetail } from "./FlowDetail";
import { FlowList } from "./FlowList";
import { ProxyStatus } from "./ProxyStatus";
import { filteredExchangesAtom, selectedSidebarLabelAtom, Sidebar } from "./Sidebar";
export function ProxyLayout() {
const os = getOsType();
const exchanges = useAtomValue(filteredExchangesAtom);
const title = useAtomValue(selectedSidebarLabelAtom);
const [flowLayout, setFlowLayout] = useAtom(flowLayoutAtom);
const [sidebarWidth, setSidebarWidth] = useLocalStorage("sidebar_width", 250);
const [sidebarHidden, setSidebarHidden] = useLocalStorage("sidebar_hidden", false);
const [floatingSidebarHidden, setFloatingSidebarHidden] = useLocalStorage(
@@ -19,14 +22,12 @@ export function ProxyLayout() {
true,
);
const [floating, setFloating] = useState(false);
const { data: proxyState } = useRpcQueryWithEvent("get_proxy_state", {}, "proxy_state_changed");
const isRunning = proxyState?.state === "running";
const isHidden = floating ? (floatingSidebarHidden ?? true) : (sidebarHidden ?? false);
return (
<div
className={classNames(
"h-full w-full grid grid-rows-[auto_1fr]",
"h-full w-full grid grid-rows-[auto_minmax(0,1fr)]",
os === "linux" && "border border-border-subtle",
)}
>
@@ -57,29 +58,25 @@ export function ProxyLayout() {
</div>
<div
data-tauri-drag-region
className="pointer-events-none flex items-center text-sm px-2"
className="pointer-events-none flex items-center justify-center gap-2 text-sm px-2 min-w-0"
>
Yaak Proxy
<span className="truncate">{title}</span>
<span className="text-text-subtlest shrink-0">
{exchanges.length === 1 ? "1 flow" : `${exchanges.length} flows`}
</span>
</div>
<div className="flex items-center gap-1 pr-1 pointer-events-auto">
{isRunning ? (
<>
<span className="text-2xs text-success">Running :9090</span>
<ActionIconButton
action={{ scope: "global", action: "proxy_stop" }}
icon="circle_stop"
iconColor="secondary"
size="sm"
/>
</>
) : (
<ActionIconButton
action={{ scope: "global", action: "proxy_start" }}
icon="circle_play"
iconColor="secondary"
size="sm"
/>
)}
<div className="flex items-center gap-1 pr-1">
<ProxyStatus />
<IconButton
className="pointer-events-auto"
icon={flowLayout === "horizontal" ? "columns_2" : "rows_2"}
title={`Change to ${flowLayout === "horizontal" ? "vertical" : "horizontal"} layout`}
size="sm"
iconColor="secondary"
onClick={() =>
setFlowLayout((prev) => (prev === "horizontal" ? "vertical" : "horizontal"))
}
/>
</div>
</div>
</HeaderSize>
@@ -126,18 +123,19 @@ export function ProxyLayout() {
>
<SplitLayout
storageKey="proxy_detail"
layout="vertical"
defaultRatio={0.4}
firstSlot={({ style }) => (
<ExchangesTable exchanges={exchanges} style={style} className="overflow-auto" />
)}
secondSlot={({ style }) => (
<div
className="gap-1"
layout={flowLayout}
defaultRatio={0.55}
firstSlot={({ style, orientation }) => (
<FlowList
flows={exchanges}
sideBySide={orientation === "horizontal"}
style={style}
className="p-3 text-text-subtlest text-sm border-t border-border-subtle"
>
Select a request to view details
</div>
className="pt-1"
/>
)}
secondSlot={({ style, orientation }) => (
<FlowDetail style={style} sideBySide={orientation === "horizontal"} />
)}
/>
</SidebarLayout>
+177
View File
@@ -0,0 +1,177 @@
import { useQueryClient } from "@tanstack/react-query";
import type { CaStatus } from "@yaakapp-internal/proxy-lib";
import { Button, Dropdown, type DropdownItem, type DropdownRef, Icon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { HTMLAttributes, ReactNode, Ref } from "react";
import { useRef } from "react";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { useRpcQuery } from "../hooks/useRpcQuery";
import { useRpcQueryWithEvent } from "../hooks/useRpcQueryWithEvent";
/** The header's state pill and its menu, with the certificate stepping out when untrusted */
export function ProxyStatus() {
const queryClient = useQueryClient();
const dropdownRef = useRef<DropdownRef>(null);
const { data: proxyState } = useRpcQueryWithEvent("get_proxy_state", {}, "proxy_state_changed");
const caStatus = useRpcQuery("ca_status", {});
const trust = useRpcMutation("ca_trust", {
onSuccess: (status) => queryClient.setQueryData<CaStatus>(["ca_status", {}], status),
});
const execute = useRpcMutation("execute_action");
const running = proxyState?.state === "running";
const port = proxyState?.port;
const untrusted = caStatus.data != null && !caStatus.data.trusted;
const items: DropdownItem[] = [
{
type: "content",
label: (
<MenuRow
icon={<StateDot running={running} />}
title={running && port != null ? `Running on port ${port}` : "Stopped"}
description={
running
? "Listening on 127.0.0.1."
: "Start the proxy to capture traffic from apps pointed at it."
}
/>
),
},
{ type: "separator" },
{
type: "content",
label: caStatus.data?.trusted ? (
<MenuRow
icon={<Icon icon="shield_check" color="success" size="sm" />}
title="Certificate trusted"
description="Yaak Proxy CA, in your login keychain."
/>
) : (
<MenuRow
icon={<Icon icon="shield_off" color="warning" size="sm" />}
title="Certificate not trusted"
description={
trust.error != null ? (
<span className="text-danger">{trust.error.message}</span>
) : trust.isPending ? (
"Waiting for your password in the macOS prompt."
) : (
"So HTTPS reads as plain text. macOS asks for your login password once."
)
}
action={
<Button
size="2xs"
variant="border"
color="primary"
isLoading={trust.isPending}
onClick={(e) => {
e.stopPropagation();
trust.mutate({});
}}
>
{trust.error != null ? "Try again" : "Trust certificate"}
</Button>
}
/>
),
},
{ type: "separator" },
{
label: running ? "Stop Proxy" : "Start Proxy",
icon: running ? "circle_stop" : "circle_play",
hotKeyAction: running ? "proxy_stop" : "proxy_start",
hotKeyLabelOnly: true,
onSelect: () => {
execute.mutate({ scope: "global", action: running ? "proxy_stop" : "proxy_start" });
},
},
];
return (
<>
{untrusted && (
<Pill color="warning" onClick={() => dropdownRef.current?.open()}>
<Icon icon="shield_off" size="xs" />
Certificate not trusted
</Pill>
)}
<Dropdown ref={dropdownRef} items={items}>
<Pill>
{running ? (
<>
<StateDot running />
<span className="text-text">Running</span>
{port != null && <span className="font-mono text-text-subtle">:{port}</span>}
</>
) : (
<>
<StateDot running={false} />
Stopped
</>
)}
<Icon icon="chevron_down" size="xs" className="text-text-subtlest" />
</Pill>
</Dropdown>
</>
);
}
interface PillProps extends HTMLAttributes<HTMLButtonElement> {
color?: "warning";
ref?: Ref<HTMLButtonElement>;
}
function Pill({ children, color, ref, ...props }: PillProps) {
return (
<button
{...props}
ref={ref}
type="button"
className={classNames(
"pointer-events-auto flex items-center gap-1.5 h-[1.45rem] px-2.5 rounded-full border text-xs",
"whitespace-nowrap outline-hidden focus-visible:ring-1 ring-border-focus",
color === "warning"
? "border-warning/50 text-warning hover:bg-warning/10"
: "border-border-subtle text-text-subtle hover:bg-surface-highlight",
)}
>
{children}
</button>
);
}
function StateDot({ running }: { running: boolean }) {
return (
<span
aria-hidden
className={classNames(
"inline-block w-[7px] h-[7px] rounded-full shrink-0",
running ? "bg-success ring-3 ring-success/20" : "border-[1.5px] border-text-subtlest",
)}
/>
);
}
function MenuRow({
icon,
title,
description,
action,
}: {
icon: ReactNode;
title: string;
description: ReactNode;
action?: ReactNode;
}) {
return (
<div className="grid grid-cols-[1rem_minmax(0,1fr)_auto] gap-x-3 items-start px-1.5 py-1">
<span className="h-5 flex items-center justify-center">{icon}</span>
<div>
<div className="text-sm text-text">{title}</div>
<div className="text-xs text-text-subtle mt-0.5">{description}</div>
</div>
<span className="self-center">{action}</span>
</div>
);
}
+9 -1
View File
@@ -50,6 +50,14 @@ export const filteredExchangesAtom = atom((get) => {
return exchanges.filter((ex) => allowedIds.has(ex.id));
});
/** The selected sidebar node's name, for the header */
export const selectedSidebarLabelAtom = atom((get) => {
const selectedId = get(selectedIdsFamily(SIDEBAR_TREE_ID))[0];
if (selectedId?.startsWith("domain:")) return selectedId.slice("domain:".length);
if (selectedId?.startsWith("path:")) return selectedId.slice("path:".length);
return "All traffic";
});
function collectNodes(node: TreeNode<SidebarItem>, map: Map<string, SidebarItem>) {
map.set(node.item.id, node.item);
for (const child of node.children ?? []) {
@@ -204,7 +212,7 @@ export function Sidebar() {
return (
<aside className="x-theme-sidebar bg-surface h-full w-full min-w-0 overflow-y-auto border-r border-border-subtle">
<div className="pt-2 text-xs">
<div className="pt-2">
<Tree
treeId={treeId}
collapsedAtom={collapsedAtom(treeId)}
+1 -1
View File
@@ -1,2 +1,2 @@
// Hardcode font size for now. In the future, this could be configurable.
document.documentElement.style.fontSize = "15px";
document.documentElement.style.fontSize = "14px";
+8
View File
@@ -0,0 +1,8 @@
import type { SplitLayoutLayout } from "@yaakapp-internal/ui";
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
export const selectedFlowIdAtom = atom<string | null>(null);
/** Stacked by default; the header's toggle switches to side by side */
export const flowLayoutAtom = atomWithStorage<SplitLayoutLayout>("flow_layout", "vertical");
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, test } from "vite-plus/test";
import { bytesToText } from "./body";
const encode = (text: string) => new TextEncoder().encode(text);
describe("bytesToText", () => {
test("decodes UTF-8", () => {
expect(bytesToText(encode("abc€"))).toBe("abc€");
});
test("rejects a body that is not UTF-8", () => {
expect(bytesToText(new Uint8Array([0xff, 0xfe, 0x00]))).toBeNull();
});
test("keeps a truncated body cut partway through a character", () => {
const cut = encode("abc€").subarray(0, 5);
expect(bytesToText(cut)).toBeNull();
expect(bytesToText(cut, true)).toBe("abc");
});
});
+87
View File
@@ -0,0 +1,87 @@
import type { FilterLanguage, FlowBody } from "@yaakapp-internal/proxy-lib";
import type { CodeEditorProps } from "@yaakapp-internal/ui/editor";
import vkBeautify from "vkbeautify";
import { rpc } from "./rpc";
export function base64ToBytes(data: string): Uint8Array<ArrayBuffer> {
const binary = atob(data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
/**
* The text of a body, or null when it isn't valid UTF-8. A truncated body may end partway
* through a character, so up to three trailing bytes are dropped before giving up on it.
*/
export function bytesToText(bytes: Uint8Array, truncated = false): string | null {
const decoder = new TextDecoder("utf-8", { fatal: true });
const maxTrim = truncated ? Math.min(3, bytes.length) : 0;
for (let trim = 0; trim <= maxTrim; trim++) {
try {
return decoder.decode(bytes.subarray(0, bytes.length - trim));
} catch {
// Try again without the last byte
}
}
return null;
}
export async function formatText(
text: string,
language: CodeEditorProps["language"],
): Promise<string> {
if (text === "") return text;
try {
if (language === "json") return await rpc("format_body", { text });
if (language === "xml" || language === "html") return vkBeautify.xml(text, " ");
} catch (err) {
console.warn("Failed to format body", err);
}
return text;
}
/** The filter syntax for a body the viewer detected as `language`: XPath for XML and HTML */
export function filterLanguage(language: CodeEditorProps["language"]): FilterLanguage | undefined {
if (language === "json") return "jsonpath";
if (language === "xml" || language === "html") return "xpath";
return undefined;
}
export async function filterText(
flowId: string,
which: FlowBody,
expression: string,
language: CodeEditorProps["language"],
): Promise<string> {
const result = await rpc("filter_body", {
flowId,
which,
expression,
language: filterLanguage(language),
});
if (result.error != null) throw new Error(result.error);
return result.content;
}
/** The media type without parameters, lowercased: `application/json` */
export function mimeEssence(contentType: string | null): string | null {
const essence = contentType?.split(";")[0]?.trim().toLowerCase();
return essence ? essence : null;
}
/** What the flow list shows for a content type: `json` for `application/problem+json` */
export function shortContentType(contentType: string | null): string | null {
const essence = mimeEssence(contentType);
if (essence == null) return null;
const subtype = essence.split("/")[1] ?? essence;
const suffix = subtype.split("+").pop() ?? subtype;
return suffix.replace(/^x-/, "");
}
export function findHeader(headers: { name: string; value: string }[], name: string) {
const lower = name.toLowerCase();
return headers.find((h) => h.name.toLowerCase() === lower)?.value ?? null;
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, test } from "vite-plus/test";
import { countNewFlows } from "./follow";
const at = (...times: string[]) => times.map((t) => ({ startedAt: `2026-10-26T10:00:${t}` }));
describe("countNewFlows", () => {
test("counts flows that started after the hold", () => {
expect(countNewFlows(at("01", "02", "03", "04"), "2026-10-26T10:00:02")).toBe(2);
});
test("counts every flow when there was no traffic at the hold", () => {
expect(countNewFlows(at("01", "02"), null)).toBe(2);
});
test("keeps counting after a filter hides the flow the list was held on", () => {
// Held while the newest flow overall started at :03, then filtered to a view without it
expect(countNewFlows(at("01", "05", "06"), "2026-10-26T10:00:03")).toBe(2);
});
test("counts nothing when no flow in the view is new", () => {
expect(countNewFlows(at("01", "02"), "2026-10-26T10:00:03")).toBe(0);
});
test("compares start times with and without fractional seconds", () => {
expect(countNewFlows(at("03.5", "04"), "2026-10-26T10:00:03")).toBe(2);
expect(countNewFlows(at("03", "03.25"), "2026-10-26T10:00:03.2")).toBe(1);
});
});
+17
View File
@@ -0,0 +1,17 @@
import type { Flow } from "@yaakapp-internal/proxy-lib";
/**
* Flows in the list that started after the list was held. `heldAt` is the start of the newest
* flow across all traffic at that moment, or null when there was none, so it still holds when
* the sidebar filter changes what the list shows. Expects flows oldest first, like `flowsAtom`.
*/
export function countNewFlows(flows: Pick<Flow, "startedAt">[], heldAt: string | null): number {
if (heldAt == null) return flows.length;
let count = 0;
for (let i = flows.length - 1; i >= 0; i--) {
const flow = flows[i];
if (flow == null || flow.startedAt <= heldAt) break;
count++;
}
return count;
}
+85 -33
View File
@@ -1,14 +1,44 @@
import type { ActionInvocation, ActionMetadata } from "@yaakapp-internal/proxy-lib";
import type { ActionInvocation, GlobalAction } from "@yaakapp-internal/proxy-lib";
import type { HotkeyContextValue, HotKeyOptions } from "@yaakapp-internal/ui";
import { useEffect, useMemo } from "react";
import { useRpcQuery } from "../hooks/useRpcQuery";
import { rpc } from "./rpc";
import { getOsType } from "./tauri";
type ActionBinding = {
invocation: ActionInvocation;
meta: ActionMetadata;
keys: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean };
};
declare module "@yaakapp-internal/ui" {
interface HotkeyActionRegistry {
action: GlobalAction;
}
}
type Keys = { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean };
type Binding = { invocation: ActionInvocation; hotkey: string; keys: Keys };
interface Callback {
action: GlobalAction;
callback: (e: KeyboardEvent) => void;
options: HotKeyOptions;
}
let callbacks: Callback[] = [];
function register(
action: GlobalAction,
callback: (e: KeyboardEvent) => void,
options: HotKeyOptions,
) {
callbacks = [
...callbacks.filter((cb) => cb.action !== action || cb.options.priority !== options.priority),
{ action, callback, options },
].sort((a, b) => (b.options.priority ?? 0) - (a.options.priority ?? 0));
return () => {
callbacks = callbacks.filter((cb) => cb.callback !== callback);
};
}
/** Parse a hotkey string like "Ctrl+Shift+P" into its parts. */
function parseHotkey(hotkey: string): ActionBinding["keys"] {
function parseHotkey(hotkey: string): Keys {
const parts = hotkey.split("+").map((p) => p.trim().toLowerCase());
return {
ctrl: parts.includes("ctrl") || parts.includes("control"),
@@ -22,42 +52,64 @@ function parseHotkey(hotkey: string): ActionBinding["keys"] {
};
}
function matchesEvent(binding: ActionBinding["keys"], e: KeyboardEvent): boolean {
function matchesEvent(keys: Keys, e: KeyboardEvent): boolean {
return (
e.ctrlKey === binding.ctrl &&
e.shiftKey === binding.shift &&
e.altKey === binding.alt &&
e.metaKey === binding.meta &&
e.key.toLowerCase() === binding.key
e.ctrlKey === keys.ctrl &&
e.shiftKey === keys.shift &&
e.altKey === keys.alt &&
e.metaKey === keys.meta &&
e.key.toLowerCase() === keys.key
);
}
/** Fetch all actions from Rust and register a global keydown listener. */
export async function initHotkeys(): Promise<() => void> {
const { actions } = await rpc("list_actions", {});
/**
* Hotkeys from the actions' metadata: a key runs the component bound to its action if one is,
* and the action itself otherwise.
*/
export function useHotkeyContextValue(): HotkeyContextValue {
const { data } = useRpcQuery("list_actions", {});
const bindings: ActionBinding[] = actions
.filter(
// oxlint-disable-next-line no-redundant-type-constituents -- ActionMetadata resolves at runtime
(entry): entry is [ActionInvocation, ActionMetadata & { defaultHotkey: string }] =>
entry[1].defaultHotkey != null,
)
.map(([invocation, meta]) => ({
invocation,
meta,
keys: parseHotkey(meta.defaultHotkey),
}));
const bindings = useMemo(() => {
const map = new Map<GlobalAction, Binding>();
for (const [invocation, meta] of data?.actions ?? []) {
if (meta.defaultHotkey == null) continue;
const hotkey = meta.defaultHotkey.replace(/\bCtrl\b/, "Control");
map.set(invocation.action, { invocation, hotkey, keys: parseHotkey(hotkey) });
}
return map;
}, [data]);
function onKeyDown(e: KeyboardEvent) {
for (const binding of bindings) {
if (matchesEvent(binding.keys, e)) {
useEffect(() => {
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();
callback(e);
return;
}
for (const binding of bindings.values()) {
if (!matchesEvent(binding.keys, e)) continue;
e.preventDefault();
void rpc("execute_action", binding.invocation);
return;
}
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [bindings]);
return useMemo(
() => ({
os: getOsType(),
bindingFor: (action) => {
const hotkey = bindings.get(action)?.hotkey;
return hotkey == null ? null : [hotkey];
},
register,
}),
[bindings],
);
}
+6 -1
View File
@@ -4,8 +4,13 @@ import { command, subscribe } from "./tauri";
export type Req<K extends keyof RpcSchema> = RpcSchema[K][0];
export type Res<K extends keyof RpcSchema> = RpcSchema[K][1];
/** Call a backend command. A failure rejects with an `Error`, never the bare string Tauri gives. */
export async function rpc<K extends keyof RpcSchema>(cmd: K, payload: Req<K>): Promise<Res<K>> {
return command<Res<K>>("rpc", { cmd, payload });
try {
return await command<Res<K>>("rpc", { cmd, payload });
} catch (err) {
throw err instanceof Error ? err : new Error(String(err));
}
}
/** Subscribe to a backend event. Returns an unsubscribe function. */
+41
View File
@@ -0,0 +1,41 @@
const reasons: Record<number, string> = {
100: "Continue",
101: "Switching Protocols",
200: "OK",
201: "Created",
202: "Accepted",
204: "No Content",
206: "Partial Content",
301: "Moved Permanently",
302: "Found",
303: "See Other",
304: "Not Modified",
307: "Temporary Redirect",
308: "Permanent Redirect",
400: "Bad Request",
401: "Unauthorized",
402: "Payment Required",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
406: "Not Acceptable",
408: "Request Timeout",
409: "Conflict",
410: "Gone",
412: "Precondition Failed",
413: "Content Too Large",
415: "Unsupported Media Type",
418: "I'm a teapot",
422: "Unprocessable Content",
429: "Too Many Requests",
500: "Internal Server Error",
501: "Not Implemented",
502: "Bad Gateway",
503: "Service Unavailable",
504: "Gateway Timeout",
};
/** The engine records the status code only, so the reason is the standard one */
export function statusReason(status: number): string | null {
return reasons[status] ?? null;
}
+2 -1
View File
@@ -8,4 +8,5 @@ type ProxyModels = {
export const { dataAtom, applyChange, replaceAll, listAtom, orderedListAtom } =
createModelStore<ProxyModels>(["flow"]);
export const flowsAtom = orderedListAtom("flow", "startedAt", "desc");
/** Oldest first, so new traffic arrives at the end of the list */
export const flowsAtom = orderedListAtom("flow", "startedAt", "asc");
+1
View File
@@ -12,6 +12,7 @@
:root {
--font-family-interface: "";
--font-family-editor: "";
--editor-font-size: 12px;
}
:root {
+14 -10
View File
@@ -1,11 +1,12 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createStore, Provider } from "jotai";
import { LazyMotion, MotionConfig } from "motion/react";
import { StrictMode } from "react";
import { HotkeyProvider } from "@yaakapp-internal/ui";
import { type ReactNode, StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ProxyLayout } from "./components/ProxyLayout";
import { useHotkeyContextValue } from "./lib/hotkeys";
import { listen, rpc } from "./lib/rpc";
import { initHotkeys } from "./lib/hotkeys";
import { applyChange, dataAtom, replaceAll } from "./lib/store";
import "./main.css";
@@ -17,25 +18,28 @@ void rpc("list_models", {}).then((res) => {
jotaiStore.set(dataAtom, (prev) => replaceAll(prev, "flow", res.flows));
});
// Register hotkeys from action metadata
void initHotkeys();
// Subscribe to model change events from the backend
void listen("model_write", (payload) => {
jotaiStore.set(dataAtom, (prev) => applyChange(prev, "flow", payload.model, payload.change));
});
function Hotkeys({ children }: { children: ReactNode }) {
return <HotkeyProvider value={useHotkeyContextValue()}>{children}</HotkeyProvider>;
}
const motionFeatures = () => import("framer-motion").then((mod) => mod.domAnimation);
createRoot(document.getElementById("root") as HTMLElement).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<Provider store={jotaiStore}>
<LazyMotion strict features={motionFeatures}>
<MotionConfig transition={{ duration: 0.1 }}>
<ProxyLayout />
</MotionConfig>
</LazyMotion>
<Hotkeys>
<LazyMotion strict features={motionFeatures}>
<MotionConfig transition={{ duration: 0.1 }}>
<ProxyLayout />
</MotionConfig>
</LazyMotion>
</Hotkeys>
</Provider>
</QueryClientProvider>
</StrictMode>,
+1
View File
@@ -0,0 +1 @@
declare module "vkbeautify";
+3 -1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@tanstack/react-query": "^5.104.2",
"@tanstack/react-virtual": "^3.14.17",
"@tauri-apps/api": "^2.12.3",
"@tauri-apps/plugin-os": "^2.4.0",
"@yaakapp-internal/model-store": "^1.0.0",
@@ -21,7 +22,8 @@
"jotai-family": "^1.0.1",
"motion": "^14.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"vkbeautify": "^0.99.3"
},
"devDependencies": {
"@rolldown/plugin-babel": "^0.2.3",
+5 -1
View File
@@ -6,6 +6,7 @@ authors = ["Gregory Schier"]
publish = false
[dependencies]
base64 = "0.23.1"
chrono = { workspace = true, features = ["serde"] }
log = { workspace = true }
include_dir = "0.7"
@@ -15,15 +16,18 @@ rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["io-util"] }
tokio = { workspace = true, features = ["io-util", "process"] }
ts-rs = { workspace = true, features = ["chrono-impl"] }
yaak-database = { workspace = true }
yaak-filter = { workspace = true }
yaak-http = { workspace = true }
yaak-models = { workspace = true }
yaak-proxy = { workspace = true }
yaak-rpc = { workspace = true }
yaak-templates = { workspace = true }
[dev-dependencies]
base64 = "0.23.1"
bytes = "1"
flate2 = "1"
http-body-util = "0.1"
+2
View File
@@ -42,6 +42,8 @@ elapsedHeadersMs: number | null,
*/
elapsedMs: number | null, error: string | null, };
export type FlowBody = "request" | "response";
export type ModelPayload = { model: Flow, change: ModelChangeEvent, };
export type ProxyHeader = { name: string, value: string, };
+53 -3
View File
@@ -1,13 +1,63 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Flow, ModelPayload } from "./gen_models";
import type { Flow, FlowBody, ModelPayload } from "./gen_models";
export type ActionInvocation = { "scope": "global", action: GlobalAction, };
export type ActionMetadata = { label: string, defaultHotkey: string | null, };
export type CaStatus = {
/**
* macOS trusts this app's CA, so HTTPS flows can be decrypted.
*/
trusted: boolean,
/**
* A certificate named "Yaak Proxy CA" is in a keychain: this one, or an older one.
*/
installed: boolean, certPath: string, };
export type CaStatusRequest = Record<string, never>;
export type CaTrustRequest = Record<string, never>;
export type FilterBodyRequest = { flowId: string, which: FlowBody,
/**
* JSONPath, or XPath for an XML or HTML body.
*/
expression: string,
/**
* The language the viewer detected from the body. Without it, the Content-Type decides.
*/
language?: FilterLanguage, };
export type FilterBodyResponse = { content: string, error: string | null, };
export type FilterLanguage = "jsonpath" | "xpath";
export type FormatBodyRequest = { text: string, };
export type GetFlowBodyRequest = { flowId: string, which: FlowBody, };
export type GetFlowBodyResponse = {
/**
* The body decoded with its Content-Encoding, at most the capture limit of it, in base64.
*/
data: string,
/**
* The body is incomplete: cut on the wire, or decoded past the capture limit.
*/
truncated: boolean,
/**
* Bytes stored for the body, as they were on the wire.
*/
storedSize: number, };
export type GetProxyStateRequest = Record<string, never>;
export type GetProxyStateResponse = { state: ProxyState, };
export type GetProxyStateResponse = { state: ProxyState,
/**
* The port the engine is bound to, while it runs.
*/
port: number | null, };
export type GlobalAction = "proxy_start" | "proxy_stop";
@@ -25,4 +75,4 @@ export type ProxyStatePayload = { state: ProxyState, };
export type RpcEventSchema = { model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, };
export type RpcSchema = { execute_action: [ActionInvocation, boolean], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_models: [ListModelsRequest, ListModelsResponse], };
export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_models: [ListModelsRequest, ListModelsResponse], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], };
+34 -11
View File
@@ -15,23 +15,46 @@ pub fn read_raw_body(
Ok(chunks.into_iter().flat_map(|c| c.data).collect())
}
/// A flow's body decoded with its Content-Encoding. A truncated body decodes as far as its
/// stored bytes go.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecodedBody {
pub data: Vec<u8>,
/// Bytes stored for the body, as they were on the wire.
pub stored_size: usize,
/// The body is incomplete: cut on the wire at the capture limit, or decoded past `limit`.
pub truncated: bool,
}
/// A flow's body decoded with its Content-Encoding, at most `limit` bytes of it. A body cut
/// at the capture limit decodes as far as its stored bytes go.
pub async fn read_body(
blobs: &BlobManager,
flow: &Flow,
body: FlowBody,
) -> Result<Vec<u8>, String> {
limit: usize,
) -> Result<DecodedBody, String> {
let raw = read_raw_body(blobs, &flow.id, body)?;
let encoding = ContentEncoding::from_header(find_header(flow.headers(body), "content-encoding"));
if encoding == ContentEncoding::Identity {
return Ok(raw);
let stored_size = raw.len();
let wire_truncated = flow.truncated(body);
let encoding =
ContentEncoding::from_header(find_header(flow.headers(body), "content-encoding"));
// A HEAD or 304 response names its encoding but has no body to decode.
if raw.is_empty() || encoding == ContentEncoding::Identity {
let truncated = wire_truncated || raw.len() > limit;
let mut data = raw;
data.truncate(limit);
return Ok(DecodedBody { data, stored_size, truncated });
}
let mut decoded = Vec::new();
match streaming_decoder(Cursor::new(raw), encoding).read_to_end(&mut decoded).await {
Ok(_) => Ok(decoded),
Err(_) if flow.truncated(body) => Ok(decoded),
Err(e) => Err(format!("Failed to decode {encoding:?} body: {e}")),
// One byte past the limit tells a body that fills it from one that overflows it.
let take = u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1);
let mut data = Vec::new();
let mut decoder = streaming_decoder(Cursor::new(raw), encoding).take(take);
match decoder.read_to_end(&mut data).await {
Ok(_) => {}
Err(_) if wire_truncated => {}
Err(e) => return Err(format!("Failed to decode {encoding:?} body: {e}")),
}
let truncated = wire_truncated || data.len() > limit;
data.truncate(limit);
Ok(DecodedBody { data, stored_size, truncated })
}
+75
View File
@@ -0,0 +1,75 @@
use serde::Serialize;
use std::path::Path;
use ts_rs::TS;
#[derive(Debug, Clone, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CaStatus {
/// macOS trusts this app's CA, so HTTPS flows can be decrypted.
pub trusted: bool,
/// A certificate named "Yaak Proxy CA" is in a keychain: this one, or an older one.
pub installed: bool,
pub cert_path: String,
}
#[cfg(target_os = "macos")]
const CA_NAME: &str = "Yaak Proxy CA";
#[cfg(target_os = "macos")]
const SECURITY: &str = "/usr/bin/security";
#[cfg(target_os = "macos")]
pub async fn status(cert_path: &Path) -> Result<CaStatus, String> {
let installed = security(&["find-certificate", "-c", CA_NAME]).await?.status.success();
let path = cert_path.to_string_lossy();
// verify-cert fails both for a missing file and for an older CA trusted under the same name.
let trusted = installed
&& cert_path.exists()
&& security(&["verify-cert", "-c", &path, "-L", "-q"]).await?.status.success();
Ok(CaStatus { trusted, installed, cert_path: path.into_owned() })
}
/// Adds the CA to the login keychain as a trusted root. macOS asks for the user's password;
/// the app never elevates.
#[cfg(target_os = "macos")]
pub async fn trust(cert_path: &Path) -> Result<(), String> {
let home = std::env::var("HOME").map_err(|_| "HOME is not set".to_string())?;
let keychain = Path::new(&home).join("Library/Keychains/login.keychain-db");
let output = security(&[
"add-trusted-cert",
"-r",
"trustRoot",
"-k",
&keychain.to_string_lossy(),
&cert_path.to_string_lossy(),
])
.await?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(if stderr.is_empty() { format!("security exited with {}", output.status) } else { stderr })
}
#[cfg(target_os = "macos")]
async fn security(args: &[&str]) -> Result<std::process::Output, String> {
tokio::process::Command::new(SECURITY)
.args(args)
.output()
.await
.map_err(|e| format!("Failed to run {SECURITY}: {e}"))
}
#[cfg(not(target_os = "macos"))]
pub async fn status(cert_path: &Path) -> Result<CaStatus, String> {
Ok(CaStatus {
trusted: false,
installed: false,
cert_path: cert_path.to_string_lossy().into_owned(),
})
}
#[cfg(not(target_os = "macos"))]
pub async fn trust(_cert_path: &Path) -> Result<(), String> {
Err("Trusting the certificate from the app is only supported on macOS".to_string())
}
+147 -4
View File
@@ -1,13 +1,17 @@
pub mod actions;
pub mod bodies;
pub mod ca;
pub mod db;
pub mod models;
pub mod settings;
use crate::actions::{ActionInvocation, ActionMetadata, GlobalAction};
use crate::ca::CaStatus;
use crate::db::ProxyQueryManager;
use crate::models::{Flow, FlowBody, ModelPayload, ProxyHeader, find_header};
use crate::models::{Flow, FlowBody, FlowIden, ModelPayload, ProxyHeader, find_header};
use crate::settings::ProxySettings;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use chrono::{DateTime, Utc};
use log::warn;
use serde::{Deserialize, Serialize};
@@ -89,6 +93,10 @@ impl ProxyCtx {
pub fn port(&self) -> Option<u16> {
self.handle.lock().ok()?.as_ref().map(|h| h.port)
}
pub fn ca_dir(&self) -> PathBuf {
self.data_dir.join("ca")
}
}
// -- Proxy state --
@@ -136,10 +144,76 @@ pub struct GetProxyStateRequest {}
#[derive(Serialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
#[serde(rename_all = "camelCase")]
pub struct GetProxyStateResponse {
pub state: ProxyState,
/// The port the engine is bound to, while it runs.
pub port: Option<u16>,
}
#[derive(Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
#[serde(rename_all = "camelCase")]
pub struct GetFlowBodyRequest {
pub flow_id: String,
pub which: FlowBody,
}
#[derive(Serialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
#[serde(rename_all = "camelCase")]
pub struct GetFlowBodyResponse {
/// The body decoded with its Content-Encoding, at most the capture limit of it, in base64.
pub data: String,
/// The body is incomplete: cut on the wire, or decoded past the capture limit.
pub truncated: bool,
/// Bytes stored for the body, as they were on the wire.
#[ts(type = "number")]
pub stored_size: usize,
}
#[derive(Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct FormatBodyRequest {
pub text: String,
}
#[derive(Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
#[serde(rename_all = "camelCase")]
pub struct FilterBodyRequest {
pub flow_id: String,
pub which: FlowBody,
/// JSONPath, or XPath for an XML or HTML body.
pub expression: String,
/// The language the viewer detected from the body. Without it, the Content-Type decides.
#[ts(optional)]
pub language: Option<FilterLanguage>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
#[serde(rename_all = "lowercase")]
pub enum FilterLanguage {
JsonPath,
XPath,
}
#[derive(Serialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct FilterBodyResponse {
pub content: String,
pub error: Option<String>,
}
#[derive(Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CaStatusRequest {}
#[derive(Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CaTrustRequest {}
// -- Handlers --
fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result<bool, RpcError> {
@@ -156,9 +230,71 @@ fn get_proxy_state(
ctx: &ProxyCtx,
_req: GetProxyStateRequest,
) -> Result<GetProxyStateResponse, RpcError> {
let handle = ctx.handle.lock().map_err(|_| RpcError { message: "lock poisoned".into() })?;
let state = if handle.is_some() { ProxyState::Running } else { ProxyState::Stopped };
Ok(GetProxyStateResponse { state })
let port = ctx.port();
let state = if port.is_some() { ProxyState::Running } else { ProxyState::Stopped };
Ok(GetProxyStateResponse { state, port })
}
fn format_body(_ctx: &ProxyCtx, req: FormatBodyRequest) -> Result<String, RpcError> {
Ok(yaak_templates::format_json::format_json(&req.text, " "))
}
async fn get_flow_body(
ctx: ProxyCtx,
req: GetFlowBodyRequest,
) -> Result<GetFlowBodyResponse, RpcError> {
let flow = find_flow(&ctx, &req.flow_id)?;
let limit = settings(&ctx)?.capture_body_limit;
let body = bodies::read_body(&ctx.blobs, &flow, req.which, limit).await.map_err(rpc_error)?;
Ok(GetFlowBodyResponse {
data: BASE64.encode(&body.data),
truncated: body.truncated,
stored_size: body.stored_size,
})
}
async fn filter_body(
ctx: ProxyCtx,
req: FilterBodyRequest,
) -> Result<FilterBodyResponse, RpcError> {
let flow = find_flow(&ctx, &req.flow_id)?;
let limit = settings(&ctx)?.capture_body_limit;
let body = bodies::read_body(&ctx.blobs, &flow, req.which, limit).await.map_err(rpc_error)?;
let text = String::from_utf8_lossy(&body.data);
let language = match req.language {
Some(FilterLanguage::JsonPath) => yaak_filter::Language::JsonPath,
Some(FilterLanguage::XPath) => yaak_filter::Language::XPath,
None => {
let content_type = find_header(flow.headers(req.which), "content-type");
yaak_filter::Language::for_content_type(content_type.unwrap_or_default())
}
};
let result = yaak_filter::filter(language, &text, &req.expression);
Ok(FilterBodyResponse { content: result.content, error: result.error })
}
async fn ca_status(ctx: ProxyCtx, _req: CaStatusRequest) -> Result<CaStatus, RpcError> {
ca::status(&ctx.ca_dir().join("ca.pem")).await.map_err(rpc_error)
}
async fn ca_trust(ctx: ProxyCtx, _req: CaTrustRequest) -> Result<CaStatus, RpcError> {
// Creates the CA when the engine has never run, so there is something to trust.
let ca = CaStore::load_or_create(&ctx.ca_dir())
.map_err(|e| rpc_error(format!("Failed to load proxy CA: {e}")))?;
ca::trust(ca.cert_path()).await.map_err(rpc_error)?;
ca::status(ca.cert_path()).await.map_err(rpc_error)
}
fn settings(ctx: &ProxyCtx) -> Result<ProxySettings, RpcError> {
ProxySettings::load(&ctx.settings_path()).map_err(rpc_error)
}
fn find_flow(ctx: &ProxyCtx, flow_id: &str) -> Result<Flow, RpcError> {
ctx.db.with_conn(|db| db.find_one::<Flow>(FlowIden::Id, flow_id)).map_err(rpc_error)
}
fn rpc_error(e: impl ToString) -> RpcError {
RpcError { message: e.to_string() }
}
fn list_actions(
@@ -324,10 +460,17 @@ define_rpc! {
ProxyCtx;
commands {
execute_action(ActionInvocation) -> bool,
format_body(FormatBodyRequest) -> String,
get_proxy_state(GetProxyStateRequest) -> GetProxyStateResponse,
list_actions(ListActionsRequest) -> ListActionsResponse,
list_models(ListModelsRequest) -> ListModelsResponse,
}
async_commands {
ca_status(CaStatusRequest) -> CaStatus,
ca_trust(CaTrustRequest) -> CaStatus,
filter_body(FilterBodyRequest) -> FilterBodyResponse,
get_flow_body(GetFlowBodyRequest) -> GetFlowBodyResponse,
}
events {
model_write(ModelPayload),
proxy_state_changed(ProxyStatePayload),
+3 -1
View File
@@ -55,7 +55,9 @@ pub struct Flow {
pub error: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_models.ts")]
pub enum FlowBody {
Request,
Response,
+133 -3
View File
@@ -3,6 +3,8 @@ use std::io::Write;
use std::net::SocketAddr;
use std::time::{Duration, Instant};
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use bytes::Bytes;
use flate2::Compression;
use flate2::write::GzEncoder;
@@ -12,9 +14,10 @@ use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
use serde_json::{Value, json};
use tokio::net::TcpListener;
use yaak_proxy_lib::ProxyCtx;
use yaak_proxy_lib::bodies::{read_body, read_raw_body};
use yaak_proxy_lib::bodies::{DecodedBody, read_body, read_raw_body};
use yaak_proxy_lib::models::{Flow, FlowBody};
use yaak_proxy_lib::settings::ProxySettings;
use yaak_rpc::RpcEventEmitter;
@@ -48,6 +51,27 @@ async fn route(req: Request<Incoming>) -> Result<Response<Full<Bytes>>, Infallib
});
Response::new(Full::from("ok"))
}
// Small on the wire, past the capture limit once decoded.
"/expands" => Response::builder()
.header("content-type", "text/plain")
.header("content-encoding", "gzip")
.body(Full::from(gzip(&vec![b'z'; BIG_BODY])))
.unwrap(),
"/json" => Response::builder()
.header("content-type", "application/json")
.body(Full::from(r#"{"items":[{"id":1},{"id":2}]}"#))
.unwrap(),
// Names an encoding but, like any 304, has no body
"/not-modified" => Response::builder()
.status(304)
.header("content-encoding", "gzip")
.body(Full::new(Bytes::new()))
.unwrap(),
// XML that only its body says is XML
"/xml-as-text" => Response::builder()
.header("content-type", "text/plain")
.body(Full::from("<items><id>1</id><id>2</id></items>"))
.unwrap(),
_ => Response::new(Full::from(vec![b'x'; BIG_BODY])),
})
}
@@ -58,7 +82,9 @@ async fn spawn_upstream() -> SocketAddr {
tokio::spawn(async move {
loop {
let (tcp, _) = listener.accept().await.unwrap();
tokio::spawn(http1::Builder::new().serve_connection(TokioIo::new(tcp), service_fn(route)));
tokio::spawn(
http1::Builder::new().serve_connection(TokioIo::new(tcp), service_fn(route)),
);
}
});
addr
@@ -120,7 +146,13 @@ async fn flow_is_stored_with_its_bodies() {
assert_eq!(read_raw_body(&ctx.blobs, &flow.id, FlowBody::Request).unwrap(), b"hello proxy");
assert_eq!(read_raw_body(&ctx.blobs, &flow.id, FlowBody::Response).unwrap(), compressed);
assert_eq!(read_body(&ctx.blobs, &flow, FlowBody::Response).await.unwrap(), b"hello proxy");
let decoded = read_body(&ctx.blobs, &flow, FlowBody::Response, CAPTURE_LIMIT).await.unwrap();
let expected = DecodedBody {
data: b"hello proxy".to_vec(),
stored_size: compressed.len(),
truncated: false,
};
assert_eq!(decoded, expected);
let big_url = format!("http://{upstream}/big");
let resp = client.get(&big_url).send().await.unwrap();
@@ -166,3 +198,101 @@ async fn early_response_keeps_request_totals() {
assert!(flow.request_size >= stored, "{} < {stored}", flow.request_size);
assert_eq!(flow.request_truncated, flow.request_size > stored);
}
async fn rpc(ctx: &ProxyCtx, cmd: &str, payload: Value) -> Result<Value, String> {
yaak_proxy_lib::build_router().dispatch(cmd, payload, ctx).await.map_err(|e| e.message)
}
#[tokio::test(flavor = "multi_thread")]
async fn body_rpcs() {
let dir = tempfile::tempdir().unwrap();
let settings =
ProxySettings { port: 0, capture_body_limit: CAPTURE_LIMIT, ..Default::default() };
settings.save(&dir.path().join("settings.json")).unwrap();
let (events, _event_rx) = RpcEventEmitter::new();
let ctx = ProxyCtx::new(dir.path(), events).unwrap();
ctx.start().unwrap();
let port = ctx.port().unwrap();
let state = rpc(&ctx, "get_proxy_state", json!({})).await.unwrap();
assert_eq!(state, json!({"state": "running", "port": port}));
let upstream = spawn_upstream().await;
let client = reqwest::Client::builder()
.proxy(reqwest::Proxy::all(format!("http://127.0.0.1:{port}")).unwrap())
.build()
.unwrap();
let expands_url = format!("http://{upstream}/expands");
client.get(&expands_url).send().await.unwrap().bytes().await.unwrap();
let flow = wait_for_flow(&ctx, &expands_url).await;
assert!(!flow.response_truncated);
let body =
rpc(&ctx, "get_flow_body", json!({"flowId": flow.id, "which": "response"})).await.unwrap();
assert_eq!(body["truncated"], json!(true));
assert!(body["storedSize"].as_u64().unwrap() < CAPTURE_LIMIT as u64);
let data = BASE64.decode(body["data"].as_str().unwrap()).unwrap();
assert_eq!(data, vec![b'z'; CAPTURE_LIMIT]);
let empty =
rpc(&ctx, "get_flow_body", json!({"flowId": flow.id, "which": "request"})).await.unwrap();
assert_eq!(empty, json!({"data": "", "truncated": false, "storedSize": 0}));
let not_modified_url = format!("http://{upstream}/not-modified");
client.get(&not_modified_url).send().await.unwrap().bytes().await.unwrap();
let flow = wait_for_flow(&ctx, &not_modified_url).await;
let body =
rpc(&ctx, "get_flow_body", json!({"flowId": flow.id, "which": "response"})).await.unwrap();
assert_eq!(body, json!({"data": "", "truncated": false, "storedSize": 0}));
let json_url = format!("http://{upstream}/json");
client.get(&json_url).send().await.unwrap().bytes().await.unwrap();
let flow = wait_for_flow(&ctx, &json_url).await;
let filtered = rpc(
&ctx,
"filter_body",
json!({"flowId": flow.id, "which": "response", "expression": "$.items[*].id"}),
)
.await
.unwrap();
assert_eq!(filtered, json!({"content": "[\n 1,\n 2\n]", "error": null}));
let invalid = rpc(
&ctx,
"filter_body",
json!({"flowId": flow.id, "which": "response", "expression": "$.items[?(@.id ==)]"}),
)
.await
.unwrap();
assert_eq!(invalid["content"], json!(""));
assert!(invalid["error"].is_string());
let xml_url = format!("http://{upstream}/xml-as-text");
client.get(&xml_url).send().await.unwrap().bytes().await.unwrap();
let flow = wait_for_flow(&ctx, &xml_url).await;
let xpath = json!({
"flowId": flow.id,
"which": "response",
"expression": "//id[2]",
"language": "xpath",
});
let filtered = rpc(&ctx, "filter_body", xpath).await.unwrap();
assert_eq!(filtered, json!({"content": "<id>2</id>", "error": null}));
// Without the viewer's language, `text/plain` means JSONPath, which can't read XML
let by_header = rpc(
&ctx,
"filter_body",
json!({"flowId": flow.id, "which": "response", "expression": "//id[2]"}),
)
.await
.unwrap();
assert_eq!(by_header["content"], json!(""));
assert!(by_header["error"].is_string());
let missing =
rpc(&ctx, "get_flow_body", json!({"flowId": "fl_missing", "which": "response"})).await;
assert!(missing.is_err());
let formatted = rpc(&ctx, "format_body", json!({"text": r#"{"a":[1,2]}"#})).await.unwrap();
assert_eq!(formatted, json!("{\n \"a\": [\n 1,\n 2\n ]\n}"));
}
+13
View File
@@ -139,6 +139,9 @@ impl RpcEventEmitter {
/// - `RpcSchema` — a struct with ts-rs derives for TypeScript type generation
/// - `RpcEventSchema` — (if events declared) a struct mapping event names to payload types
///
/// Commands under the optional `async_commands` block are `async fn(Ctx, Req)` handlers,
/// wrapped with `rpc_handler_async!`.
///
/// The wire name for each command/event is derived from `stringify!($ident)`.
///
/// # Example
@@ -162,6 +165,9 @@ macro_rules! define_rpc {
commands {
$( $handler:ident ( $req:ty ) -> $res:ty ),* $(,)?
}
$( async_commands {
$( $async_handler:ident ( $async_req:ty ) -> $async_res:ty ),* $(,)?
} )?
events {
$( $evt_ident:ident ( $evt_payload:ty ) ),* $(,)?
}
@@ -169,6 +175,7 @@ macro_rules! define_rpc {
pub fn build_router() -> $crate::RpcRouter<$ctx> {
let mut router = $crate::RpcRouter::new();
$( router.register(stringify!($handler), $crate::rpc_handler!($handler)); )*
$( $( router.register(stringify!($async_handler), $crate::rpc_handler_async!($async_handler)); )* )?
router
}
@@ -176,6 +183,7 @@ macro_rules! define_rpc {
#[ts(export, export_to = "gen_rpc.ts")]
pub struct RpcSchema {
$( pub $handler: ($req, $res), )*
$( $( pub $async_handler: ($async_req, $async_res), )* )?
}
#[derive(ts_rs::TS)]
@@ -191,10 +199,14 @@ macro_rules! define_rpc {
commands {
$( $handler:ident ( $req:ty ) -> $res:ty ),* $(,)?
}
$( async_commands {
$( $async_handler:ident ( $async_req:ty ) -> $async_res:ty ),* $(,)?
} )?
) => {
pub fn build_router() -> $crate::RpcRouter<$ctx> {
let mut router = $crate::RpcRouter::new();
$( router.register(stringify!($handler), $crate::rpc_handler!($handler)); )*
$( $( router.register(stringify!($async_handler), $crate::rpc_handler_async!($async_handler)); )* )?
router
}
@@ -202,6 +214,7 @@ macro_rules! define_rpc {
#[ts(export, export_to = "gen_rpc.ts")]
pub struct RpcSchema {
$( pub $handler: ($req, $res), )*
$( $( pub $async_handler: ($async_req, $async_res), )* )?
}
};
}
+3 -1
View File
@@ -246,6 +246,7 @@
"version": "1.0.0",
"dependencies": {
"@tanstack/react-query": "^5.104.2",
"@tanstack/react-virtual": "^3.14.17",
"@tauri-apps/api": "^2.12.3",
"@tauri-apps/plugin-os": "^2.4.0",
"@yaakapp-internal/model-store": "^1.0.0",
@@ -257,7 +258,8 @@
"jotai-family": "^1.0.1",
"motion": "^14.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"vkbeautify": "^0.99.3"
},
"devDependencies": {
"@rolldown/plugin-babel": "^0.2.3",
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,60 @@
import type { ErrorInfo, ReactNode } from "react";
import { Component } from "react";
import { Banner } from "./Banner";
import { Button } from "./Button";
import { InlineCode } from "./InlineCode";
export interface ErrorBoundaryProps {
name: string;
children: ReactNode;
/** Shows a button that hands the error over, eg. to open it in a dialog. Without it the message shows inline. */
onShowError?: (error: unknown) => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error: unknown;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: unknown, info: ErrorInfo) {
console.warn("Error caught by ErrorBoundary:", error, info);
}
render() {
const { hasError, error } = this.state;
if (!hasError) {
return this.props.children;
}
const { name, onShowError } = this.props;
return (
<Banner color="danger" className="flex items-center gap-2 overflow-auto">
<div>
Error rendering <InlineCode>{name}</InlineCode> component
{onShowError == null && `: ${error instanceof Error ? error.message : String(error)}`}
</div>
{onShowError && (
<Button
className="inline-flex"
variant="border"
color="danger"
size="2xs"
onClick={() => onShowError(error)}
>
Show
</Button>
)}
</Banner>
);
}
}
+45
View File
@@ -0,0 +1,45 @@
import { HStack } from "./Stacks";
import classNames from "classnames";
import type { HotkeyAction } from "../hooks/useHotKey";
import { useFormattedHotkey } from "../hooks/useHotKey";
interface Props {
action: HotkeyAction | null;
className?: string;
variant?: "text" | "with-bg";
}
export function Hotkey({ action, className, variant }: Props) {
const labelParts = useFormattedHotkey(action);
if (labelParts === null) {
return null;
}
return <HotkeyRaw labelParts={labelParts} className={className} variant={variant} />;
}
interface HotkeyRawProps {
labelParts: string[];
className?: string;
variant?: "text" | "with-bg";
}
export function HotkeyRaw({ labelParts, className, variant }: HotkeyRawProps) {
return (
<HStack
className={classNames(
className,
variant === "with-bg" &&
"rounded-sm bg-surface-highlight px-1 border border-border text-text-subtle",
variant === "text" && "text-text-subtlest",
)}
>
{labelParts.map((char, index) => (
// oxlint-disable-next-line react/no-array-index-key
<div key={index} className="min-w-[1em] text-center">
{char}
</div>
))}
</HStack>
);
}
+2
View File
@@ -118,6 +118,7 @@ import {
RotateCcwIcon,
Rows2Icon,
SaveIcon,
ScissorsIcon,
SearchIcon,
SendHorizontalIcon,
SettingsIcon,
@@ -264,6 +265,7 @@ const icons = {
rotate_ccw: RotateCcwIcon,
rows_2: Rows2Icon,
save: SaveIcon,
scissors: ScissorsIcon,
search: SearchIcon,
send_horizontal: SendHorizontalIcon,
settings: SettingsIcon,
+77
View File
@@ -0,0 +1,77 @@
import type { Color } from "@yaakapp-internal/plugins";
import type { IconProps } from "./Icon";
import { IconButton } from "./IconButton";
import classNames from "classnames";
import type { ReactNode } from "react";
/**
* A single control attached to a labelled separator, rendered between the label
* and the rule.
*
* Declared rather than passed as a node so the separator keeps ownership of the
* things that are easy to get wrong by hand: matching the label's colour, and
* staying out of the rule's way when the label is long.
*/
export interface SeparatorAction {
icon: IconProps["icon"];
/** Tooltip and accessible name. Required — the control is icon-only. */
title: string;
onClick: () => void;
}
interface Props {
orientation?: "horizontal" | "vertical";
dashed?: boolean;
className?: string;
children?: ReactNode;
color?: Color;
action?: SeparatorAction;
}
export function Separator({
color,
className,
dashed,
orientation = "horizontal",
children,
action,
}: Props) {
return (
<div role="presentation" className={classNames(className, "flex items-center w-full")}>
{children && (
<div className="text-sm text-text-subtlest mr-2 whitespace-nowrap">{children}</div>
)}
{action && (
<IconButton
size="2xs"
iconSize="xs"
className="shrink-0 mr-2 -ml-1"
// Forced, because the button itself sets `text-text` at full strength.
iconClassName="text-text-subtlest!"
icon={action.icon}
title={action.title}
onClick={action.onClick}
/>
)}
<div
className={classNames(
"opacity-60",
// Keep a stub of the line visible no matter how long the label is —
// `w-full` alone gets squeezed to nothing by a wide label.
orientation === "horizontal" && "min-w-8",
color == null && "border-border",
color === "primary" && "border-primary",
color === "secondary" && "border-secondary",
color === "success" && "border-success",
color === "notice" && "border-notice",
color === "warning" && "border-warning",
color === "danger" && "border-danger",
color === "info" && "border-info",
dashed && "border-dashed",
orientation === "horizontal" && "w-full h-0 border-t",
orientation === "vertical" && "h-full w-0 border-l",
)}
/>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import type { RefObject } from "react";
import { useEffect, useRef } from "react";
/**
* Get notified when a mouse click happens outside the target ref
* @param ref The element to be notified when a mouse click happens outside it
* @param onClickAway
* @param ignored Optional outside element to ignore (useful for dropdown triggers)
*/
export function useClickOutside(
ref: RefObject<HTMLElement | null>,
onClickAway: (event: MouseEvent) => void,
ignored?: RefObject<HTMLElement | null>,
) {
const savedCallback = useRef(onClickAway);
useEffect(() => {
savedCallback.current = onClickAway;
}, [onClickAway]);
useEffect(() => {
const handler = (event: MouseEvent) => {
if (ref.current == null || !(event.target instanceof HTMLElement)) {
return;
}
const isIgnored = ignored?.current?.contains(event.target);
const clickedOutside = !ref.current.contains(event.target);
if (!isIgnored && clickedOutside) {
savedCallback.current(event);
}
};
// NOTE: We're using mousedown instead of click to handle some edge cases like when a context
// menu is open with the ctrl key.
document.addEventListener("mousedown", handler, { capture: true });
document.addEventListener("contextmenu", handler, { capture: true });
return () => {
document.removeEventListener("mousedown", handler);
document.removeEventListener("contextmenu", handler);
};
}, [ignored, ref]);
}
+90
View File
@@ -0,0 +1,90 @@
import { createContext, useContext, useEffect } from "react";
/**
* Hosts narrow {@link HotkeyAction} to their own actions by augmenting this with
* `{ action: TheirActionUnion }`. Left empty, any string is an action.
*/
export interface HotkeyActionRegistry {}
export type HotkeyAction = HotkeyActionRegistry extends { action: infer A extends string }
? A
: string;
export type HotKeyOptions = {
enable?: boolean | (() => boolean);
priority?: number;
allowDefault?: boolean;
};
export interface HotkeyContextValue {
/** The host's OS, which decides how key combos are labelled */
os: string;
/** The key combos bound to an action, like `["Meta+Shift+s"]`, or null when it has none */
bindingFor: (action: HotkeyAction) => string[] | null;
/** Bind a callback to an action's keys, returning the unbind */
register: (
action: HotkeyAction,
callback: (e: KeyboardEvent) => void,
options: HotKeyOptions,
) => () => void;
}
const HotkeyContext = createContext<HotkeyContextValue>({
os: "",
bindingFor: () => null,
register: () => () => {},
});
export const HotkeyProvider = HotkeyContext;
export function useHotKey(
action: HotkeyAction | null,
callback: (e: KeyboardEvent) => void,
options: HotKeyOptions = {},
) {
const { register } = useContext(HotkeyContext);
useEffect(() => {
if (action == null) return;
return register(action, callback, options);
}, [action, callback, options, register]);
}
export function useFormattedHotkey(action: HotkeyAction | null): string[] | null {
const { os, bindingFor } = useContext(HotkeyContext);
const trigger = action == null ? null : (bindingFor(action)?.[0] ?? null);
return trigger == null ? null : formatHotkey(trigger, os);
}
const MAC_KEY_LABELS: Record<string, string> = {
Meta: "⌘",
Shift: "⇧",
Control: "⌃",
Alt: "⌥",
// U+21A9 has an emoji presentation, which Chromium's font fallback picks
// (a blue glyph among monochrome ones). U+FE0E forces the text form.
Enter: "↩︎",
Tab: "⇥",
Backspace: "⌫",
Delete: "⌦",
Minus: "-",
Plus: "+",
Equal: "=",
};
const OTHER_KEY_LABELS: Record<string, string> = {
Control: "Ctrl",
};
/** Label a key combo like `Meta+Shift+s`: one glyph per key on macOS, one joined string elsewhere */
export function formatHotkey(trigger: string, os: string): string[] {
const labels = os === "macos" ? MAC_KEY_LABELS : OTHER_KEY_LABELS;
const parts = trigger.split("+").map((p) => labels[p] ?? capitalize(p));
return os === "macos" ? parts : [parts.join("+")];
}
function capitalize(str: string): string {
return str
.split(" ")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
+24
View File
@@ -8,12 +8,25 @@ export { CountBadge } from "./components/CountBadge";
export type { DetailsBannerProps } from "./components/DetailsBanner";
export { DetailsBanner } from "./components/DetailsBanner";
export { DropMarker } from "./components/DropMarker";
export type {
ContextMenuProps,
DropdownItem,
DropdownItemContent,
DropdownItemDefault,
DropdownItemSeparator,
DropdownProps,
DropdownRef,
} from "./components/Dropdown";
export { ContextMenu, Dropdown } from "./components/Dropdown";
export { LazyCodeEditor } from "./components/editor/LazyCodeEditor";
export type { ErrorBoundaryProps } from "./components/ErrorBoundary";
export { ErrorBoundary } from "./components/ErrorBoundary";
export { FormattedError } from "./components/FormattedError";
export { HeaderSize } from "./components/HeaderSize";
export type { HeadersTableProps } from "./components/HeadersTable";
export { HeadersTable } from "./components/HeadersTable";
export { Heading } from "./components/Heading";
export { Hotkey, HotkeyRaw } from "./components/Hotkey";
export type { IconProps } from "./components/Icon";
export { Icon } from "./components/Icon";
export type { IconButtonProps } from "./components/IconButton";
@@ -28,6 +41,8 @@ export { Label } from "./components/Label";
export { LoadingIcon } from "./components/LoadingIcon";
export { Overlay } from "./components/Overlay";
export { Portal } from "./components/Portal";
export type { SeparatorAction } from "./components/Separator";
export { Separator } from "./components/Separator";
export type { ResizeHandleEvent } from "./components/ResizeHandle";
export { ResizeHandle } from "./components/ResizeHandle";
export { SidebarLayout } from "./components/SidebarLayout";
@@ -77,9 +92,17 @@ export { TextViewer } from "./components/viewers/TextViewer";
export { VideoViewer } from "./components/viewers/VideoViewer";
export { WebPageViewer } from "./components/viewers/WebPageViewer";
export { WindowControls } from "./components/WindowControls";
export { useClickOutside } from "./hooks/useClickOutside";
export { useContainerSize } from "./hooks/useContainerSize";
export { useDebouncedState } from "./hooks/useDebouncedState";
export { useDebouncedValue } from "./hooks/useDebouncedValue";
export type {
HotKeyOptions,
HotkeyAction,
HotkeyActionRegistry,
HotkeyContextValue,
} from "./hooks/useHotKey";
export { formatHotkey, HotkeyProvider, useFormattedHotkey, useHotKey } from "./hooks/useHotKey";
export { useIsFullscreen } from "./hooks/useIsFullscreen";
export { usePortal } from "./hooks/usePortal";
export { useStateWithDeps } from "./hooks/useStateWithDeps";
@@ -93,4 +116,5 @@ export { HEADER_SIZE_LG, HEADER_SIZE_MD, WINDOW_CONTROLS_WIDTH } from "./lib/con
export { languageFromContent, languageFromContentType } from "./lib/contentType";
export { computeSideForDragMove } from "./lib/dnd";
export { docFingerprint } from "./lib/docFingerprint";
export { getNodeText } from "./lib/getNodeText";
export { minPromiseMillis } from "./lib/minPromiseMillis";
+22
View File
@@ -0,0 +1,22 @@
import type { ReactNode } from "react";
/**
* Get the text content from a ReactNode
* https://stackoverflow.com/questions/50428910/get-text-content-from-node-in-react
*/
export function getNodeText(node: ReactNode): string {
if (typeof node === "string" || typeof node === "number") {
return String(node);
}
if (Array.isArray(node)) {
return node.map(getNodeText).join("");
}
if (typeof node === "object" && node) {
// oxlint-disable-next-line no-explicit-any
return getNodeText((node as any).props.children);
}
return "";
}