No traffic yet;
- }
-
- return (
-
-
-
-
- Method
- URL
- Status
- Type
-
-
-
- {exchanges.map((ex) => (
-
- {ex.method}
-
- {ex.url}
-
-
-
-
-
- {getContentType(ex.contentType)}
-
-
- ))}
-
-
-
- );
-}
-
-function StatusBadge({ status, error }: { status: number | null; error: string | null }) {
- if (error) return
Error ;
- if (status == null) return
— ;
-
- const color =
- status >= 500
- ? "text-danger"
- : status >= 400
- ? "text-warning"
- : status >= 300
- ? "text-notice"
- : "text-success";
-
- return
{status} ;
-}
-
-function getContentType(ct: string | null): string {
- if (ct == null) return "—";
- // Strip parameters (e.g. "; charset=utf-8")
- return ct.split(";")[0]?.trim() ?? ct;
-}
diff --git a/apps/yaak-proxy/components/FlowBodyViewer.tsx b/apps/yaak-proxy/components/FlowBodyViewer.tsx
new file mode 100644
index 000000000..133ff4c96
--- /dev/null
+++ b/apps/yaak-proxy/components/FlowBodyViewer.tsx
@@ -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 (
+
+
+
+ );
+ }
+ if (body.error) {
+ return
{body.error.message} ;
+ }
+
+ const { bytes, truncated, storedSize } = body.data;
+ if (bytes.length === 0) {
+ return
{which === "request" ? "No request body" : "Empty"} ;
+ }
+
+ 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 =
;
+ } else if (mime.startsWith("image/")) {
+ viewer =
;
+ } else if (mime.startsWith("multipart/") && pretty) {
+ const boundary = contentType?.match(/;\s*boundary="?([^";]+)"?/i)?.[1] ?? "unknown";
+ viewer = (
+
+ );
+ } else if (/csv|tab-separated/.test(mime) && pretty) {
+ viewer =
;
+ } else {
+ const text = bytesToText(bytes, truncated);
+ const language = languageFromContentType(contentType, text);
+ if (text == null) {
+ viewer =
Binary body, {formatSize(bytes.length)} ;
+ } else if (language === "html" && pretty && which === "response") {
+ viewer =
;
+ } else {
+ viewer = (
+
+ );
+ }
+ }
+
+ if (!truncated) return viewer;
+
+ return (
+
+
+ {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.`}
+
+
{viewer}
+
+ );
+}
+
+/** 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 (
+
+ );
+}
+
+function ImageBody({ bytes, mime }: { bytes: Uint8Array
; 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 (
+
+
+
+
+
+ {size != null && `${size.width} × ${size.height} • `}
+ {mime} • {formatSize(bytes.length)}
+
+
+ );
+}
+
+export function EmptyState({ children }: { children: ReactNode }) {
+ return (
+
+ );
+}
diff --git a/apps/yaak-proxy/components/FlowDetail.tsx b/apps/yaak-proxy/components/FlowDetail.tsx
new file mode 100644
index 000000000..1dc644739
--- /dev/null
+++ b/apps/yaak-proxy/components/FlowDetail.tsx
@@ -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 (
+
+
+ {flow == null ? (
+
+
+ Select a flow
+
+ Start the proxy
+
+ Stop the proxy
+
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+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(
+ () => [
+ {
+ value: TAB_RESPONSE,
+ renderButton: ({ buttonProps, isActive }) => (
+
+ ),
+ },
+ {
+ value: TAB_REQUEST,
+ label: "Request",
+ rightSlot: flow.requestSize > 0 ? : null,
+ },
+ {
+ value: TAB_HEADERS,
+ label: "Headers",
+ rightSlot: (
+
+ ),
+ },
+ {
+ value: TAB_QUERY,
+ label: "Query",
+ rightSlot: ,
+ },
+ ],
+ [
+ pretty,
+ mime,
+ flow.requestSize,
+ flow.requestHeaders.length,
+ flow.responseHeaders.length,
+ queryCount,
+ ],
+ );
+
+ return (
+
+
+
+
+ {flow.elapsedMs != null && (
+ <>
+ •
+
+ {formatMillis(flow.elapsedMs)}
+
+ >
+ )}
+ {!failed && (
+ <>
+ •
+
+ >
+ )}
+
+ {flow.responseTruncated ? (
+
+
+ {responseBody.data == null
+ ? "Body cut"
+ : `First ${formatSize(responseBody.data.storedSize)} kept`}
+
+ ) : (
+
+ )}
+
+
+
+ {failed && (
+
+ {flow.error}
+
+ )}
+
+
+ {failed ? (
+
+ No response
+
+ The request never got a response. The Request tab has what the app sent.
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+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: ,
+ onSelect: () => onChangePretty(true),
+ },
+ {
+ label: `${prettyLabel} (Raw)`,
+ hidden: !allowRaw,
+ leftSlot: ,
+ onSelect: () => onChangePretty(false),
+ },
+ ];
+
+ return (
+
+ {
+ if (isActive) return;
+ e.preventDefault();
+ buttonProps.onClick?.(e);
+ }}
+ rightSlot={
+
+ }
+ >
+ {showPretty ? prettyLabel : "Raw"}
+
+
+ );
+}
+
+function CheckMark({ visible }: { visible: boolean }) {
+ return ;
+}
+
+function Keys({ keys }: { keys: string[] }) {
+ return (
+
+ {keys.map((k) => (
+
+ {k}
+
+ ))}
+
+ );
+}
diff --git a/apps/yaak-proxy/components/FlowHeaders.tsx b/apps/yaak-proxy/components/FlowHeaders.tsx
new file mode 100644
index 000000000..01d8c7496
--- /dev/null
+++ b/apps/yaak-proxy/components/FlowHeaders.tsx
@@ -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 (
+
+ Info}>
+
+
+ {startedFormat.format(new Date(`${flow.startedAt}Z`))}
+
+ {flow.url}
+ {flow.remoteAddr ?? }
+ {versions}
+
+ {flow.elapsedMs == null ? (
+
+ ) : flow.elapsedHeadersMs == null ? (
+ `${formatMillis(flow.elapsedMs)} in all`
+ ) : (
+ `${formatMillis(flow.elapsedMs)} in all, headers after ${formatMillis(flow.elapsedHeadersMs)}`
+ )}
+
+
+
+ {formatSize(flow.requestSize)} sent, {formatSize(flow.responseSize)} received{" "}
+
+ ({bytesFormat.format(flow.responseSize)} B)
+
+
+
+
+
+
+
+
+ );
+}
+
+export function FlowQuery({ flow }: { flow: Flow }) {
+ const params = useMemo(() => queryParams(flow.url), [flow.url]);
+
+ if (params.length === 0) {
+ return No query parameters ;
+ }
+
+ return (
+
+
+ {params.map(([name, value], i) => (
+ // oxlint-disable-next-line react/no-array-index-key -- a name can repeat
+
+ {value}
+
+ ))}
+
+
+ );
+}
+
+export function queryParams(url: string): [string, string][] {
+ try {
+ return [...new URL(url).searchParams.entries()];
+ } catch {
+ return [];
+ }
+}
+
+function Missing() {
+ return -- ;
+}
diff --git a/apps/yaak-proxy/components/FlowList.tsx b/apps/yaak-proxy/components/FlowList.tsx
new file mode 100644
index 000000000..84aae5820
--- /dev/null
+++ b/apps/yaak-proxy/components/FlowList.tsx
@@ -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(null);
+ const ignoreScroll = useRef(false);
+ const [following, setFollowing] = useState(true);
+ const [heldAt, setHeldAt] = useState(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) => {
+ 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 (
+
+
+
+ Method
+ Host
+ Path
+ Status
+ {!sideBySide && Type }
+ {!sideBySide && Size }
+ Time
+
+
+
+ {flows.length === 0 ? (
+
No traffic yet
+ ) : (
+
+ {virtualizer.getVirtualItems().map((item) => {
+ const flow = flows[item.index];
+ if (flow == null) return null;
+ return (
+
+ );
+ })}
+
+ )}
+
+ {newCount > 0 && (
+
+
+ {newCount === 1 ? "1 new flow" : `${newCount} new flows`}
+
+ )}
+
+
+ );
+}
+
+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 (
+ 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",
+ )}
+ >
+
+ {failed ? (
+
+ ) : flow.responseTruncated ? (
+
+ ) : null}
+
+
+ {host}
+
+ {path}
+ {query}
+
+
+ {!sideBySide &&
+ (failed ? (
+
+ {flow.error}
+
+ ) : (
+
+ {type ?? -- }
+
+ ))}
+ {!sideBySide && (
+
+ {failed ? -- : formatSize(flow.responseSize)}
+
+ )}
+
+ {flow.elapsedMs == null ? "--" : formatMillis(flow.elapsedMs)}
+
+
+ );
+});
+
+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: "" };
+ }
+}
diff --git a/apps/yaak-proxy/components/ProxyLayout.tsx b/apps/yaak-proxy/components/ProxyLayout.tsx
index 9a8f5f8d7..cfb562e84 100644
--- a/apps/yaak-proxy/components/ProxyLayout.tsx
+++ b/apps/yaak-proxy/components/ProxyLayout.tsx
@@ -1,17 +1,20 @@
import { HeaderSize, IconButton, SidebarLayout, SplitLayout } from "@yaakapp-internal/ui";
import classNames from "classnames";
-import { useAtomValue } from "jotai";
+import { useAtom, useAtomValue } from "jotai";
import { useState } from "react";
import { useLocalStorage } from "react-use";
-import { useRpcQueryWithEvent } from "../hooks/useRpcQueryWithEvent";
+import { flowLayoutAtom } from "../lib/atoms";
import { getOsType } from "../lib/tauri";
-import { ActionIconButton } from "./ActionIconButton";
-import { ExchangesTable } from "./ExchangesTable";
-import { filteredExchangesAtom, Sidebar } from "./Sidebar";
+import { FlowDetail } from "./FlowDetail";
+import { FlowList } from "./FlowList";
+import { ProxyStatus } from "./ProxyStatus";
+import { filteredExchangesAtom, selectedSidebarLabelAtom, Sidebar } from "./Sidebar";
export function ProxyLayout() {
const os = getOsType();
const exchanges = useAtomValue(filteredExchangesAtom);
+ const title = useAtomValue(selectedSidebarLabelAtom);
+ const [flowLayout, setFlowLayout] = useAtom(flowLayoutAtom);
const [sidebarWidth, setSidebarWidth] = useLocalStorage("sidebar_width", 250);
const [sidebarHidden, setSidebarHidden] = useLocalStorage("sidebar_hidden", false);
const [floatingSidebarHidden, setFloatingSidebarHidden] = useLocalStorage(
@@ -19,14 +22,12 @@ export function ProxyLayout() {
true,
);
const [floating, setFloating] = useState(false);
- const { data: proxyState } = useRpcQueryWithEvent("get_proxy_state", {}, "proxy_state_changed");
- const isRunning = proxyState?.state === "running";
const isHidden = floating ? (floatingSidebarHidden ?? true) : (sidebarHidden ?? false);
return (
@@ -57,29 +58,25 @@ export function ProxyLayout() {
- Yaak Proxy
+ {title}
+
+ {exchanges.length === 1 ? "1 flow" : `${exchanges.length} flows`}
+
-
- {isRunning ? (
- <>
-
Running :9090
-
- >
- ) : (
-
- )}
+
+
+
+ setFlowLayout((prev) => (prev === "horizontal" ? "vertical" : "horizontal"))
+ }
+ />
@@ -126,18 +123,19 @@ export function ProxyLayout() {
>
(
-
- )}
- secondSlot={({ style }) => (
- (
+
- Select a request to view details
-
+ className="pt-1"
+ />
+ )}
+ secondSlot={({ style, orientation }) => (
+
)}
/>
diff --git a/apps/yaak-proxy/components/ProxyStatus.tsx b/apps/yaak-proxy/components/ProxyStatus.tsx
new file mode 100644
index 000000000..d3766ff3e
--- /dev/null
+++ b/apps/yaak-proxy/components/ProxyStatus.tsx
@@ -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(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(["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: (
+ }
+ 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 ? (
+ }
+ title="Certificate trusted"
+ description="Yaak Proxy CA, in your login keychain."
+ />
+ ) : (
+ }
+ title="Certificate not trusted"
+ description={
+ trust.error != null ? (
+ {trust.error.message}
+ ) : trust.isPending ? (
+ "Waiting for your password in the macOS prompt."
+ ) : (
+ "So HTTPS reads as plain text. macOS asks for your login password once."
+ )
+ }
+ action={
+ {
+ e.stopPropagation();
+ trust.mutate({});
+ }}
+ >
+ {trust.error != null ? "Try again" : "Trust certificate"}
+
+ }
+ />
+ ),
+ },
+ { 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 && (
+ dropdownRef.current?.open()}>
+
+ Certificate not trusted
+
+ )}
+
+
+ {running ? (
+ <>
+
+ Running
+ {port != null && :{port} }
+ >
+ ) : (
+ <>
+
+ Stopped
+ >
+ )}
+
+
+
+ >
+ );
+}
+
+interface PillProps extends HTMLAttributes {
+ color?: "warning";
+ ref?: Ref;
+}
+
+function Pill({ children, color, ref, ...props }: PillProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+function StateDot({ running }: { running: boolean }) {
+ return (
+
+ );
+}
+
+function MenuRow({
+ icon,
+ title,
+ description,
+ action,
+}: {
+ icon: ReactNode;
+ title: string;
+ description: ReactNode;
+ action?: ReactNode;
+}) {
+ return (
+
+
{icon}
+
+
{title}
+
{description}
+
+
{action}
+
+ );
+}
diff --git a/apps/yaak-proxy/components/Sidebar.tsx b/apps/yaak-proxy/components/Sidebar.tsx
index 4d1631d82..fdf391d41 100644
--- a/apps/yaak-proxy/components/Sidebar.tsx
+++ b/apps/yaak-proxy/components/Sidebar.tsx
@@ -50,6 +50,14 @@ export const filteredExchangesAtom = atom((get) => {
return exchanges.filter((ex) => allowedIds.has(ex.id));
});
+/** The selected sidebar node's name, for the header */
+export const selectedSidebarLabelAtom = atom((get) => {
+ const selectedId = get(selectedIdsFamily(SIDEBAR_TREE_ID))[0];
+ if (selectedId?.startsWith("domain:")) return selectedId.slice("domain:".length);
+ if (selectedId?.startsWith("path:")) return selectedId.slice("path:".length);
+ return "All traffic";
+});
+
function collectNodes(node: TreeNode, map: Map) {
map.set(node.item.id, node.item);
for (const child of node.children ?? []) {
@@ -204,7 +212,7 @@ export function Sidebar() {
return (
-
+
(null);
+
+/** Stacked by default; the header's toggle switches to side by side */
+export const flowLayoutAtom = atomWithStorage("flow_layout", "vertical");
diff --git a/apps/yaak-proxy/lib/body.test.ts b/apps/yaak-proxy/lib/body.test.ts
new file mode 100644
index 000000000..48c83d61c
--- /dev/null
+++ b/apps/yaak-proxy/lib/body.test.ts
@@ -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");
+ });
+});
diff --git a/apps/yaak-proxy/lib/body.ts b/apps/yaak-proxy/lib/body.ts
new file mode 100644
index 000000000..b3129dfb3
--- /dev/null
+++ b/apps/yaak-proxy/lib/body.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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;
+}
diff --git a/apps/yaak-proxy/lib/follow.test.ts b/apps/yaak-proxy/lib/follow.test.ts
new file mode 100644
index 000000000..9db5bf3a7
--- /dev/null
+++ b/apps/yaak-proxy/lib/follow.test.ts
@@ -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);
+ });
+});
diff --git a/apps/yaak-proxy/lib/follow.ts b/apps/yaak-proxy/lib/follow.ts
new file mode 100644
index 000000000..354e1abb6
--- /dev/null
+++ b/apps/yaak-proxy/lib/follow.ts
@@ -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[], 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;
+}
diff --git a/apps/yaak-proxy/lib/hotkeys.ts b/apps/yaak-proxy/lib/hotkeys.ts
index 58351377b..06bb9512c 100644
--- a/apps/yaak-proxy/lib/hotkeys.ts
+++ b/apps/yaak-proxy/lib/hotkeys.ts
@@ -1,14 +1,44 @@
-import type { ActionInvocation, ActionMetadata } from "@yaakapp-internal/proxy-lib";
+import type { ActionInvocation, GlobalAction } from "@yaakapp-internal/proxy-lib";
+import type { HotkeyContextValue, HotKeyOptions } from "@yaakapp-internal/ui";
+import { useEffect, useMemo } from "react";
+import { useRpcQuery } from "../hooks/useRpcQuery";
import { rpc } from "./rpc";
+import { getOsType } from "./tauri";
-type ActionBinding = {
- invocation: ActionInvocation;
- meta: ActionMetadata;
- keys: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean };
-};
+declare module "@yaakapp-internal/ui" {
+ interface HotkeyActionRegistry {
+ action: GlobalAction;
+ }
+}
+
+type Keys = { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean };
+
+type Binding = { invocation: ActionInvocation; hotkey: string; keys: Keys };
+
+interface Callback {
+ action: GlobalAction;
+ callback: (e: KeyboardEvent) => void;
+ options: HotKeyOptions;
+}
+
+let callbacks: Callback[] = [];
+
+function register(
+ action: GlobalAction,
+ callback: (e: KeyboardEvent) => void,
+ options: HotKeyOptions,
+) {
+ callbacks = [
+ ...callbacks.filter((cb) => cb.action !== action || cb.options.priority !== options.priority),
+ { action, callback, options },
+ ].sort((a, b) => (b.options.priority ?? 0) - (a.options.priority ?? 0));
+ return () => {
+ callbacks = callbacks.filter((cb) => cb.callback !== callback);
+ };
+}
/** Parse a hotkey string like "Ctrl+Shift+P" into its parts. */
-function parseHotkey(hotkey: string): ActionBinding["keys"] {
+function parseHotkey(hotkey: string): Keys {
const parts = hotkey.split("+").map((p) => p.trim().toLowerCase());
return {
ctrl: parts.includes("ctrl") || parts.includes("control"),
@@ -22,42 +52,64 @@ function parseHotkey(hotkey: string): ActionBinding["keys"] {
};
}
-function matchesEvent(binding: ActionBinding["keys"], e: KeyboardEvent): boolean {
+function matchesEvent(keys: Keys, e: KeyboardEvent): boolean {
return (
- e.ctrlKey === binding.ctrl &&
- e.shiftKey === binding.shift &&
- e.altKey === binding.alt &&
- e.metaKey === binding.meta &&
- e.key.toLowerCase() === binding.key
+ e.ctrlKey === keys.ctrl &&
+ e.shiftKey === keys.shift &&
+ e.altKey === keys.alt &&
+ e.metaKey === keys.meta &&
+ e.key.toLowerCase() === keys.key
);
}
-/** Fetch all actions from Rust and register a global keydown listener. */
-export async function initHotkeys(): Promise<() => void> {
- const { actions } = await rpc("list_actions", {});
+/**
+ * Hotkeys from the actions' metadata: a key runs the component bound to its action if one is,
+ * and the action itself otherwise.
+ */
+export function useHotkeyContextValue(): HotkeyContextValue {
+ const { data } = useRpcQuery("list_actions", {});
- const bindings: ActionBinding[] = actions
- .filter(
- // oxlint-disable-next-line no-redundant-type-constituents -- ActionMetadata resolves at runtime
- (entry): entry is [ActionInvocation, ActionMetadata & { defaultHotkey: string }] =>
- entry[1].defaultHotkey != null,
- )
- .map(([invocation, meta]) => ({
- invocation,
- meta,
- keys: parseHotkey(meta.defaultHotkey),
- }));
+ const bindings = useMemo(() => {
+ const map = new Map();
+ for (const [invocation, meta] of data?.actions ?? []) {
+ if (meta.defaultHotkey == null) continue;
+ const hotkey = meta.defaultHotkey.replace(/\bCtrl\b/, "Control");
+ map.set(invocation.action, { invocation, hotkey, keys: parseHotkey(hotkey) });
+ }
+ return map;
+ }, [data]);
- function onKeyDown(e: KeyboardEvent) {
- for (const binding of bindings) {
- if (matchesEvent(binding.keys, e)) {
+ useEffect(() => {
+ function onKeyDown(e: KeyboardEvent) {
+ for (const { action, callback, options } of callbacks) {
+ const enable = typeof options.enable === "function" ? options.enable() : options.enable;
+ const binding = bindings.get(action);
+ if (enable === false || binding == null || !matchesEvent(binding.keys, e)) continue;
+ if (!options.allowDefault) e.preventDefault();
+ callback(e);
+ return;
+ }
+ for (const binding of bindings.values()) {
+ if (!matchesEvent(binding.keys, e)) continue;
e.preventDefault();
void rpc("execute_action", binding.invocation);
return;
}
}
- }
- window.addEventListener("keydown", onKeyDown);
- return () => window.removeEventListener("keydown", onKeyDown);
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [bindings]);
+
+ return useMemo(
+ () => ({
+ os: getOsType(),
+ bindingFor: (action) => {
+ const hotkey = bindings.get(action)?.hotkey;
+ return hotkey == null ? null : [hotkey];
+ },
+ register,
+ }),
+ [bindings],
+ );
}
diff --git a/apps/yaak-proxy/lib/rpc.ts b/apps/yaak-proxy/lib/rpc.ts
index fa31c4fcc..0c3d6fec0 100644
--- a/apps/yaak-proxy/lib/rpc.ts
+++ b/apps/yaak-proxy/lib/rpc.ts
@@ -4,8 +4,13 @@ import { command, subscribe } from "./tauri";
export type Req = RpcSchema[K][0];
export type Res = RpcSchema[K][1];
+/** Call a backend command. A failure rejects with an `Error`, never the bare string Tauri gives. */
export async function rpc(cmd: K, payload: Req): Promise> {
- return command>("rpc", { cmd, payload });
+ try {
+ return await command>("rpc", { cmd, payload });
+ } catch (err) {
+ throw err instanceof Error ? err : new Error(String(err));
+ }
}
/** Subscribe to a backend event. Returns an unsubscribe function. */
diff --git a/apps/yaak-proxy/lib/statusReason.ts b/apps/yaak-proxy/lib/statusReason.ts
new file mode 100644
index 000000000..fd306426c
--- /dev/null
+++ b/apps/yaak-proxy/lib/statusReason.ts
@@ -0,0 +1,41 @@
+const reasons: Record = {
+ 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;
+}
diff --git a/apps/yaak-proxy/lib/store.ts b/apps/yaak-proxy/lib/store.ts
index 5fbea515d..f1fc03e72 100644
--- a/apps/yaak-proxy/lib/store.ts
+++ b/apps/yaak-proxy/lib/store.ts
@@ -8,4 +8,5 @@ type ProxyModels = {
export const { dataAtom, applyChange, replaceAll, listAtom, orderedListAtom } =
createModelStore(["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");
diff --git a/apps/yaak-proxy/main.css b/apps/yaak-proxy/main.css
index da1e53f1f..21b27e572 100644
--- a/apps/yaak-proxy/main.css
+++ b/apps/yaak-proxy/main.css
@@ -12,6 +12,7 @@
:root {
--font-family-interface: "";
--font-family-editor: "";
+ --editor-font-size: 12px;
}
:root {
diff --git a/apps/yaak-proxy/main.tsx b/apps/yaak-proxy/main.tsx
index 323c86350..9ca698838 100644
--- a/apps/yaak-proxy/main.tsx
+++ b/apps/yaak-proxy/main.tsx
@@ -1,11 +1,12 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createStore, Provider } from "jotai";
import { LazyMotion, MotionConfig } from "motion/react";
-import { StrictMode } from "react";
+import { HotkeyProvider } from "@yaakapp-internal/ui";
+import { type ReactNode, StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ProxyLayout } from "./components/ProxyLayout";
+import { useHotkeyContextValue } from "./lib/hotkeys";
import { listen, rpc } from "./lib/rpc";
-import { initHotkeys } from "./lib/hotkeys";
import { applyChange, dataAtom, replaceAll } from "./lib/store";
import "./main.css";
@@ -17,25 +18,28 @@ void rpc("list_models", {}).then((res) => {
jotaiStore.set(dataAtom, (prev) => replaceAll(prev, "flow", res.flows));
});
-// Register hotkeys from action metadata
-void initHotkeys();
-
// Subscribe to model change events from the backend
void listen("model_write", (payload) => {
jotaiStore.set(dataAtom, (prev) => applyChange(prev, "flow", payload.model, payload.change));
});
+function Hotkeys({ children }: { children: ReactNode }) {
+ return {children} ;
+}
+
const motionFeatures = () => import("framer-motion").then((mod) => mod.domAnimation);
createRoot(document.getElementById("root") as HTMLElement).render(
-
-
-
-
-
+
+
+
+
+
+
+
,
diff --git a/apps/yaak-proxy/modules.d.ts b/apps/yaak-proxy/modules.d.ts
new file mode 100644
index 000000000..1ba602b91
--- /dev/null
+++ b/apps/yaak-proxy/modules.d.ts
@@ -0,0 +1 @@
+declare module "vkbeautify";
diff --git a/apps/yaak-proxy/package.json b/apps/yaak-proxy/package.json
index b8dc036de..0a659820d 100644
--- a/apps/yaak-proxy/package.json
+++ b/apps/yaak-proxy/package.json
@@ -10,6 +10,7 @@
},
"dependencies": {
"@tanstack/react-query": "^5.104.2",
+ "@tanstack/react-virtual": "^3.14.17",
"@tauri-apps/api": "^2.12.3",
"@tauri-apps/plugin-os": "^2.4.0",
"@yaakapp-internal/model-store": "^1.0.0",
@@ -21,7 +22,8 @@
"jotai-family": "^1.0.1",
"motion": "^14.0.0",
"react": "^19.2.0",
- "react-dom": "^19.2.0"
+ "react-dom": "^19.2.0",
+ "vkbeautify": "^0.99.3"
},
"devDependencies": {
"@rolldown/plugin-babel": "^0.2.3",
diff --git a/crates-proxy/yaak-proxy-lib/Cargo.toml b/crates-proxy/yaak-proxy-lib/Cargo.toml
index 3753adedf..aeb7233b2 100644
--- a/crates-proxy/yaak-proxy-lib/Cargo.toml
+++ b/crates-proxy/yaak-proxy-lib/Cargo.toml
@@ -6,6 +6,7 @@ authors = ["Gregory Schier"]
publish = false
[dependencies]
+base64 = "0.23.1"
chrono = { workspace = true, features = ["serde"] }
log = { workspace = true }
include_dir = "0.7"
@@ -15,15 +16,18 @@ rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
-tokio = { workspace = true, features = ["io-util"] }
+tokio = { workspace = true, features = ["io-util", "process"] }
ts-rs = { workspace = true, features = ["chrono-impl"] }
yaak-database = { workspace = true }
+yaak-filter = { workspace = true }
yaak-http = { workspace = true }
yaak-models = { workspace = true }
yaak-proxy = { workspace = true }
yaak-rpc = { workspace = true }
+yaak-templates = { workspace = true }
[dev-dependencies]
+base64 = "0.23.1"
bytes = "1"
flate2 = "1"
http-body-util = "0.1"
diff --git a/crates-proxy/yaak-proxy-lib/bindings/gen_models.ts b/crates-proxy/yaak-proxy-lib/bindings/gen_models.ts
index 917c14541..7f4c19388 100644
--- a/crates-proxy/yaak-proxy-lib/bindings/gen_models.ts
+++ b/crates-proxy/yaak-proxy-lib/bindings/gen_models.ts
@@ -42,6 +42,8 @@ elapsedHeadersMs: number | null,
*/
elapsedMs: number | null, error: string | null, };
+export type FlowBody = "request" | "response";
+
export type ModelPayload = { model: Flow, change: ModelChangeEvent, };
export type ProxyHeader = { name: string, value: string, };
diff --git a/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts b/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts
index 6ce078dc4..b440bebd0 100644
--- a/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts
+++ b/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts
@@ -1,13 +1,63 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
-import type { Flow, ModelPayload } from "./gen_models";
+import type { Flow, FlowBody, ModelPayload } from "./gen_models";
export type ActionInvocation = { "scope": "global", action: GlobalAction, };
export type ActionMetadata = { label: string, defaultHotkey: string | null, };
+export type CaStatus = {
+/**
+ * macOS trusts this app's CA, so HTTPS flows can be decrypted.
+ */
+trusted: boolean,
+/**
+ * A certificate named "Yaak Proxy CA" is in a keychain: this one, or an older one.
+ */
+installed: boolean, certPath: string, };
+
+export type CaStatusRequest = Record;
+
+export type CaTrustRequest = Record;
+
+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;
-export type GetProxyStateResponse = { state: ProxyState, };
+export type GetProxyStateResponse = { state: ProxyState,
+/**
+ * The port the engine is bound to, while it runs.
+ */
+port: number | null, };
export type GlobalAction = "proxy_start" | "proxy_stop";
@@ -25,4 +75,4 @@ export type ProxyStatePayload = { state: ProxyState, };
export type RpcEventSchema = { model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, };
-export type RpcSchema = { execute_action: [ActionInvocation, boolean], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_models: [ListModelsRequest, ListModelsResponse], };
+export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_models: [ListModelsRequest, ListModelsResponse], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], };
diff --git a/crates-proxy/yaak-proxy-lib/src/bodies.rs b/crates-proxy/yaak-proxy-lib/src/bodies.rs
index 9d7add1bf..803b091a7 100644
--- a/crates-proxy/yaak-proxy-lib/src/bodies.rs
+++ b/crates-proxy/yaak-proxy-lib/src/bodies.rs
@@ -15,23 +15,46 @@ pub fn read_raw_body(
Ok(chunks.into_iter().flat_map(|c| c.data).collect())
}
-/// A flow's body decoded with its Content-Encoding. A truncated body decodes as far as its
-/// stored bytes go.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct DecodedBody {
+ pub data: Vec,
+ /// Bytes stored for the body, as they were on the wire.
+ pub stored_size: usize,
+ /// The body is incomplete: cut on the wire at the capture limit, or decoded past `limit`.
+ pub truncated: bool,
+}
+
+/// A flow's body decoded with its Content-Encoding, at most `limit` bytes of it. A body cut
+/// at the capture limit decodes as far as its stored bytes go.
pub async fn read_body(
blobs: &BlobManager,
flow: &Flow,
body: FlowBody,
-) -> Result, String> {
+ limit: usize,
+) -> Result {
let raw = read_raw_body(blobs, &flow.id, body)?;
- let encoding = ContentEncoding::from_header(find_header(flow.headers(body), "content-encoding"));
- if encoding == ContentEncoding::Identity {
- return Ok(raw);
+ let stored_size = raw.len();
+ let wire_truncated = flow.truncated(body);
+ let encoding =
+ ContentEncoding::from_header(find_header(flow.headers(body), "content-encoding"));
+ // A HEAD or 304 response names its encoding but has no body to decode.
+ if raw.is_empty() || encoding == ContentEncoding::Identity {
+ let truncated = wire_truncated || raw.len() > limit;
+ let mut data = raw;
+ data.truncate(limit);
+ return Ok(DecodedBody { data, stored_size, truncated });
}
- let mut decoded = Vec::new();
- match streaming_decoder(Cursor::new(raw), encoding).read_to_end(&mut decoded).await {
- Ok(_) => Ok(decoded),
- Err(_) if flow.truncated(body) => Ok(decoded),
- Err(e) => Err(format!("Failed to decode {encoding:?} body: {e}")),
+ // One byte past the limit tells a body that fills it from one that overflows it.
+ let take = u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1);
+ let mut data = Vec::new();
+ let mut decoder = streaming_decoder(Cursor::new(raw), encoding).take(take);
+ match decoder.read_to_end(&mut data).await {
+ Ok(_) => {}
+ Err(_) if wire_truncated => {}
+ Err(e) => return Err(format!("Failed to decode {encoding:?} body: {e}")),
}
+ let truncated = wire_truncated || data.len() > limit;
+ data.truncate(limit);
+ Ok(DecodedBody { data, stored_size, truncated })
}
diff --git a/crates-proxy/yaak-proxy-lib/src/ca.rs b/crates-proxy/yaak-proxy-lib/src/ca.rs
new file mode 100644
index 000000000..3b57e145f
--- /dev/null
+++ b/crates-proxy/yaak-proxy-lib/src/ca.rs
@@ -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 {
+ 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 {
+ 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 {
+ 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())
+}
diff --git a/crates-proxy/yaak-proxy-lib/src/lib.rs b/crates-proxy/yaak-proxy-lib/src/lib.rs
index 0f2bfbf2f..fd680e0f4 100644
--- a/crates-proxy/yaak-proxy-lib/src/lib.rs
+++ b/crates-proxy/yaak-proxy-lib/src/lib.rs
@@ -1,13 +1,17 @@
pub mod actions;
pub mod bodies;
+pub mod ca;
pub mod db;
pub mod models;
pub mod settings;
use crate::actions::{ActionInvocation, ActionMetadata, GlobalAction};
+use crate::ca::CaStatus;
use crate::db::ProxyQueryManager;
-use crate::models::{Flow, FlowBody, ModelPayload, ProxyHeader, find_header};
+use crate::models::{Flow, FlowBody, FlowIden, ModelPayload, ProxyHeader, find_header};
use crate::settings::ProxySettings;
+use base64::Engine;
+use base64::engine::general_purpose::STANDARD as BASE64;
use chrono::{DateTime, Utc};
use log::warn;
use serde::{Deserialize, Serialize};
@@ -89,6 +93,10 @@ impl ProxyCtx {
pub fn port(&self) -> Option {
self.handle.lock().ok()?.as_ref().map(|h| h.port)
}
+
+ pub fn ca_dir(&self) -> PathBuf {
+ self.data_dir.join("ca")
+ }
}
// -- Proxy state --
@@ -136,10 +144,76 @@ pub struct GetProxyStateRequest {}
#[derive(Serialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
+#[serde(rename_all = "camelCase")]
pub struct GetProxyStateResponse {
pub state: ProxyState,
+ /// The port the engine is bound to, while it runs.
+ pub port: Option,
}
+#[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,
+}
+
+#[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,
+}
+
+#[derive(Deserialize, TS)]
+#[ts(export, export_to = "gen_rpc.ts")]
+pub struct CaStatusRequest {}
+
+#[derive(Deserialize, TS)]
+#[ts(export, export_to = "gen_rpc.ts")]
+pub struct CaTrustRequest {}
+
// -- Handlers --
fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result {
@@ -156,9 +230,71 @@ fn get_proxy_state(
ctx: &ProxyCtx,
_req: GetProxyStateRequest,
) -> Result {
- let handle = ctx.handle.lock().map_err(|_| RpcError { message: "lock poisoned".into() })?;
- let state = if handle.is_some() { ProxyState::Running } else { ProxyState::Stopped };
- Ok(GetProxyStateResponse { state })
+ let port = ctx.port();
+ let state = if port.is_some() { ProxyState::Running } else { ProxyState::Stopped };
+ Ok(GetProxyStateResponse { state, port })
+}
+
+fn format_body(_ctx: &ProxyCtx, req: FormatBodyRequest) -> Result {
+ Ok(yaak_templates::format_json::format_json(&req.text, " "))
+}
+
+async fn get_flow_body(
+ ctx: ProxyCtx,
+ req: GetFlowBodyRequest,
+) -> Result {
+ 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 {
+ 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 {
+ ca::status(&ctx.ca_dir().join("ca.pem")).await.map_err(rpc_error)
+}
+
+async fn ca_trust(ctx: ProxyCtx, _req: CaTrustRequest) -> Result {
+ // 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::load(&ctx.settings_path()).map_err(rpc_error)
+}
+
+fn find_flow(ctx: &ProxyCtx, flow_id: &str) -> Result {
+ ctx.db.with_conn(|db| db.find_one::(FlowIden::Id, flow_id)).map_err(rpc_error)
+}
+
+fn rpc_error(e: impl ToString) -> RpcError {
+ RpcError { message: e.to_string() }
}
fn list_actions(
@@ -324,10 +460,17 @@ define_rpc! {
ProxyCtx;
commands {
execute_action(ActionInvocation) -> bool,
+ format_body(FormatBodyRequest) -> String,
get_proxy_state(GetProxyStateRequest) -> GetProxyStateResponse,
list_actions(ListActionsRequest) -> ListActionsResponse,
list_models(ListModelsRequest) -> ListModelsResponse,
}
+ async_commands {
+ ca_status(CaStatusRequest) -> CaStatus,
+ ca_trust(CaTrustRequest) -> CaStatus,
+ filter_body(FilterBodyRequest) -> FilterBodyResponse,
+ get_flow_body(GetFlowBodyRequest) -> GetFlowBodyResponse,
+ }
events {
model_write(ModelPayload),
proxy_state_changed(ProxyStatePayload),
diff --git a/crates-proxy/yaak-proxy-lib/src/models.rs b/crates-proxy/yaak-proxy-lib/src/models.rs
index 5fa05c507..21136b7c4 100644
--- a/crates-proxy/yaak-proxy-lib/src/models.rs
+++ b/crates-proxy/yaak-proxy-lib/src/models.rs
@@ -55,7 +55,9 @@ pub struct Flow {
pub error: Option,
}
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
+#[serde(rename_all = "snake_case")]
+#[ts(export, export_to = "gen_models.ts")]
pub enum FlowBody {
Request,
Response,
diff --git a/crates-proxy/yaak-proxy-lib/tests/flow.rs b/crates-proxy/yaak-proxy-lib/tests/flow.rs
index 8afc34824..f34f99c69 100644
--- a/crates-proxy/yaak-proxy-lib/tests/flow.rs
+++ b/crates-proxy/yaak-proxy-lib/tests/flow.rs
@@ -3,6 +3,8 @@ use std::io::Write;
use std::net::SocketAddr;
use std::time::{Duration, Instant};
+use base64::Engine;
+use base64::engine::general_purpose::STANDARD as BASE64;
use bytes::Bytes;
use flate2::Compression;
use flate2::write::GzEncoder;
@@ -12,9 +14,10 @@ use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
+use serde_json::{Value, json};
use tokio::net::TcpListener;
use yaak_proxy_lib::ProxyCtx;
-use yaak_proxy_lib::bodies::{read_body, read_raw_body};
+use yaak_proxy_lib::bodies::{DecodedBody, read_body, read_raw_body};
use yaak_proxy_lib::models::{Flow, FlowBody};
use yaak_proxy_lib::settings::ProxySettings;
use yaak_rpc::RpcEventEmitter;
@@ -48,6 +51,27 @@ async fn route(req: Request) -> Result>, Infallib
});
Response::new(Full::from("ok"))
}
+ // Small on the wire, past the capture limit once decoded.
+ "/expands" => Response::builder()
+ .header("content-type", "text/plain")
+ .header("content-encoding", "gzip")
+ .body(Full::from(gzip(&vec![b'z'; BIG_BODY])))
+ .unwrap(),
+ "/json" => Response::builder()
+ .header("content-type", "application/json")
+ .body(Full::from(r#"{"items":[{"id":1},{"id":2}]}"#))
+ .unwrap(),
+ // Names an encoding but, like any 304, has no body
+ "/not-modified" => Response::builder()
+ .status(304)
+ .header("content-encoding", "gzip")
+ .body(Full::new(Bytes::new()))
+ .unwrap(),
+ // XML that only its body says is XML
+ "/xml-as-text" => Response::builder()
+ .header("content-type", "text/plain")
+ .body(Full::from("1 2 "))
+ .unwrap(),
_ => Response::new(Full::from(vec![b'x'; BIG_BODY])),
})
}
@@ -58,7 +82,9 @@ async fn spawn_upstream() -> SocketAddr {
tokio::spawn(async move {
loop {
let (tcp, _) = listener.accept().await.unwrap();
- tokio::spawn(http1::Builder::new().serve_connection(TokioIo::new(tcp), service_fn(route)));
+ tokio::spawn(
+ http1::Builder::new().serve_connection(TokioIo::new(tcp), service_fn(route)),
+ );
}
});
addr
@@ -120,7 +146,13 @@ async fn flow_is_stored_with_its_bodies() {
assert_eq!(read_raw_body(&ctx.blobs, &flow.id, FlowBody::Request).unwrap(), b"hello proxy");
assert_eq!(read_raw_body(&ctx.blobs, &flow.id, FlowBody::Response).unwrap(), compressed);
- assert_eq!(read_body(&ctx.blobs, &flow, FlowBody::Response).await.unwrap(), b"hello proxy");
+ let decoded = read_body(&ctx.blobs, &flow, FlowBody::Response, CAPTURE_LIMIT).await.unwrap();
+ let expected = DecodedBody {
+ data: b"hello proxy".to_vec(),
+ stored_size: compressed.len(),
+ truncated: false,
+ };
+ assert_eq!(decoded, expected);
let big_url = format!("http://{upstream}/big");
let resp = client.get(&big_url).send().await.unwrap();
@@ -166,3 +198,101 @@ async fn early_response_keeps_request_totals() {
assert!(flow.request_size >= stored, "{} < {stored}", flow.request_size);
assert_eq!(flow.request_truncated, flow.request_size > stored);
}
+
+async fn rpc(ctx: &ProxyCtx, cmd: &str, payload: Value) -> Result {
+ 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": "2 ", "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}"));
+}
diff --git a/crates/common/yaak-rpc/src/lib.rs b/crates/common/yaak-rpc/src/lib.rs
index 05765cfb5..30fee70d2 100644
--- a/crates/common/yaak-rpc/src/lib.rs
+++ b/crates/common/yaak-rpc/src/lib.rs
@@ -139,6 +139,9 @@ impl RpcEventEmitter {
/// - `RpcSchema` — a struct with ts-rs derives for TypeScript type generation
/// - `RpcEventSchema` — (if events declared) a struct mapping event names to payload types
///
+/// Commands under the optional `async_commands` block are `async fn(Ctx, Req)` handlers,
+/// wrapped with `rpc_handler_async!`.
+///
/// The wire name for each command/event is derived from `stringify!($ident)`.
///
/// # Example
@@ -162,6 +165,9 @@ macro_rules! define_rpc {
commands {
$( $handler:ident ( $req:ty ) -> $res:ty ),* $(,)?
}
+ $( async_commands {
+ $( $async_handler:ident ( $async_req:ty ) -> $async_res:ty ),* $(,)?
+ } )?
events {
$( $evt_ident:ident ( $evt_payload:ty ) ),* $(,)?
}
@@ -169,6 +175,7 @@ macro_rules! define_rpc {
pub fn build_router() -> $crate::RpcRouter<$ctx> {
let mut router = $crate::RpcRouter::new();
$( router.register(stringify!($handler), $crate::rpc_handler!($handler)); )*
+ $( $( router.register(stringify!($async_handler), $crate::rpc_handler_async!($async_handler)); )* )?
router
}
@@ -176,6 +183,7 @@ macro_rules! define_rpc {
#[ts(export, export_to = "gen_rpc.ts")]
pub struct RpcSchema {
$( pub $handler: ($req, $res), )*
+ $( $( pub $async_handler: ($async_req, $async_res), )* )?
}
#[derive(ts_rs::TS)]
@@ -191,10 +199,14 @@ macro_rules! define_rpc {
commands {
$( $handler:ident ( $req:ty ) -> $res:ty ),* $(,)?
}
+ $( async_commands {
+ $( $async_handler:ident ( $async_req:ty ) -> $async_res:ty ),* $(,)?
+ } )?
) => {
pub fn build_router() -> $crate::RpcRouter<$ctx> {
let mut router = $crate::RpcRouter::new();
$( router.register(stringify!($handler), $crate::rpc_handler!($handler)); )*
+ $( $( router.register(stringify!($async_handler), $crate::rpc_handler_async!($async_handler)); )* )?
router
}
@@ -202,6 +214,7 @@ macro_rules! define_rpc {
#[ts(export, export_to = "gen_rpc.ts")]
pub struct RpcSchema {
$( pub $handler: ($req, $res), )*
+ $( $( pub $async_handler: ($async_req, $async_res), )* )?
}
};
}
diff --git a/package-lock.json b/package-lock.json
index 668c12e0a..4c774974e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -246,6 +246,7 @@
"version": "1.0.0",
"dependencies": {
"@tanstack/react-query": "^5.104.2",
+ "@tanstack/react-virtual": "^3.14.17",
"@tauri-apps/api": "^2.12.3",
"@tauri-apps/plugin-os": "^2.4.0",
"@yaakapp-internal/model-store": "^1.0.0",
@@ -257,7 +258,8 @@
"jotai-family": "^1.0.1",
"motion": "^14.0.0",
"react": "^19.2.0",
- "react-dom": "^19.2.0"
+ "react-dom": "^19.2.0",
+ "vkbeautify": "^0.99.3"
},
"devDependencies": {
"@rolldown/plugin-babel": "^0.2.3",
diff --git a/packages/ui/src/components/Dropdown.tsx b/packages/ui/src/components/Dropdown.tsx
new file mode 100644
index 000000000..de81cf601
--- /dev/null
+++ b/packages/ui/src/components/Dropdown.tsx
@@ -0,0 +1,1071 @@
+import { generateId } from "@yaakapp-internal/lib/generateId";
+import classNames from "classnames";
+import { atom, createStore } from "jotai";
+import * as m from "motion/react-m";
+import type {
+ CSSProperties,
+ HTMLAttributes,
+ MouseEvent,
+ ReactElement,
+ FocusEvent as ReactFocusEvent,
+ KeyboardEvent as ReactKeyboardEvent,
+ ReactNode,
+ RefObject,
+ SetStateAction,
+} from "react";
+import {
+ Children,
+ cloneElement,
+ forwardRef,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { useKey, useWindowSize } from "react-use";
+import { useClickOutside } from "../hooks/useClickOutside";
+import type { HotkeyAction } from "../hooks/useHotKey";
+import { useHotKey } from "../hooks/useHotKey";
+import { useStateWithDeps } from "../hooks/useStateWithDeps";
+import { getNodeText } from "../lib/getNodeText";
+import { Button } from "./Button";
+import { ErrorBoundary } from "./ErrorBoundary";
+import { Hotkey } from "./Hotkey";
+import { Icon, type IconProps } from "./Icon";
+import { IconButton } from "./IconButton";
+import { LoadingIcon } from "./LoadingIcon";
+import { Overlay } from "./Overlay";
+import type { SeparatorAction } from "./Separator";
+import { Separator } from "./Separator";
+import { HStack, VStack } from "./Stacks";
+
+export type DropdownItemSeparator = {
+ type: "separator";
+ label?: ReactNode;
+ hidden?: boolean;
+ /** A control shown beside the label, eg. revealing the labelled file on disk. */
+ action?: SeparatorAction;
+};
+
+export type DropdownItemContent = {
+ type: "content";
+ label?: ReactNode;
+ hidden?: boolean;
+};
+
+export type DropdownItemDefault = {
+ type?: "default";
+ label: ReactNode;
+ hotKeyAction?: HotkeyAction;
+ hotKeyLabelOnly?: boolean;
+ color?: "default" | "primary" | "danger" | "info" | "warning" | "notice" | "success";
+ disabled?: boolean;
+ hidden?: boolean;
+ leftSlot?: ReactNode;
+ rightSlot?: ReactNode;
+ waitForOnSelect?: boolean;
+ keepOpenOnSelect?: boolean;
+ onSelect?: () => void | Promise;
+ submenu?: DropdownItem[];
+ /** If true, submenu opens on click instead of hover */
+ submenuOpenOnClick?: boolean;
+ /**
+ * How the submenu opens. "row" (default) opens it from the row itself (hover, or click
+ * with submenuOpenOnClick). "button" keeps the row selectable via onSelect and renders
+ * a dedicated button on the right that opens the submenu.
+ */
+ submenuTrigger?: "row" | "button";
+ icon?: IconProps["icon"];
+};
+
+export type DropdownItem = DropdownItemDefault | DropdownItemSeparator | DropdownItemContent;
+
+export interface DropdownProps {
+ children: ReactElement>;
+ items: DropdownItem[];
+ fullWidth?: boolean;
+ hotKeyAction?: HotkeyAction;
+ onOpen?: () => void;
+}
+
+export interface DropdownRef {
+ isOpen: boolean;
+ open: (index?: number) => void;
+ toggle: () => void;
+ close?: () => void;
+ next?: (incrBy?: number) => void;
+ prev?: (incrBy?: number) => void;
+ select?: () => void;
+}
+
+// Every dropdown gets a unique ID and we use this global atom to ensure
+// only one dropdown can be open at a time.
+// TODO: Also make ContextMenu use this
+const openAtom = atom(null);
+const openStore = createStore();
+
+export const Dropdown = forwardRef(function Dropdown(
+ { children, items, hotKeyAction, fullWidth, onOpen }: DropdownProps,
+ ref,
+) {
+ const id = useRef(generateId());
+ const [isOpen, setIsOpen] = useState(false);
+
+ useEffect(() => {
+ return openStore.sub(openAtom, () => {
+ const globalOpenId = openStore.get(openAtom);
+ const newIsOpen = globalOpenId === id.current;
+ if (newIsOpen !== isOpen) {
+ setIsOpen(newIsOpen);
+ }
+ });
+ }, [isOpen]);
+
+ // const [isOpen, _setIsOpen] = useState(false);
+ const [defaultSelectedIndex, setDefaultSelectedIndex] = useState(null);
+ const buttonRef = useRef(null);
+ const menuRef = useRef>(null);
+
+ const handleSetIsOpen = useCallback(
+ (o: SetStateAction) => {
+ openStore.set(openAtom, (prevId) => {
+ const prevIsOpen = prevId === id.current;
+ const newIsOpen = typeof o === "function" ? o(prevIsOpen) : o;
+ // Persist background color of button until we close the dropdown
+ if (newIsOpen) {
+ onOpen?.();
+ if (buttonRef.current) {
+ buttonRef.current.style.backgroundColor = window
+ .getComputedStyle(buttonRef.current)
+ .getPropertyValue("background-color");
+ }
+ }
+ return newIsOpen ? id.current : null; // Set global atom to current ID to signify open state
+ });
+ },
+ [onOpen],
+ );
+
+ // Because a different dropdown can cause ours to close, a useEffect([isOpen]) is the only method
+ // we have of detecting the dropdown closed, to do cleanup.
+ useEffect(() => {
+ if (!isOpen) {
+ // Clear persisted BG
+ if (buttonRef.current) buttonRef.current.style.backgroundColor = "";
+ // Set to different value when opened and closed to force it to update. This is to force
+ // to reset its selected-index state, which it does when this prop changes
+ setDefaultSelectedIndex(null);
+ }
+ }, [isOpen]);
+
+ // Pull into variable so linter forces us to add it as a hook dep to useImperativeHandle. If we don't,
+ // the ref will not update when menuRef updates, causing stale callback state to be used.
+ const menuRefCurrent = menuRef.current;
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ ...menuRefCurrent,
+ isOpen: isOpen,
+ toggle() {
+ if (!isOpen) this.open();
+ else this.close();
+ },
+ open(index?: number) {
+ handleSetIsOpen(true);
+ setDefaultSelectedIndex(index ?? -1);
+ },
+ close() {
+ handleSetIsOpen(false);
+ },
+ }),
+ [isOpen, handleSetIsOpen, menuRefCurrent],
+ );
+
+ useHotKey(hotKeyAction ?? null, () => {
+ setDefaultSelectedIndex(0);
+ handleSetIsOpen(true);
+ });
+
+ const child = useMemo(() => {
+ const existingChild = Children.only(children);
+ const originalOnClick = existingChild.props?.onClick;
+ const props: HTMLAttributes & { ref: RefObject } =
+ {
+ ...existingChild.props,
+ ref: buttonRef,
+ "aria-haspopup": "true",
+ onClick: (e: MouseEvent) => {
+ // Call original onClick first if it exists
+ originalOnClick?.(e);
+
+ // Only toggle dropdown if event wasn't prevented
+ if (!e.defaultPrevented) {
+ e.preventDefault();
+ e.stopPropagation();
+ handleSetIsOpen((o) => !o); // Toggle dropdown
+ }
+ },
+ };
+ return cloneElement(existingChild, props);
+ }, [children, handleSetIsOpen]);
+
+ useEffect(() => {
+ buttonRef.current?.setAttribute("aria-expanded", isOpen.toString());
+ }, [isOpen]);
+
+ const windowSize = useWindowSize();
+ const triggerRect = useMemo(() => {
+ if (!windowSize) return null; // No-op to TS happy with this dep
+ if (!isOpen) return null;
+ return buttonRef.current?.getBoundingClientRect();
+ }, [isOpen, windowSize]);
+
+ return (
+ <>
+ {child}
+
+ handleSetIsOpen(false)}
+ isOpen={isOpen}
+ />
+
+ >
+ );
+});
+
+export interface ContextMenuProps {
+ triggerPosition: { x: number; y: number } | null;
+ /**
+ * The box the menu belongs to, when the trigger has one.
+ *
+ * Placement aligns to the trigger's left or right edge depending on the room beside it, so a
+ * menu opened from an element wants its real rect. Without one the position is treated as a
+ * zero-width point, which is right for a menu opened at the cursor and wrong for one opened
+ * from a button.
+ */
+ triggerRect?: Pick;
+ /**
+ * The element the menu belongs to, so a click on it doesn't count as a click outside.
+ *
+ * Without this the trigger gets both: the outside-click handler closes the menu on mousedown,
+ * then the trigger's own click opens it again, and pressing it looks like it does nothing.
+ */
+ triggerRef?: RefObject;
+ className?: string;
+ items: DropdownProps["items"];
+ onClose: () => void;
+}
+
+export const ContextMenu = forwardRef(function ContextMenu(
+ { triggerPosition, triggerRect, triggerRef, className, items, onClose },
+ ref,
+) {
+ const triggerShape = useMemo(
+ () =>
+ triggerRect ?? {
+ top: triggerPosition?.y ?? 0,
+ bottom: triggerPosition?.y ?? 0,
+ left: triggerPosition?.x ?? 0,
+ right: triggerPosition?.x ?? 0,
+ },
+ [triggerPosition, triggerRect],
+ );
+
+ if (triggerPosition == null) return null;
+
+ return (
+
+ );
+});
+
+interface MenuProps {
+ className?: string;
+ defaultSelectedIndex: number | null;
+ triggerShape: Pick | null;
+ onClose: () => void;
+ onCloseAll?: () => void;
+ showTriangle?: boolean;
+ fullWidth?: boolean;
+ isOpen: boolean;
+ items: DropdownItem[];
+ // Any element, not just a button: a menu can be opened from anything, and this is only ever
+ // used to ask whether a click landed on the trigger
+ triggerRef?: RefObject;
+ isSubmenu?: boolean;
+}
+
+const Menu = forwardRef, MenuProps>(
+ (
+ {
+ className,
+ isOpen,
+ items,
+ fullWidth,
+ onClose,
+ onCloseAll,
+ triggerShape,
+ defaultSelectedIndex,
+ showTriangle,
+ triggerRef,
+ isSubmenu,
+ }: MenuProps,
+ ref,
+ ) => {
+ const [selectedIndex, setSelectedIndex] = useStateWithDeps(
+ defaultSelectedIndex ?? -1,
+ [defaultSelectedIndex],
+ );
+
+ const [filter, setFilter] = useState("");
+
+ // Clear filter when menu opens
+ useEffect(() => {
+ if (isOpen) {
+ setFilter("");
+ }
+ }, [isOpen]);
+
+ const [activeSubmenu, setActiveSubmenu] = useState<{
+ item: DropdownItemDefault;
+ parent: HTMLButtonElement;
+ viaKeyboard?: boolean;
+ } | null>(null);
+
+ const mousePosition = useRef({ x: 0, y: 0 });
+ const submenuTimeoutRef = useRef(null);
+ const submenuRef = useRef(null);
+
+ // HACK: Use a ref to track selectedIndex so our closure functions (eg. select()) can
+ // have access to the latest value.
+ const selectedIndexRef = useRef(selectedIndex);
+ useEffect(() => {
+ selectedIndexRef.current = selectedIndex;
+ }, [selectedIndex]);
+
+ const handleClose = useCallback(() => {
+ onClose();
+ setActiveSubmenu(null);
+ }, [onClose]);
+
+ // Close the entire menu hierarchy (used when selecting an item)
+ const handleCloseAll = useCallback(() => {
+ if (onCloseAll) {
+ onCloseAll();
+ } else {
+ handleClose();
+ }
+ }, [onCloseAll, handleClose]);
+
+ // Handle type-ahead filtering (only for the deepest open menu)
+ const handleMenuKeyDown = (e: ReactKeyboardEvent) => {
+ // Skip if this menu has a submenu open - let the submenu handle typing
+ if (activeSubmenu) return;
+
+ const isCharacter = e.key.length === 1;
+ const isSpecial = e.ctrlKey || e.metaKey || e.altKey;
+ if (isCharacter && !isSpecial) {
+ e.preventDefault();
+ setFilter((f) => f + e.key);
+ setSelectedIndex(0);
+ } else if (e.key === "Backspace" && !isSpecial) {
+ e.preventDefault();
+ setFilter((f) => f.slice(0, -1));
+ }
+ };
+
+ useKey(
+ "Escape",
+ () => {
+ if (!isOpen) return;
+ if (activeSubmenu) setActiveSubmenu(null);
+ else if (filter !== "") setFilter("");
+ else handleClose();
+ },
+ {},
+ [isOpen, filter, setFilter, handleClose, activeSubmenu],
+ );
+
+ const handlePrev = useCallback(
+ (incrBy = 1) => {
+ setSelectedIndex((currIndex) => {
+ let nextIndex = (currIndex ?? 0) - incrBy;
+ const maxTries = items.length;
+ for (let i = 0; i < maxTries; i++) {
+ if (items[nextIndex]?.hidden || items[nextIndex]?.type === "separator") {
+ nextIndex--;
+ } else if (nextIndex < 0) {
+ nextIndex = items.length - 1;
+ } else {
+ break;
+ }
+ }
+ return nextIndex;
+ });
+ },
+ [items, setSelectedIndex],
+ );
+
+ const handleNext = useCallback(
+ (incrBy = 1) => {
+ setSelectedIndex((currIndex) => {
+ let nextIndex = (currIndex ?? -1) + incrBy;
+ const maxTries = items.length;
+ for (let i = 0; i < maxTries; i++) {
+ if (items[nextIndex]?.hidden || items[nextIndex]?.type === "separator") {
+ nextIndex++;
+ } else if (nextIndex >= items.length) {
+ nextIndex = 0;
+ } else {
+ break;
+ }
+ }
+ return nextIndex;
+ });
+ },
+ [items, setSelectedIndex],
+ );
+
+ // Ensure selection is on a valid item (not hidden/separator/content)
+ useEffect(() => {
+ const item = items[selectedIndex ?? -1];
+ if (item?.hidden || item?.type === "separator" || item?.type === "content") {
+ handleNext();
+ }
+ }, [selectedIndex, items, handleNext]);
+
+ useKey(
+ "ArrowUp",
+ (e) => {
+ if (!isOpen || activeSubmenu) return;
+ e.preventDefault();
+ handlePrev();
+ },
+ {},
+ [isOpen, activeSubmenu],
+ );
+
+ useKey(
+ "ArrowDown",
+ (e) => {
+ if (!isOpen || activeSubmenu) return;
+ e.preventDefault();
+ handleNext();
+ },
+ {},
+ [isOpen, activeSubmenu],
+ );
+
+ useKey(
+ "ArrowLeft",
+ (e) => {
+ if (!isOpen) return;
+ // Only handle if this menu doesn't have an open submenu
+ // (let the deepest submenu handle the key first)
+ if (activeSubmenu) return;
+ // If this is a submenu, ArrowLeft closes it and returns to parent
+ if (isSubmenu) {
+ e.preventDefault();
+ onClose();
+ }
+ },
+ {},
+ [isOpen, isSubmenu, activeSubmenu, onClose],
+ );
+
+ const handleSelect = useCallback(
+ async (item: DropdownItem, parentEl?: HTMLButtonElement) => {
+ // Handle click-to-open submenu
+ if ("submenu" in item && item.submenu && item.submenuOpenOnClick && parentEl) {
+ setActiveSubmenu({ item, parent: parentEl });
+ return;
+ }
+
+ if (!("onSelect" in item) || !item.onSelect) return;
+ setSelectedIndex(null);
+
+ const promise = item.onSelect();
+ if (item.waitForOnSelect) {
+ try {
+ await promise;
+ } catch {
+ // Nothing
+ }
+ }
+
+ if (!item.keepOpenOnSelect) {
+ handleCloseAll();
+ } else if (isSubmenu) {
+ // Keep the parent menu open, but close this submenu — its items may no
+ // longer describe the row after the action (e.g. Pin → Unpin, Remove)
+ handleClose();
+ }
+ },
+ [handleCloseAll, handleClose, isSubmenu, setSelectedIndex],
+ );
+
+ useImperativeHandle(ref, () => {
+ return {
+ close: handleClose,
+ prev: handlePrev,
+ next: handleNext,
+ select: async () => {
+ const item = items[selectedIndexRef.current ?? -1] ?? null;
+ if (!item) return;
+ await handleSelect(item);
+ },
+ };
+ }, [handleClose, handleNext, handlePrev, handleSelect, items]);
+
+ const styles = useMemo<{
+ container: CSSProperties;
+ menu: CSSProperties;
+ triangle: CSSProperties;
+ upsideDown: boolean;
+ }>(() => {
+ if (triggerShape == null) return { container: {}, triangle: {}, menu: {}, upsideDown: false };
+
+ if (isSubmenu) {
+ const parentRect = triggerShape;
+ const docRect = document.documentElement.getBoundingClientRect();
+ const spaceRight = docRect.width - parentRect.right;
+ const spaceBelow = docRect.height - parentRect.top;
+ const spaceAbove = parentRect.bottom;
+ const openLeft = spaceRight < 200; // Heuristic to open on left if not enough space on right
+ // Estimate submenu height (items * ~28px + padding), flip if not enough space below
+ const estimatedHeight = items.length * 28 + 20;
+ const openUpward = spaceBelow < estimatedHeight && spaceAbove > spaceBelow;
+
+ return {
+ upsideDown: openUpward,
+ container: {
+ top: openUpward ? undefined : parentRect.top,
+ bottom: openUpward ? docRect.height - parentRect.bottom : undefined,
+ left: openLeft ? undefined : parentRect.right,
+ right: openLeft ? docRect.width - parentRect.left : undefined,
+ },
+ menu: {
+ maxHeight: `${(openUpward ? spaceAbove : spaceBelow) - 20}px`,
+ },
+ triangle: {}, // No triangle for submenus
+ };
+ }
+
+ const menuMarginY = 5;
+ const docRect = document.documentElement.getBoundingClientRect();
+ const width = triggerShape.right - triggerShape.left;
+ const heightAbove = triggerShape.top;
+ const heightBelow = docRect.height - triggerShape.bottom;
+ const horizontalSpaceRemaining = docRect.width - triggerShape.left;
+ const top = triggerShape.bottom;
+ const onRight = horizontalSpaceRemaining < 300;
+ const upsideDown = heightBelow < heightAbove && heightBelow < items.length * 25 + 20 + 200;
+ const triggerWidth = triggerShape.right - triggerShape.left;
+ return {
+ upsideDown,
+ container: {
+ top: !upsideDown ? top + menuMarginY : undefined,
+ bottom: upsideDown
+ ? docRect.height - top - (triggerShape.top - triggerShape.bottom) + menuMarginY
+ : undefined,
+ right: onRight ? docRect.width - triggerShape.right : undefined,
+ left: !onRight ? triggerShape.left : undefined,
+ minWidth: fullWidth ? triggerWidth : undefined,
+ maxWidth: "40rem",
+ },
+ triangle: {
+ width: "0.4rem",
+ height: "0.4rem",
+ ...(onRight
+ ? { right: width / 2, marginRight: "-0.2rem" }
+ : { left: width / 2, marginLeft: "-0.2rem" }),
+ ...(upsideDown
+ ? { bottom: "-0.2rem", rotate: "225deg" }
+ : { top: "-0.2rem", rotate: "45deg" }),
+ },
+ menu: {
+ maxHeight: `${(upsideDown ? heightAbove : heightBelow) - 15}px`,
+ },
+ };
+ }, [fullWidth, items.length, triggerShape, isSubmenu]);
+
+ const filteredItems = useMemo(
+ () => items.filter((i) => getNodeText(i.label).toLowerCase().includes(filter.toLowerCase())),
+ [items, filter],
+ );
+
+ const handleFocus = useCallback(
+ (i: DropdownItem) => {
+ const index = filteredItems.indexOf(i) ?? null;
+ setSelectedIndex(index);
+ },
+ [filteredItems, setSelectedIndex],
+ );
+
+ useKey(
+ "ArrowRight",
+ (e) => {
+ if (!isOpen || activeSubmenu) return;
+ const item = filteredItems[selectedIndex ?? -1];
+ if (item?.type !== "separator" && item?.type !== "content" && item?.submenu) {
+ e.preventDefault();
+ const parent = document.activeElement as HTMLButtonElement;
+ if (parent) {
+ setActiveSubmenu({ item, parent, viaKeyboard: true });
+ }
+ }
+ },
+ {},
+ [isOpen, activeSubmenu, filteredItems, selectedIndex],
+ );
+
+ useKey(
+ "Enter",
+ (e) => {
+ if (!isOpen || activeSubmenu) return;
+ const item = filteredItems[selectedIndex ?? -1];
+ if (!item || item.type === "separator" || item.type === "content") return;
+ e.preventDefault();
+ if (item.submenu && item.submenuTrigger !== "button") {
+ const parent = document.activeElement as HTMLButtonElement;
+ if (parent) {
+ setActiveSubmenu({ item, parent, viaKeyboard: true });
+ }
+ } else if (item.onSelect) {
+ handleSelect(item).catch(console.error);
+ }
+ },
+ {},
+ [isOpen, activeSubmenu, filteredItems, selectedIndex, handleSelect],
+ );
+
+ const handleItemHover = useCallback(
+ (item: DropdownItemDefault, parent: HTMLButtonElement) => {
+ if (submenuTimeoutRef.current) {
+ clearTimeout(submenuTimeoutRef.current);
+ }
+
+ if (item.submenu && !item.submenuOpenOnClick && item.submenuTrigger !== "button") {
+ setActiveSubmenu({ item, parent });
+ } else if (activeSubmenu && activeSubmenu.item !== item) {
+ // Hovering the row that owns the open submenu must not dismiss it — the
+ // pointer travels across the row on its way to a button-triggered submenu
+ submenuTimeoutRef.current = window.setTimeout(() => {
+ const submenuEl = submenuRef.current;
+ if (!submenuEl || !activeSubmenu) {
+ setActiveSubmenu(null);
+ return;
+ }
+
+ const { parent } = activeSubmenu;
+ const parentRect = parent.getBoundingClientRect();
+ const submenuRect = submenuEl.getBoundingClientRect();
+ const mouse = mousePosition.current;
+
+ if (
+ mouse.x >= submenuRect.left &&
+ mouse.x <= submenuRect.right &&
+ mouse.y >= submenuRect.top &&
+ mouse.y <= submenuRect.bottom
+ ) {
+ return;
+ }
+
+ const tolerance = 5;
+ const p1 = { x: parentRect.right, y: parentRect.top - tolerance };
+ const p2 = { x: parentRect.right, y: parentRect.bottom + tolerance };
+ const p3 = { x: submenuRect.left, y: submenuRect.top - tolerance };
+ const p4 = { x: submenuRect.left, y: submenuRect.bottom + tolerance };
+
+ const inTriangle =
+ isPointInTriangle(mouse, p1, p2, p4) || isPointInTriangle(mouse, p1, p3, p4);
+
+ if (!inTriangle) {
+ setActiveSubmenu(null);
+ }
+ }, 100);
+ }
+ },
+ [activeSubmenu],
+ );
+
+ const menuRef = useRef(null);
+ useClickOutside(menuRef, handleClose, triggerRef);
+
+ // Keep focus on menu container when filtering leaves no items
+ useEffect(() => {
+ if (filteredItems.length === 0 && filter && menuRef.current) {
+ menuRef.current.focus();
+ }
+ }, [filteredItems.length, filter]);
+
+ const submenuTriggerShape = useMemo(() => {
+ if (!activeSubmenu) return null;
+ const rect = activeSubmenu.parent.getBoundingClientRect();
+ return {
+ top: rect.top,
+ bottom: rect.bottom,
+ left: rect.left,
+ right: rect.right,
+ };
+ }, [activeSubmenu]);
+
+ const handleMouseMove = (event: React.MouseEvent) => {
+ mousePosition.current = { x: event.clientX, y: event.clientY };
+ };
+
+ const menuContent = (
+ {
+ // Prevent showing any ancestor context menus
+ e.stopPropagation();
+ e.preventDefault();
+ }}
+ initial={{ opacity: 0, y: (styles.upsideDown ? 1 : -1) * 5, scale: 0.98 }}
+ animate={{ opacity: 1, y: 0, scale: 1 }}
+ role="menu"
+ aria-orientation="vertical"
+ dir="ltr"
+ style={styles.container}
+ className={classNames(
+ className,
+ "x-theme-menu",
+ "outline-hidden my-1 pointer-events-auto z-40",
+ "fixed",
+ )}
+ >
+ {showTriangle && !isSubmenu && (
+
+ )}
+
+ {filter && (
+
+
+ {filter}
+
+ )}
+ {filteredItems.length === 0 && (
+ No matches
+ )}
+ {filteredItems.map((item, i) => {
+ if (item.hidden) {
+ return null;
+ }
+ if (item.type === "separator") {
+ return (
+
+ {item.label}
+
+ );
+ }
+ if (item.type === "content") {
+ return (
+ // oxlint-disable-next-line no-array-index-key -- index is fine
+
+ {item.label}
+
+ );
+ }
+ const isParentOfActiveSubmenu = activeSubmenu?.item === item;
+ return (
+ setActiveSubmenu({ item, parent: el })}
+ // oxlint-disable-next-line no-array-index-key -- It's fine
+ key={i}
+ item={item}
+ />
+ );
+ })}
+
+ {activeSubmenu && (
+ {
+ if (submenuTimeoutRef.current) {
+ clearTimeout(submenuTimeoutRef.current);
+ }
+ }}
+ >
+
setActiveSubmenu(null)}
+ onCloseAll={handleCloseAll}
+ triggerShape={submenuTriggerShape}
+ />
+
+ )}
+
+ );
+
+ // Hotkeys must be rendered even when menu is closed (so they work globally)
+ const hotKeyElements = items.map(
+ (item, i) =>
+ item.type !== "separator" &&
+ item.type !== "content" &&
+ !item.hotKeyLabelOnly &&
+ item.hotKeyAction && (
+
+ ),
+ );
+
+ if (!isOpen) {
+ return <>{hotKeyElements}>;
+ }
+
+ if (isSubmenu) {
+ return menuContent;
+ }
+
+ return (
+ <>
+ {hotKeyElements}
+
+ {menuContent}
+
+ >
+ );
+ },
+);
+
+interface MenuItemProps {
+ className?: string;
+ item: DropdownItemDefault;
+ onSelect: (item: DropdownItemDefault, el?: HTMLButtonElement) => Promise;
+ onFocus: (item: DropdownItemDefault) => void;
+ onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
+ onOpenSubmenu: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
+ focused: boolean;
+ isParentOfActiveSubmenu?: boolean;
+}
+
+function MenuItem({
+ className,
+ focused,
+ onFocus,
+ onHover,
+ item,
+ onSelect,
+ onOpenSubmenu,
+ isParentOfActiveSubmenu,
+ ...props
+}: MenuItemProps) {
+ const [isLoading, setIsLoading] = useState(false);
+ const handleClick = useCallback(async () => {
+ if (item.waitForOnSelect) setIsLoading(true);
+ await onSelect?.(item, buttonRef.current ?? undefined);
+ if (item.waitForOnSelect) setIsLoading(false);
+ }, [item, onSelect]);
+
+ const handleFocus = useCallback(
+ (e: ReactFocusEvent) => {
+ e.stopPropagation(); // Don't trigger focus on any parents
+ return onFocus?.(item);
+ },
+ [item, onFocus],
+ );
+
+ const buttonRef = useRef(null);
+ const initRef = useCallback(
+ (el: HTMLButtonElement | null) => {
+ buttonRef.current = el;
+ if (el === null) return;
+ if (focused) {
+ setTimeout(() => el.focus(), 0);
+ }
+ },
+ [focused],
+ );
+
+ const handleMouseEnter = (e: MouseEvent) => {
+ onHover(item, e.currentTarget);
+ e.currentTarget.focus();
+ };
+
+ const hasButtonSubmenu = item.submenu != null && item.submenuTrigger === "button";
+
+ const rightSlot =
+ item.submenu && !hasButtonSubmenu ? (
+
+ ) : (
+ (item.rightSlot ?? )
+ );
+
+ const button = (
+ e.currentTarget.blur()}
+ disabled={item.disabled}
+ onFocus={handleFocus}
+ onClick={handleClick}
+ justify="start"
+ leftSlot={
+ (isLoading || item.leftSlot || item.icon) && (
+
+ {isLoading ? : item.icon ? : item.leftSlot}
+
+ )
+ }
+ rightSlot={rightSlot && {rightSlot}
}
+ innerClassName="text-left!"
+ color="custom"
+ className={classNames(
+ className,
+ "h-xs", // More compact
+ "min-w-32 outline-hidden px-2 mx-1.5 flex whitespace-nowrap",
+ "focus:bg-surface-highlight focus:text rounded-sm focus:outline-hidden focus-visible:outline-1",
+ isParentOfActiveSubmenu && "bg-surface-highlight text rounded-sm",
+ hasButtonSubmenu && "pr-8",
+ item.color === "danger" && "text-danger!",
+ item.color === "primary" && "text-primary!",
+ item.color === "success" && "text-success!",
+ item.color === "warning" && "text-warning!",
+ item.color === "notice" && "text-notice!",
+ item.color === "info" && "text-info!",
+ )}
+ {...props}
+ >
+ {item.label}
+
+ );
+
+ if (!hasButtonSubmenu) {
+ return button;
+ }
+
+ // The submenu trigger overlays the row as a sibling (not a child) because the row is
+ // itself a button and buttons cannot nest. Hover handling lives on this wrapper so the
+ // row keeps its focus highlight while the mouse is over the trigger.
+ return (
+ {
+ const el = buttonRef.current;
+ if (el == null) return;
+ onHover(item, el);
+ el.focus();
+ }}
+ onMouseLeave={() => buttonRef.current?.blur()}
+ >
+ {button}
+
+ {
+ // Prevent the trigger from stealing focus, which would unhighlight the row
+ e.preventDefault();
+ }}
+ onClick={(e) => {
+ e.stopPropagation();
+ onOpenSubmenu(item, e.currentTarget);
+ }}
+ />
+
+
+ );
+}
+
+interface MenuItemHotKeyProps {
+ action: HotkeyAction | undefined;
+ onSelect: MenuItemProps["onSelect"];
+ item: MenuItemProps["item"];
+}
+
+function MenuItemHotKey({ action, onSelect, item }: MenuItemHotKeyProps) {
+ useHotKey(action ?? null, () => onSelect(item));
+ return null;
+}
+
+function sign(
+ p1: { x: number; y: number },
+ p2: { x: number; y: number },
+ p3: { x: number; y: number },
+) {
+ return (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y);
+}
+
+function isPointInTriangle(
+ pt: { x: number; y: number },
+ v1: { x: number; y: number },
+ v2: { x: number; y: number },
+ v3: { x: number; y: number },
+) {
+ const d1 = sign(pt, v1, v2);
+ const d2 = sign(pt, v2, v3);
+ const d3 = sign(pt, v3, v1);
+
+ const has_neg = d1 < 0 || d2 < 0 || d3 < 0;
+ const has_pos = d1 > 0 || d2 > 0 || d3 > 0;
+
+ return !(has_neg && has_pos);
+}
diff --git a/packages/ui/src/components/ErrorBoundary.tsx b/packages/ui/src/components/ErrorBoundary.tsx
new file mode 100644
index 000000000..7a5af266c
--- /dev/null
+++ b/packages/ui/src/components/ErrorBoundary.tsx
@@ -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 {
+ 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 (
+
+
+ Error rendering {name} component
+ {onShowError == null && `: ${error instanceof Error ? error.message : String(error)}`}
+
+ {onShowError && (
+ onShowError(error)}
+ >
+ Show
+
+ )}
+
+ );
+ }
+}
diff --git a/packages/ui/src/components/Hotkey.tsx b/packages/ui/src/components/Hotkey.tsx
new file mode 100644
index 000000000..452964fca
--- /dev/null
+++ b/packages/ui/src/components/Hotkey.tsx
@@ -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 ;
+}
+
+interface HotkeyRawProps {
+ labelParts: string[];
+ className?: string;
+ variant?: "text" | "with-bg";
+}
+
+export function HotkeyRaw({ labelParts, className, variant }: HotkeyRawProps) {
+ return (
+
+ {labelParts.map((char, index) => (
+ // oxlint-disable-next-line react/no-array-index-key
+
+ {char}
+
+ ))}
+
+ );
+}
diff --git a/packages/ui/src/components/Icon.tsx b/packages/ui/src/components/Icon.tsx
index 675b46e8c..53f1381e7 100644
--- a/packages/ui/src/components/Icon.tsx
+++ b/packages/ui/src/components/Icon.tsx
@@ -118,6 +118,7 @@ import {
RotateCcwIcon,
Rows2Icon,
SaveIcon,
+ ScissorsIcon,
SearchIcon,
SendHorizontalIcon,
SettingsIcon,
@@ -264,6 +265,7 @@ const icons = {
rotate_ccw: RotateCcwIcon,
rows_2: Rows2Icon,
save: SaveIcon,
+ scissors: ScissorsIcon,
search: SearchIcon,
send_horizontal: SendHorizontalIcon,
settings: SettingsIcon,
diff --git a/packages/ui/src/components/Separator.tsx b/packages/ui/src/components/Separator.tsx
new file mode 100644
index 000000000..eaa29583c
--- /dev/null
+++ b/packages/ui/src/components/Separator.tsx
@@ -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 (
+
+ {children && (
+
{children}
+ )}
+ {action && (
+
+ )}
+
+
+ );
+}
diff --git a/packages/ui/src/hooks/useClickOutside.ts b/packages/ui/src/hooks/useClickOutside.ts
new file mode 100644
index 000000000..665ddc72a
--- /dev/null
+++ b/packages/ui/src/hooks/useClickOutside.ts
@@ -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,
+ onClickAway: (event: MouseEvent) => void,
+ ignored?: RefObject,
+) {
+ 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]);
+}
diff --git a/packages/ui/src/hooks/useHotKey.ts b/packages/ui/src/hooks/useHotKey.ts
new file mode 100644
index 000000000..09f2896ee
--- /dev/null
+++ b/packages/ui/src/hooks/useHotKey.ts
@@ -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({
+ 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 = {
+ 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 = {
+ 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(" ");
+}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index a8f460a9c..bc7d650bf 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -8,12 +8,25 @@ export { CountBadge } from "./components/CountBadge";
export type { DetailsBannerProps } from "./components/DetailsBanner";
export { DetailsBanner } from "./components/DetailsBanner";
export { DropMarker } from "./components/DropMarker";
+export type {
+ ContextMenuProps,
+ DropdownItem,
+ DropdownItemContent,
+ DropdownItemDefault,
+ DropdownItemSeparator,
+ DropdownProps,
+ DropdownRef,
+} from "./components/Dropdown";
+export { ContextMenu, Dropdown } from "./components/Dropdown";
export { LazyCodeEditor } from "./components/editor/LazyCodeEditor";
+export type { ErrorBoundaryProps } from "./components/ErrorBoundary";
+export { ErrorBoundary } from "./components/ErrorBoundary";
export { FormattedError } from "./components/FormattedError";
export { HeaderSize } from "./components/HeaderSize";
export type { HeadersTableProps } from "./components/HeadersTable";
export { HeadersTable } from "./components/HeadersTable";
export { Heading } from "./components/Heading";
+export { Hotkey, HotkeyRaw } from "./components/Hotkey";
export type { IconProps } from "./components/Icon";
export { Icon } from "./components/Icon";
export type { IconButtonProps } from "./components/IconButton";
@@ -28,6 +41,8 @@ export { Label } from "./components/Label";
export { LoadingIcon } from "./components/LoadingIcon";
export { Overlay } from "./components/Overlay";
export { Portal } from "./components/Portal";
+export type { SeparatorAction } from "./components/Separator";
+export { Separator } from "./components/Separator";
export type { ResizeHandleEvent } from "./components/ResizeHandle";
export { ResizeHandle } from "./components/ResizeHandle";
export { SidebarLayout } from "./components/SidebarLayout";
@@ -77,9 +92,17 @@ export { TextViewer } from "./components/viewers/TextViewer";
export { VideoViewer } from "./components/viewers/VideoViewer";
export { WebPageViewer } from "./components/viewers/WebPageViewer";
export { WindowControls } from "./components/WindowControls";
+export { useClickOutside } from "./hooks/useClickOutside";
export { useContainerSize } from "./hooks/useContainerSize";
export { useDebouncedState } from "./hooks/useDebouncedState";
export { useDebouncedValue } from "./hooks/useDebouncedValue";
+export type {
+ HotKeyOptions,
+ HotkeyAction,
+ HotkeyActionRegistry,
+ HotkeyContextValue,
+} from "./hooks/useHotKey";
+export { formatHotkey, HotkeyProvider, useFormattedHotkey, useHotKey } from "./hooks/useHotKey";
export { useIsFullscreen } from "./hooks/useIsFullscreen";
export { usePortal } from "./hooks/usePortal";
export { useStateWithDeps } from "./hooks/useStateWithDeps";
@@ -93,4 +116,5 @@ export { HEADER_SIZE_LG, HEADER_SIZE_MD, WINDOW_CONTROLS_WIDTH } from "./lib/con
export { languageFromContent, languageFromContentType } from "./lib/contentType";
export { computeSideForDragMove } from "./lib/dnd";
export { docFingerprint } from "./lib/docFingerprint";
+export { getNodeText } from "./lib/getNodeText";
export { minPromiseMillis } from "./lib/minPromiseMillis";
diff --git a/packages/ui/src/lib/getNodeText.ts b/packages/ui/src/lib/getNodeText.ts
new file mode 100644
index 000000000..96a1274ec
--- /dev/null
+++ b/packages/ui/src/lib/getNodeText.ts
@@ -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 "";
+}