mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Rescue the proxy GUI slice (#785)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6132171791
commit
686294939b
50 files changed
+3394
-1596
No files matched your search
Generated
+3
@@ -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]]
|
||||
|
||||
@@ -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 +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";
|
||||
@@ -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 +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";
|
||||
@@ -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 +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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>•</span>
|
||||
<span
|
||||
title={
|
||||
flow.elapsedHeadersMs == null
|
||||
? undefined
|
||||
: `HEADERS: ${formatMillis(flow.elapsedHeadersMs)}\nTOTAL: ${formatMillis(flow.elapsedMs)}`
|
||||
}
|
||||
>
|
||||
{formatMillis(flow.elapsedMs)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{!failed && (
|
||||
<>
|
||||
<span>•</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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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: "" };
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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";
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
@@ -12,6 +12,7 @@
|
||||
:root {
|
||||
--font-family-interface: "";
|
||||
--font-family-editor: "";
|
||||
--editor-font-size: 12px;
|
||||
}
|
||||
|
||||
:root {
|
||||
|
||||
+14
-10
@@ -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>,
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare module "vkbeautify";
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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], };
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(¬_modified_url).send().await.unwrap().bytes().await.unwrap();
|
||||
let flow = wait_for_flow(&ctx, ¬_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}"));
|
||||
}
|
||||
@@ -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), )* )?
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Generated
+3
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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(" ");
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 "";
|
||||
}
|
||||
Reference in new issue
Block a user