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"
|
name = "yaak-proxy-lib"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.23.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -11774,10 +11775,12 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"ts-rs",
|
"ts-rs",
|
||||||
"yaak-database",
|
"yaak-database",
|
||||||
|
"yaak-filter",
|
||||||
"yaak-http",
|
"yaak-http",
|
||||||
"yaak-models",
|
"yaak-models",
|
||||||
"yaak-proxy",
|
"yaak-proxy",
|
||||||
"yaak-rpc",
|
"yaak-rpc",
|
||||||
|
"yaak-templates",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -1,60 +1,19 @@
|
|||||||
import { Banner, Button, InlineCode } from "@yaakapp-internal/ui";
|
import { ErrorBoundary as BaseErrorBoundary } from "@yaakapp-internal/ui";
|
||||||
import type { ErrorInfo, ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Component, useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { showDialog } from "../lib/dialog";
|
import { showDialog } from "../lib/dialog";
|
||||||
import RouteError from "./RouteError";
|
import RouteError from "./RouteError";
|
||||||
|
|
||||||
interface ErrorBoundaryProps {
|
export function ErrorBoundary({ name, children }: { name: string; children: ReactNode }) {
|
||||||
name: string;
|
return (
|
||||||
children: ReactNode;
|
<BaseErrorBoundary name={name} onShowError={showErrorDialog}>
|
||||||
|
{children}
|
||||||
|
</BaseErrorBoundary>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ErrorBoundaryState {
|
function showErrorDialog(error: unknown) {
|
||||||
hasError: boolean;
|
showDialog({ id: "error-boundary", render: () => <RouteError error={error} /> });
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundaryTestThrow() {
|
export function ErrorBoundaryTestThrow() {
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,45 +1 @@
|
|||||||
import { HStack } from "@yaakapp-internal/ui";
|
export { Hotkey, HotkeyRaw } 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,77 +1,2 @@
|
|||||||
import type { Color } from "@yaakapp-internal/plugins";
|
export type { SeparatorAction } from "@yaakapp-internal/ui";
|
||||||
import type { IconProps } from "@yaakapp-internal/ui";
|
export { Separator } 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,41 +1 @@
|
|||||||
import type { RefObject } from "react";
|
export { useClickOutside } from "@yaakapp-internal/ui";
|
||||||
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]);
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,26 @@
|
|||||||
import { platform } from "@yaakapp-internal/platform";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import { debounce } from "@yaakapp-internal/lib";
|
import { debounce } from "@yaakapp-internal/lib";
|
||||||
import { settingsAtom } from "@yaakapp-internal/models";
|
import { settingsAtom } from "@yaakapp-internal/models";
|
||||||
|
import {
|
||||||
|
formatHotkey,
|
||||||
|
type HotkeyContextValue,
|
||||||
|
type HotKeyOptions,
|
||||||
|
useFormattedHotkey,
|
||||||
|
useHotKey,
|
||||||
|
} from "@yaakapp-internal/ui";
|
||||||
import { atom, useAtomValue } from "jotai";
|
import { atom, useAtomValue } from "jotai";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { capitalize } from "../lib/capitalize";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
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 HOLD_KEYS = ["Shift", "Control", "Command", "Alt", "Meta"];
|
||||||
const SINGLE_WHITELIST = ["Delete", "Enter", "Backspace"];
|
const SINGLE_WHITELIST = ["Delete", "Enter", "Backspace"];
|
||||||
|
|
||||||
@@ -188,12 +203,6 @@ export const hotkeyActions: HotkeyAction[] = (
|
|||||||
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
|
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
|
||||||
});
|
});
|
||||||
|
|
||||||
export type HotKeyOptions = {
|
|
||||||
enable?: boolean | (() => boolean);
|
|
||||||
priority?: number;
|
|
||||||
allowDefault?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Callback {
|
interface Callback {
|
||||||
action: HotkeyAction;
|
action: HotkeyAction;
|
||||||
callback: (e: KeyboardEvent) => void;
|
callback: (e: KeyboardEvent) => void;
|
||||||
@@ -210,25 +219,34 @@ const clearCurrentKeysDebounced = debounce(() => {
|
|||||||
jotaiStore.set(currentKeysAtom, new Set([]));
|
jotaiStore.set(currentKeysAtom, new Set([]));
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
export function useHotKey(
|
function registerHotkey(
|
||||||
action: HotkeyAction | null,
|
action: HotkeyAction,
|
||||||
callback: (e: KeyboardEvent) => void,
|
callback: (e: KeyboardEvent) => void,
|
||||||
options: HotKeyOptions = {},
|
options: HotKeyOptions,
|
||||||
) {
|
) {
|
||||||
useEffect(() => {
|
jotaiStore.set(callbacksAtom, (prev) => {
|
||||||
if (action == null) return;
|
const without = prev.filter((cb) => {
|
||||||
jotaiStore.set(callbacksAtom, (prev) => {
|
const isTheSame = cb.action === action && cb.options.priority === options.priority;
|
||||||
const without = prev.filter((cb) => {
|
return !isTheSame;
|
||||||
const isTheSame = cb.action === action && cb.options.priority === options.priority;
|
|
||||||
return !isTheSame;
|
|
||||||
});
|
|
||||||
const newCb: Callback = { action, callback, options };
|
|
||||||
return [...without, newCb];
|
|
||||||
});
|
});
|
||||||
return () => {
|
const newCb: Callback = { action, callback, options };
|
||||||
jotaiStore.set(callbacksAtom, (prev) => prev.filter((cb) => cb.callback !== callback));
|
return [...without, newCb];
|
||||||
};
|
});
|
||||||
}, [action, callback, options]);
|
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() {
|
export function useSubscribeHotKeys() {
|
||||||
@@ -330,66 +348,7 @@ export function getHotkeyScope(action: HotkeyAction): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatHotkeyString(trigger: string): string[] {
|
export function formatHotkeyString(trigger: string): string[] {
|
||||||
const os = platform.osType();
|
return formatHotkey(trigger, 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareKeys(keysA: string[], keysB: string[]) {
|
function compareKeys(keysA: string[], keysB: string[]) {
|
||||||
|
|||||||
@@ -1,22 +1 @@
|
|||||||
import type { ReactNode } from "react";
|
export { getNodeText } from "@yaakapp-internal/ui";
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 "";
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
import { HotkeyProvider } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { Provider as JotaiProvider } from "jotai";
|
import { Provider as JotaiProvider } from "jotai";
|
||||||
import { LazyMotion, MotionConfig } from "motion/react";
|
import { LazyMotion, MotionConfig } from "motion/react";
|
||||||
import { lazy, Suspense } from "react";
|
import { lazy, type ReactNode, Suspense } from "react";
|
||||||
import { GlobalHooks } from "../components/GlobalHooks";
|
import { GlobalHooks } from "../components/GlobalHooks";
|
||||||
import RouteError from "../components/RouteError";
|
import RouteError from "../components/RouteError";
|
||||||
|
import { useHotkeyContextValue } from "../hooks/useHotKey";
|
||||||
import { jotaiStore } from "../lib/jotai";
|
import { jotaiStore } from "../lib/jotai";
|
||||||
import { queryClient } from "../lib/queryClient";
|
import { queryClient } from "../lib/queryClient";
|
||||||
|
|
||||||
@@ -27,22 +29,28 @@ function RouteComponent() {
|
|||||||
return (
|
return (
|
||||||
<JotaiProvider store={jotaiStore}>
|
<JotaiProvider store={jotaiStore}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<LazyMotion strict features={motionFeatures}>
|
<Hotkeys>
|
||||||
<MotionConfig transition={{ duration: 0.1 }}>
|
<LazyMotion strict features={motionFeatures}>
|
||||||
<Suspense>
|
<MotionConfig transition={{ duration: 0.1 }}>
|
||||||
<Toasts />
|
<Suspense>
|
||||||
<Dialogs />
|
<Toasts />
|
||||||
<ContextMenus />
|
<Dialogs />
|
||||||
</Suspense>
|
<ContextMenus />
|
||||||
<Layout />
|
</Suspense>
|
||||||
<GlobalHooks />
|
<Layout />
|
||||||
</MotionConfig>
|
<GlobalHooks />
|
||||||
</LazyMotion>
|
</MotionConfig>
|
||||||
|
</LazyMotion>
|
||||||
|
</Hotkeys>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</JotaiProvider>
|
</JotaiProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Hotkeys({ children }: { children: ReactNode }) {
|
||||||
|
return <HotkeyProvider value={useHotkeyContextValue()}>{children}</HotkeyProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
function Layout() {
|
function Layout() {
|
||||||
return (
|
return (
|
||||||
<div
|
<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 { HeaderSize, IconButton, SidebarLayout, SplitLayout } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useLocalStorage } from "react-use";
|
import { useLocalStorage } from "react-use";
|
||||||
import { useRpcQueryWithEvent } from "../hooks/useRpcQueryWithEvent";
|
import { flowLayoutAtom } from "../lib/atoms";
|
||||||
import { getOsType } from "../lib/tauri";
|
import { getOsType } from "../lib/tauri";
|
||||||
import { ActionIconButton } from "./ActionIconButton";
|
import { FlowDetail } from "./FlowDetail";
|
||||||
import { ExchangesTable } from "./ExchangesTable";
|
import { FlowList } from "./FlowList";
|
||||||
import { filteredExchangesAtom, Sidebar } from "./Sidebar";
|
import { ProxyStatus } from "./ProxyStatus";
|
||||||
|
import { filteredExchangesAtom, selectedSidebarLabelAtom, Sidebar } from "./Sidebar";
|
||||||
|
|
||||||
export function ProxyLayout() {
|
export function ProxyLayout() {
|
||||||
const os = getOsType();
|
const os = getOsType();
|
||||||
const exchanges = useAtomValue(filteredExchangesAtom);
|
const exchanges = useAtomValue(filteredExchangesAtom);
|
||||||
|
const title = useAtomValue(selectedSidebarLabelAtom);
|
||||||
|
const [flowLayout, setFlowLayout] = useAtom(flowLayoutAtom);
|
||||||
const [sidebarWidth, setSidebarWidth] = useLocalStorage("sidebar_width", 250);
|
const [sidebarWidth, setSidebarWidth] = useLocalStorage("sidebar_width", 250);
|
||||||
const [sidebarHidden, setSidebarHidden] = useLocalStorage("sidebar_hidden", false);
|
const [sidebarHidden, setSidebarHidden] = useLocalStorage("sidebar_hidden", false);
|
||||||
const [floatingSidebarHidden, setFloatingSidebarHidden] = useLocalStorage(
|
const [floatingSidebarHidden, setFloatingSidebarHidden] = useLocalStorage(
|
||||||
@@ -19,14 +22,12 @@ export function ProxyLayout() {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
const [floating, setFloating] = useState(false);
|
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);
|
const isHidden = floating ? (floatingSidebarHidden ?? true) : (sidebarHidden ?? false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
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",
|
os === "linux" && "border border-border-subtle",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -57,29 +58,25 @@ export function ProxyLayout() {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
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>
|
||||||
<div className="flex items-center gap-1 pr-1 pointer-events-auto">
|
<div className="flex items-center gap-1 pr-1">
|
||||||
{isRunning ? (
|
<ProxyStatus />
|
||||||
<>
|
<IconButton
|
||||||
<span className="text-2xs text-success">Running :9090</span>
|
className="pointer-events-auto"
|
||||||
<ActionIconButton
|
icon={flowLayout === "horizontal" ? "columns_2" : "rows_2"}
|
||||||
action={{ scope: "global", action: "proxy_stop" }}
|
title={`Change to ${flowLayout === "horizontal" ? "vertical" : "horizontal"} layout`}
|
||||||
icon="circle_stop"
|
size="sm"
|
||||||
iconColor="secondary"
|
iconColor="secondary"
|
||||||
size="sm"
|
onClick={() =>
|
||||||
/>
|
setFlowLayout((prev) => (prev === "horizontal" ? "vertical" : "horizontal"))
|
||||||
</>
|
}
|
||||||
) : (
|
/>
|
||||||
<ActionIconButton
|
|
||||||
action={{ scope: "global", action: "proxy_start" }}
|
|
||||||
icon="circle_play"
|
|
||||||
iconColor="secondary"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</HeaderSize>
|
</HeaderSize>
|
||||||
@@ -126,18 +123,19 @@ export function ProxyLayout() {
|
|||||||
>
|
>
|
||||||
<SplitLayout
|
<SplitLayout
|
||||||
storageKey="proxy_detail"
|
storageKey="proxy_detail"
|
||||||
layout="vertical"
|
className="gap-1"
|
||||||
defaultRatio={0.4}
|
layout={flowLayout}
|
||||||
firstSlot={({ style }) => (
|
defaultRatio={0.55}
|
||||||
<ExchangesTable exchanges={exchanges} style={style} className="overflow-auto" />
|
firstSlot={({ style, orientation }) => (
|
||||||
)}
|
<FlowList
|
||||||
secondSlot={({ style }) => (
|
flows={exchanges}
|
||||||
<div
|
sideBySide={orientation === "horizontal"}
|
||||||
style={style}
|
style={style}
|
||||||
className="p-3 text-text-subtlest text-sm border-t border-border-subtle"
|
className="pt-1"
|
||||||
>
|
/>
|
||||||
Select a request to view details
|
)}
|
||||||
</div>
|
secondSlot={({ style, orientation }) => (
|
||||||
|
<FlowDetail style={style} sideBySide={orientation === "horizontal"} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</SidebarLayout>
|
</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));
|
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>) {
|
function collectNodes(node: TreeNode<SidebarItem>, map: Map<string, SidebarItem>) {
|
||||||
map.set(node.item.id, node.item);
|
map.set(node.item.id, node.item);
|
||||||
for (const child of node.children ?? []) {
|
for (const child of node.children ?? []) {
|
||||||
@@ -204,7 +212,7 @@ export function Sidebar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="x-theme-sidebar bg-surface h-full w-full min-w-0 overflow-y-auto border-r border-border-subtle">
|
<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
|
<Tree
|
||||||
treeId={treeId}
|
treeId={treeId}
|
||||||
collapsedAtom={collapsedAtom(treeId)}
|
collapsedAtom={collapsedAtom(treeId)}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Hardcode font size for now. In the future, this could be configurable.
|
// 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 { rpc } from "./rpc";
|
||||||
|
import { getOsType } from "./tauri";
|
||||||
|
|
||||||
type ActionBinding = {
|
declare module "@yaakapp-internal/ui" {
|
||||||
invocation: ActionInvocation;
|
interface HotkeyActionRegistry {
|
||||||
meta: ActionMetadata;
|
action: GlobalAction;
|
||||||
keys: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean };
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
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. */
|
/** 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());
|
const parts = hotkey.split("+").map((p) => p.trim().toLowerCase());
|
||||||
return {
|
return {
|
||||||
ctrl: parts.includes("ctrl") || parts.includes("control"),
|
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 (
|
return (
|
||||||
e.ctrlKey === binding.ctrl &&
|
e.ctrlKey === keys.ctrl &&
|
||||||
e.shiftKey === binding.shift &&
|
e.shiftKey === keys.shift &&
|
||||||
e.altKey === binding.alt &&
|
e.altKey === keys.alt &&
|
||||||
e.metaKey === binding.meta &&
|
e.metaKey === keys.meta &&
|
||||||
e.key.toLowerCase() === binding.key
|
e.key.toLowerCase() === keys.key
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch all actions from Rust and register a global keydown listener. */
|
/**
|
||||||
export async function initHotkeys(): Promise<() => void> {
|
* Hotkeys from the actions' metadata: a key runs the component bound to its action if one is,
|
||||||
const { actions } = await rpc("list_actions", {});
|
* and the action itself otherwise.
|
||||||
|
*/
|
||||||
|
export function useHotkeyContextValue(): HotkeyContextValue {
|
||||||
|
const { data } = useRpcQuery("list_actions", {});
|
||||||
|
|
||||||
const bindings: ActionBinding[] = actions
|
const bindings = useMemo(() => {
|
||||||
.filter(
|
const map = new Map<GlobalAction, Binding>();
|
||||||
// oxlint-disable-next-line no-redundant-type-constituents -- ActionMetadata resolves at runtime
|
for (const [invocation, meta] of data?.actions ?? []) {
|
||||||
(entry): entry is [ActionInvocation, ActionMetadata & { defaultHotkey: string }] =>
|
if (meta.defaultHotkey == null) continue;
|
||||||
entry[1].defaultHotkey != null,
|
const hotkey = meta.defaultHotkey.replace(/\bCtrl\b/, "Control");
|
||||||
)
|
map.set(invocation.action, { invocation, hotkey, keys: parseHotkey(hotkey) });
|
||||||
.map(([invocation, meta]) => ({
|
}
|
||||||
invocation,
|
return map;
|
||||||
meta,
|
}, [data]);
|
||||||
keys: parseHotkey(meta.defaultHotkey),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
useEffect(() => {
|
||||||
for (const binding of bindings) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
if (matchesEvent(binding.keys, e)) {
|
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();
|
e.preventDefault();
|
||||||
void rpc("execute_action", binding.invocation);
|
void rpc("execute_action", binding.invocation);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
return () => window.removeEventListener("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 Req<K extends keyof RpcSchema> = RpcSchema[K][0];
|
||||||
export type Res<K extends keyof RpcSchema> = RpcSchema[K][1];
|
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>> {
|
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. */
|
/** 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 } =
|
export const { dataAtom, applyChange, replaceAll, listAtom, orderedListAtom } =
|
||||||
createModelStore<ProxyModels>(["flow"]);
|
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 {
|
:root {
|
||||||
--font-family-interface: "";
|
--font-family-interface: "";
|
||||||
--font-family-editor: "";
|
--font-family-editor: "";
|
||||||
|
--editor-font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
+14
-10
@@ -1,11 +1,12 @@
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { createStore, Provider } from "jotai";
|
import { createStore, Provider } from "jotai";
|
||||||
import { LazyMotion, MotionConfig } from "motion/react";
|
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 { createRoot } from "react-dom/client";
|
||||||
import { ProxyLayout } from "./components/ProxyLayout";
|
import { ProxyLayout } from "./components/ProxyLayout";
|
||||||
|
import { useHotkeyContextValue } from "./lib/hotkeys";
|
||||||
import { listen, rpc } from "./lib/rpc";
|
import { listen, rpc } from "./lib/rpc";
|
||||||
import { initHotkeys } from "./lib/hotkeys";
|
|
||||||
import { applyChange, dataAtom, replaceAll } from "./lib/store";
|
import { applyChange, dataAtom, replaceAll } from "./lib/store";
|
||||||
import "./main.css";
|
import "./main.css";
|
||||||
|
|
||||||
@@ -17,25 +18,28 @@ void rpc("list_models", {}).then((res) => {
|
|||||||
jotaiStore.set(dataAtom, (prev) => replaceAll(prev, "flow", res.flows));
|
jotaiStore.set(dataAtom, (prev) => replaceAll(prev, "flow", res.flows));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register hotkeys from action metadata
|
|
||||||
void initHotkeys();
|
|
||||||
|
|
||||||
// Subscribe to model change events from the backend
|
// Subscribe to model change events from the backend
|
||||||
void listen("model_write", (payload) => {
|
void listen("model_write", (payload) => {
|
||||||
jotaiStore.set(dataAtom, (prev) => applyChange(prev, "flow", payload.model, payload.change));
|
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);
|
const motionFeatures = () => import("framer-motion").then((mod) => mod.domAnimation);
|
||||||
|
|
||||||
createRoot(document.getElementById("root") as HTMLElement).render(
|
createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<Provider store={jotaiStore}>
|
<Provider store={jotaiStore}>
|
||||||
<LazyMotion strict features={motionFeatures}>
|
<Hotkeys>
|
||||||
<MotionConfig transition={{ duration: 0.1 }}>
|
<LazyMotion strict features={motionFeatures}>
|
||||||
<ProxyLayout />
|
<MotionConfig transition={{ duration: 0.1 }}>
|
||||||
</MotionConfig>
|
<ProxyLayout />
|
||||||
</LazyMotion>
|
</MotionConfig>
|
||||||
|
</LazyMotion>
|
||||||
|
</Hotkeys>
|
||||||
</Provider>
|
</Provider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
declare module "vkbeautify";
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.104.2",
|
"@tanstack/react-query": "^5.104.2",
|
||||||
|
"@tanstack/react-virtual": "^3.14.17",
|
||||||
"@tauri-apps/api": "^2.12.3",
|
"@tauri-apps/api": "^2.12.3",
|
||||||
"@tauri-apps/plugin-os": "^2.4.0",
|
"@tauri-apps/plugin-os": "^2.4.0",
|
||||||
"@yaakapp-internal/model-store": "^1.0.0",
|
"@yaakapp-internal/model-store": "^1.0.0",
|
||||||
@@ -21,7 +22,8 @@
|
|||||||
"jotai-family": "^1.0.1",
|
"jotai-family": "^1.0.1",
|
||||||
"motion": "^14.0.0",
|
"motion": "^14.0.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0",
|
||||||
|
"vkbeautify": "^0.99.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rolldown/plugin-babel": "^0.2.3",
|
"@rolldown/plugin-babel": "^0.2.3",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ authors = ["Gregory Schier"]
|
|||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
base64 = "0.23.1"
|
||||||
chrono = { workspace = true, features = ["serde"] }
|
chrono = { workspace = true, features = ["serde"] }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
include_dir = "0.7"
|
include_dir = "0.7"
|
||||||
@@ -15,15 +16,18 @@ rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
|
|||||||
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
|
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["io-util"] }
|
tokio = { workspace = true, features = ["io-util", "process"] }
|
||||||
ts-rs = { workspace = true, features = ["chrono-impl"] }
|
ts-rs = { workspace = true, features = ["chrono-impl"] }
|
||||||
yaak-database = { workspace = true }
|
yaak-database = { workspace = true }
|
||||||
|
yaak-filter = { workspace = true }
|
||||||
yaak-http = { workspace = true }
|
yaak-http = { workspace = true }
|
||||||
yaak-models = { workspace = true }
|
yaak-models = { workspace = true }
|
||||||
yaak-proxy = { workspace = true }
|
yaak-proxy = { workspace = true }
|
||||||
yaak-rpc = { workspace = true }
|
yaak-rpc = { workspace = true }
|
||||||
|
yaak-templates = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
base64 = "0.23.1"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
flate2 = "1"
|
flate2 = "1"
|
||||||
http-body-util = "0.1"
|
http-body-util = "0.1"
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ elapsedHeadersMs: number | null,
|
|||||||
*/
|
*/
|
||||||
elapsedMs: number | null, error: string | null, };
|
elapsedMs: number | null, error: string | null, };
|
||||||
|
|
||||||
|
export type FlowBody = "request" | "response";
|
||||||
|
|
||||||
export type ModelPayload = { model: Flow, change: ModelChangeEvent, };
|
export type ModelPayload = { model: Flow, change: ModelChangeEvent, };
|
||||||
|
|
||||||
export type ProxyHeader = { name: string, value: string, };
|
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.
|
// 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 ActionInvocation = { "scope": "global", action: GlobalAction, };
|
||||||
|
|
||||||
export type ActionMetadata = { label: string, defaultHotkey: string | null, };
|
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 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";
|
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 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())
|
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
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
/// stored bytes go.
|
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(
|
pub async fn read_body(
|
||||||
blobs: &BlobManager,
|
blobs: &BlobManager,
|
||||||
flow: &Flow,
|
flow: &Flow,
|
||||||
body: FlowBody,
|
body: FlowBody,
|
||||||
) -> Result<Vec<u8>, String> {
|
limit: usize,
|
||||||
|
) -> Result<DecodedBody, String> {
|
||||||
let raw = read_raw_body(blobs, &flow.id, body)?;
|
let raw = read_raw_body(blobs, &flow.id, body)?;
|
||||||
let encoding = ContentEncoding::from_header(find_header(flow.headers(body), "content-encoding"));
|
let stored_size = raw.len();
|
||||||
if encoding == ContentEncoding::Identity {
|
let wire_truncated = flow.truncated(body);
|
||||||
return Ok(raw);
|
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();
|
// One byte past the limit tells a body that fills it from one that overflows it.
|
||||||
match streaming_decoder(Cursor::new(raw), encoding).read_to_end(&mut decoded).await {
|
let take = u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1);
|
||||||
Ok(_) => Ok(decoded),
|
let mut data = Vec::new();
|
||||||
Err(_) if flow.truncated(body) => Ok(decoded),
|
let mut decoder = streaming_decoder(Cursor::new(raw), encoding).take(take);
|
||||||
Err(e) => Err(format!("Failed to decode {encoding:?} body: {e}")),
|
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 actions;
|
||||||
pub mod bodies;
|
pub mod bodies;
|
||||||
|
pub mod ca;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|
||||||
use crate::actions::{ActionInvocation, ActionMetadata, GlobalAction};
|
use crate::actions::{ActionInvocation, ActionMetadata, GlobalAction};
|
||||||
|
use crate::ca::CaStatus;
|
||||||
use crate::db::ProxyQueryManager;
|
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 crate::settings::ProxySettings;
|
||||||
|
use base64::Engine;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use log::warn;
|
use log::warn;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -89,6 +93,10 @@ impl ProxyCtx {
|
|||||||
pub fn port(&self) -> Option<u16> {
|
pub fn port(&self) -> Option<u16> {
|
||||||
self.handle.lock().ok()?.as_ref().map(|h| h.port)
|
self.handle.lock().ok()?.as_ref().map(|h| h.port)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn ca_dir(&self) -> PathBuf {
|
||||||
|
self.data_dir.join("ca")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Proxy state --
|
// -- Proxy state --
|
||||||
@@ -136,10 +144,76 @@ pub struct GetProxyStateRequest {}
|
|||||||
|
|
||||||
#[derive(Serialize, TS)]
|
#[derive(Serialize, TS)]
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct GetProxyStateResponse {
|
pub struct GetProxyStateResponse {
|
||||||
pub state: ProxyState,
|
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 --
|
// -- Handlers --
|
||||||
|
|
||||||
fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result<bool, RpcError> {
|
fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result<bool, RpcError> {
|
||||||
@@ -156,9 +230,71 @@ fn get_proxy_state(
|
|||||||
ctx: &ProxyCtx,
|
ctx: &ProxyCtx,
|
||||||
_req: GetProxyStateRequest,
|
_req: GetProxyStateRequest,
|
||||||
) -> Result<GetProxyStateResponse, RpcError> {
|
) -> Result<GetProxyStateResponse, RpcError> {
|
||||||
let handle = ctx.handle.lock().map_err(|_| RpcError { message: "lock poisoned".into() })?;
|
let port = ctx.port();
|
||||||
let state = if handle.is_some() { ProxyState::Running } else { ProxyState::Stopped };
|
let state = if port.is_some() { ProxyState::Running } else { ProxyState::Stopped };
|
||||||
Ok(GetProxyStateResponse { state })
|
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(
|
fn list_actions(
|
||||||
@@ -324,10 +460,17 @@ define_rpc! {
|
|||||||
ProxyCtx;
|
ProxyCtx;
|
||||||
commands {
|
commands {
|
||||||
execute_action(ActionInvocation) -> bool,
|
execute_action(ActionInvocation) -> bool,
|
||||||
|
format_body(FormatBodyRequest) -> String,
|
||||||
get_proxy_state(GetProxyStateRequest) -> GetProxyStateResponse,
|
get_proxy_state(GetProxyStateRequest) -> GetProxyStateResponse,
|
||||||
list_actions(ListActionsRequest) -> ListActionsResponse,
|
list_actions(ListActionsRequest) -> ListActionsResponse,
|
||||||
list_models(ListModelsRequest) -> ListModelsResponse,
|
list_models(ListModelsRequest) -> ListModelsResponse,
|
||||||
}
|
}
|
||||||
|
async_commands {
|
||||||
|
ca_status(CaStatusRequest) -> CaStatus,
|
||||||
|
ca_trust(CaTrustRequest) -> CaStatus,
|
||||||
|
filter_body(FilterBodyRequest) -> FilterBodyResponse,
|
||||||
|
get_flow_body(GetFlowBodyRequest) -> GetFlowBodyResponse,
|
||||||
|
}
|
||||||
events {
|
events {
|
||||||
model_write(ModelPayload),
|
model_write(ModelPayload),
|
||||||
proxy_state_changed(ProxyStatePayload),
|
proxy_state_changed(ProxyStatePayload),
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ pub struct Flow {
|
|||||||
pub error: Option<String>,
|
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 {
|
pub enum FlowBody {
|
||||||
Request,
|
Request,
|
||||||
Response,
|
Response,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ use std::io::Write;
|
|||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use flate2::Compression;
|
use flate2::Compression;
|
||||||
use flate2::write::GzEncoder;
|
use flate2::write::GzEncoder;
|
||||||
@@ -12,9 +14,10 @@ use hyper::server::conn::http1;
|
|||||||
use hyper::service::service_fn;
|
use hyper::service::service_fn;
|
||||||
use hyper::{Request, Response};
|
use hyper::{Request, Response};
|
||||||
use hyper_util::rt::TokioIo;
|
use hyper_util::rt::TokioIo;
|
||||||
|
use serde_json::{Value, json};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use yaak_proxy_lib::ProxyCtx;
|
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::models::{Flow, FlowBody};
|
||||||
use yaak_proxy_lib::settings::ProxySettings;
|
use yaak_proxy_lib::settings::ProxySettings;
|
||||||
use yaak_rpc::RpcEventEmitter;
|
use yaak_rpc::RpcEventEmitter;
|
||||||
@@ -48,6 +51,27 @@ async fn route(req: Request<Incoming>) -> Result<Response<Full<Bytes>>, Infallib
|
|||||||
});
|
});
|
||||||
Response::new(Full::from("ok"))
|
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])),
|
_ => Response::new(Full::from(vec![b'x'; BIG_BODY])),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -58,7 +82,9 @@ async fn spawn_upstream() -> SocketAddr {
|
|||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
let (tcp, _) = listener.accept().await.unwrap();
|
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
|
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::Request).unwrap(), b"hello proxy");
|
||||||
assert_eq!(read_raw_body(&ctx.blobs, &flow.id, FlowBody::Response).unwrap(), compressed);
|
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 big_url = format!("http://{upstream}/big");
|
||||||
let resp = client.get(&big_url).send().await.unwrap();
|
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!(flow.request_size >= stored, "{} < {stored}", flow.request_size);
|
||||||
assert_eq!(flow.request_truncated, flow.request_size > stored);
|
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
|
/// - `RpcSchema` — a struct with ts-rs derives for TypeScript type generation
|
||||||
/// - `RpcEventSchema` — (if events declared) a struct mapping event names to payload types
|
/// - `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)`.
|
/// The wire name for each command/event is derived from `stringify!($ident)`.
|
||||||
///
|
///
|
||||||
/// # Example
|
/// # Example
|
||||||
@@ -162,6 +165,9 @@ macro_rules! define_rpc {
|
|||||||
commands {
|
commands {
|
||||||
$( $handler:ident ( $req:ty ) -> $res:ty ),* $(,)?
|
$( $handler:ident ( $req:ty ) -> $res:ty ),* $(,)?
|
||||||
}
|
}
|
||||||
|
$( async_commands {
|
||||||
|
$( $async_handler:ident ( $async_req:ty ) -> $async_res:ty ),* $(,)?
|
||||||
|
} )?
|
||||||
events {
|
events {
|
||||||
$( $evt_ident:ident ( $evt_payload:ty ) ),* $(,)?
|
$( $evt_ident:ident ( $evt_payload:ty ) ),* $(,)?
|
||||||
}
|
}
|
||||||
@@ -169,6 +175,7 @@ macro_rules! define_rpc {
|
|||||||
pub fn build_router() -> $crate::RpcRouter<$ctx> {
|
pub fn build_router() -> $crate::RpcRouter<$ctx> {
|
||||||
let mut router = $crate::RpcRouter::new();
|
let mut router = $crate::RpcRouter::new();
|
||||||
$( router.register(stringify!($handler), $crate::rpc_handler!($handler)); )*
|
$( router.register(stringify!($handler), $crate::rpc_handler!($handler)); )*
|
||||||
|
$( $( router.register(stringify!($async_handler), $crate::rpc_handler_async!($async_handler)); )* )?
|
||||||
router
|
router
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +183,7 @@ macro_rules! define_rpc {
|
|||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
pub struct RpcSchema {
|
pub struct RpcSchema {
|
||||||
$( pub $handler: ($req, $res), )*
|
$( pub $handler: ($req, $res), )*
|
||||||
|
$( $( pub $async_handler: ($async_req, $async_res), )* )?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(ts_rs::TS)]
|
#[derive(ts_rs::TS)]
|
||||||
@@ -191,10 +199,14 @@ macro_rules! define_rpc {
|
|||||||
commands {
|
commands {
|
||||||
$( $handler:ident ( $req:ty ) -> $res:ty ),* $(,)?
|
$( $handler:ident ( $req:ty ) -> $res:ty ),* $(,)?
|
||||||
}
|
}
|
||||||
|
$( async_commands {
|
||||||
|
$( $async_handler:ident ( $async_req:ty ) -> $async_res:ty ),* $(,)?
|
||||||
|
} )?
|
||||||
) => {
|
) => {
|
||||||
pub fn build_router() -> $crate::RpcRouter<$ctx> {
|
pub fn build_router() -> $crate::RpcRouter<$ctx> {
|
||||||
let mut router = $crate::RpcRouter::new();
|
let mut router = $crate::RpcRouter::new();
|
||||||
$( router.register(stringify!($handler), $crate::rpc_handler!($handler)); )*
|
$( router.register(stringify!($handler), $crate::rpc_handler!($handler)); )*
|
||||||
|
$( $( router.register(stringify!($async_handler), $crate::rpc_handler_async!($async_handler)); )* )?
|
||||||
router
|
router
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +214,7 @@ macro_rules! define_rpc {
|
|||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
pub struct RpcSchema {
|
pub struct RpcSchema {
|
||||||
$( pub $handler: ($req, $res), )*
|
$( pub $handler: ($req, $res), )*
|
||||||
|
$( $( pub $async_handler: ($async_req, $async_res), )* )?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+3
-1
@@ -246,6 +246,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.104.2",
|
"@tanstack/react-query": "^5.104.2",
|
||||||
|
"@tanstack/react-virtual": "^3.14.17",
|
||||||
"@tauri-apps/api": "^2.12.3",
|
"@tauri-apps/api": "^2.12.3",
|
||||||
"@tauri-apps/plugin-os": "^2.4.0",
|
"@tauri-apps/plugin-os": "^2.4.0",
|
||||||
"@yaakapp-internal/model-store": "^1.0.0",
|
"@yaakapp-internal/model-store": "^1.0.0",
|
||||||
@@ -257,7 +258,8 @@
|
|||||||
"jotai-family": "^1.0.1",
|
"jotai-family": "^1.0.1",
|
||||||
"motion": "^14.0.0",
|
"motion": "^14.0.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0",
|
||||||
|
"vkbeautify": "^0.99.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rolldown/plugin-babel": "^0.2.3",
|
"@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,
|
RotateCcwIcon,
|
||||||
Rows2Icon,
|
Rows2Icon,
|
||||||
SaveIcon,
|
SaveIcon,
|
||||||
|
ScissorsIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
SendHorizontalIcon,
|
SendHorizontalIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
@@ -264,6 +265,7 @@ const icons = {
|
|||||||
rotate_ccw: RotateCcwIcon,
|
rotate_ccw: RotateCcwIcon,
|
||||||
rows_2: Rows2Icon,
|
rows_2: Rows2Icon,
|
||||||
save: SaveIcon,
|
save: SaveIcon,
|
||||||
|
scissors: ScissorsIcon,
|
||||||
search: SearchIcon,
|
search: SearchIcon,
|
||||||
send_horizontal: SendHorizontalIcon,
|
send_horizontal: SendHorizontalIcon,
|
||||||
settings: SettingsIcon,
|
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 type { DetailsBannerProps } from "./components/DetailsBanner";
|
||||||
export { DetailsBanner } from "./components/DetailsBanner";
|
export { DetailsBanner } from "./components/DetailsBanner";
|
||||||
export { DropMarker } from "./components/DropMarker";
|
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 { LazyCodeEditor } from "./components/editor/LazyCodeEditor";
|
||||||
|
export type { ErrorBoundaryProps } from "./components/ErrorBoundary";
|
||||||
|
export { ErrorBoundary } from "./components/ErrorBoundary";
|
||||||
export { FormattedError } from "./components/FormattedError";
|
export { FormattedError } from "./components/FormattedError";
|
||||||
export { HeaderSize } from "./components/HeaderSize";
|
export { HeaderSize } from "./components/HeaderSize";
|
||||||
export type { HeadersTableProps } from "./components/HeadersTable";
|
export type { HeadersTableProps } from "./components/HeadersTable";
|
||||||
export { HeadersTable } from "./components/HeadersTable";
|
export { HeadersTable } from "./components/HeadersTable";
|
||||||
export { Heading } from "./components/Heading";
|
export { Heading } from "./components/Heading";
|
||||||
|
export { Hotkey, HotkeyRaw } from "./components/Hotkey";
|
||||||
export type { IconProps } from "./components/Icon";
|
export type { IconProps } from "./components/Icon";
|
||||||
export { Icon } from "./components/Icon";
|
export { Icon } from "./components/Icon";
|
||||||
export type { IconButtonProps } from "./components/IconButton";
|
export type { IconButtonProps } from "./components/IconButton";
|
||||||
@@ -28,6 +41,8 @@ export { Label } from "./components/Label";
|
|||||||
export { LoadingIcon } from "./components/LoadingIcon";
|
export { LoadingIcon } from "./components/LoadingIcon";
|
||||||
export { Overlay } from "./components/Overlay";
|
export { Overlay } from "./components/Overlay";
|
||||||
export { Portal } from "./components/Portal";
|
export { Portal } from "./components/Portal";
|
||||||
|
export type { SeparatorAction } from "./components/Separator";
|
||||||
|
export { Separator } from "./components/Separator";
|
||||||
export type { ResizeHandleEvent } from "./components/ResizeHandle";
|
export type { ResizeHandleEvent } from "./components/ResizeHandle";
|
||||||
export { ResizeHandle } from "./components/ResizeHandle";
|
export { ResizeHandle } from "./components/ResizeHandle";
|
||||||
export { SidebarLayout } from "./components/SidebarLayout";
|
export { SidebarLayout } from "./components/SidebarLayout";
|
||||||
@@ -77,9 +92,17 @@ export { TextViewer } from "./components/viewers/TextViewer";
|
|||||||
export { VideoViewer } from "./components/viewers/VideoViewer";
|
export { VideoViewer } from "./components/viewers/VideoViewer";
|
||||||
export { WebPageViewer } from "./components/viewers/WebPageViewer";
|
export { WebPageViewer } from "./components/viewers/WebPageViewer";
|
||||||
export { WindowControls } from "./components/WindowControls";
|
export { WindowControls } from "./components/WindowControls";
|
||||||
|
export { useClickOutside } from "./hooks/useClickOutside";
|
||||||
export { useContainerSize } from "./hooks/useContainerSize";
|
export { useContainerSize } from "./hooks/useContainerSize";
|
||||||
export { useDebouncedState } from "./hooks/useDebouncedState";
|
export { useDebouncedState } from "./hooks/useDebouncedState";
|
||||||
export { useDebouncedValue } from "./hooks/useDebouncedValue";
|
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 { useIsFullscreen } from "./hooks/useIsFullscreen";
|
||||||
export { usePortal } from "./hooks/usePortal";
|
export { usePortal } from "./hooks/usePortal";
|
||||||
export { useStateWithDeps } from "./hooks/useStateWithDeps";
|
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 { languageFromContent, languageFromContentType } from "./lib/contentType";
|
||||||
export { computeSideForDragMove } from "./lib/dnd";
|
export { computeSideForDragMove } from "./lib/dnd";
|
||||||
export { docFingerprint } from "./lib/docFingerprint";
|
export { docFingerprint } from "./lib/docFingerprint";
|
||||||
|
export { getNodeText } from "./lib/getNodeText";
|
||||||
export { minPromiseMillis } from "./lib/minPromiseMillis";
|
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