From 686294939b93839350057808ff0c078014436a58 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Mon, 26 Oct 2026 06:38:34 -0700 Subject: [PATCH] Rescue the proxy GUI slice (#785) Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 3 + apps/yaak-client/components/ErrorBoundary.tsx | 63 +- apps/yaak-client/components/core/Dropdown.tsx | 1084 +---------------- apps/yaak-client/components/core/Hotkey.tsx | 46 +- .../yaak-client/components/core/Separator.tsx | 79 +- apps/yaak-client/hooks/useClickOutside.ts | 42 +- apps/yaak-client/hooks/useHotKey.ts | 127 +- apps/yaak-client/lib/getNodeText.ts | 23 +- apps/yaak-client/routes/__root.tsx | 32 +- apps/yaak-proxy/components/ExchangesTable.tsx | 76 -- apps/yaak-proxy/components/FlowBodyViewer.tsx | 183 +++ apps/yaak-proxy/components/FlowDetail.tsx | 292 +++++ apps/yaak-proxy/components/FlowHeaders.tsx | 109 ++ apps/yaak-proxy/components/FlowList.tsx | 281 +++++ apps/yaak-proxy/components/ProxyLayout.tsx | 78 +- apps/yaak-proxy/components/ProxyStatus.tsx | 177 +++ apps/yaak-proxy/components/Sidebar.tsx | 10 +- apps/yaak-proxy/font-size.ts | 2 +- apps/yaak-proxy/lib/atoms.ts | 8 + apps/yaak-proxy/lib/body.test.ts | 20 + apps/yaak-proxy/lib/body.ts | 87 ++ apps/yaak-proxy/lib/follow.test.ts | 28 + apps/yaak-proxy/lib/follow.ts | 17 + apps/yaak-proxy/lib/hotkeys.ts | 118 +- apps/yaak-proxy/lib/rpc.ts | 7 +- apps/yaak-proxy/lib/statusReason.ts | 41 + apps/yaak-proxy/lib/store.ts | 3 +- apps/yaak-proxy/main.css | 1 + apps/yaak-proxy/main.tsx | 24 +- apps/yaak-proxy/modules.d.ts | 1 + apps/yaak-proxy/package.json | 4 +- crates-proxy/yaak-proxy-lib/Cargo.toml | 6 +- .../yaak-proxy-lib/bindings/gen_models.ts | 2 + .../yaak-proxy-lib/bindings/gen_rpc.ts | 56 +- crates-proxy/yaak-proxy-lib/src/bodies.rs | 45 +- crates-proxy/yaak-proxy-lib/src/ca.rs | 75 ++ crates-proxy/yaak-proxy-lib/src/lib.rs | 151 ++- crates-proxy/yaak-proxy-lib/src/models.rs | 4 +- crates-proxy/yaak-proxy-lib/tests/flow.rs | 136 ++- crates/common/yaak-rpc/src/lib.rs | 13 + package-lock.json | 4 +- packages/ui/src/components/Dropdown.tsx | 1071 ++++++++++++++++ packages/ui/src/components/ErrorBoundary.tsx | 60 + packages/ui/src/components/Hotkey.tsx | 45 + packages/ui/src/components/Icon.tsx | 2 + packages/ui/src/components/Separator.tsx | 77 ++ packages/ui/src/hooks/useClickOutside.ts | 41 + packages/ui/src/hooks/useHotKey.ts | 90 ++ packages/ui/src/index.ts | 24 + packages/ui/src/lib/getNodeText.ts | 22 + 50 files changed, 3394 insertions(+), 1596 deletions(-) delete mode 100644 apps/yaak-proxy/components/ExchangesTable.tsx create mode 100644 apps/yaak-proxy/components/FlowBodyViewer.tsx create mode 100644 apps/yaak-proxy/components/FlowDetail.tsx create mode 100644 apps/yaak-proxy/components/FlowHeaders.tsx create mode 100644 apps/yaak-proxy/components/FlowList.tsx create mode 100644 apps/yaak-proxy/components/ProxyStatus.tsx create mode 100644 apps/yaak-proxy/lib/atoms.ts create mode 100644 apps/yaak-proxy/lib/body.test.ts create mode 100644 apps/yaak-proxy/lib/body.ts create mode 100644 apps/yaak-proxy/lib/follow.test.ts create mode 100644 apps/yaak-proxy/lib/follow.ts create mode 100644 apps/yaak-proxy/lib/statusReason.ts create mode 100644 apps/yaak-proxy/modules.d.ts create mode 100644 crates-proxy/yaak-proxy-lib/src/ca.rs create mode 100644 packages/ui/src/components/Dropdown.tsx create mode 100644 packages/ui/src/components/ErrorBoundary.tsx create mode 100644 packages/ui/src/components/Hotkey.tsx create mode 100644 packages/ui/src/components/Separator.tsx create mode 100644 packages/ui/src/hooks/useClickOutside.ts create mode 100644 packages/ui/src/hooks/useHotKey.ts create mode 100644 packages/ui/src/lib/getNodeText.ts diff --git a/Cargo.lock b/Cargo.lock index cdd454480..0e9119bce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11755,6 +11755,7 @@ dependencies = [ name = "yaak-proxy-lib" version = "0.0.0" dependencies = [ + "base64 0.23.1", "bytes", "chrono", "flate2", @@ -11774,10 +11775,12 @@ dependencies = [ "tokio", "ts-rs", "yaak-database", + "yaak-filter", "yaak-http", "yaak-models", "yaak-proxy", "yaak-rpc", + "yaak-templates", ] [[package]] diff --git a/apps/yaak-client/components/ErrorBoundary.tsx b/apps/yaak-client/components/ErrorBoundary.tsx index a764bfe77..cba573a8f 100644 --- a/apps/yaak-client/components/ErrorBoundary.tsx +++ b/apps/yaak-client/components/ErrorBoundary.tsx @@ -1,60 +1,19 @@ -import { Banner, Button, InlineCode } from "@yaakapp-internal/ui"; -import type { ErrorInfo, ReactNode } from "react"; -import { Component, useEffect } from "react"; +import { ErrorBoundary as BaseErrorBoundary } from "@yaakapp-internal/ui"; +import type { ReactNode } from "react"; +import { useEffect } from "react"; import { showDialog } from "../lib/dialog"; import RouteError from "./RouteError"; -interface ErrorBoundaryProps { - name: string; - children: ReactNode; +export function ErrorBoundary({ name, children }: { name: string; children: ReactNode }) { + return ( + + {children} + + ); } -interface ErrorBoundaryState { - hasError: boolean; - error: Error | null; -} - -export class ErrorBoundary extends Component { - constructor(props: ErrorBoundaryProps) { - super(props); - this.state = { hasError: false, error: null }; - } - - static getDerivedStateFromError(error: Error): ErrorBoundaryState { - return { hasError: true, error }; - } - - componentDidCatch(error: Error, info: ErrorInfo) { - console.warn("Error caught by ErrorBoundary:", error, info); - } - - render() { - if (this.state.hasError) { - return ( - -
- Error rendering {this.props.name} component -
- -
- ); - } - - return this.props.children; - } +function showErrorDialog(error: unknown) { + showDialog({ id: "error-boundary", render: () => }); } export function ErrorBoundaryTestThrow() { diff --git a/apps/yaak-client/components/core/Dropdown.tsx b/apps/yaak-client/components/core/Dropdown.tsx index d35687b21..baa52d220 100644 --- a/apps/yaak-client/components/core/Dropdown.tsx +++ b/apps/yaak-client/components/core/Dropdown.tsx @@ -1,1076 +1,10 @@ -import { - HStack, - Icon, - type IconProps, - LoadingIcon, - Overlay, - VStack, - useStateWithDeps, +export type { + ContextMenuProps, + DropdownItem, + DropdownItemContent, + DropdownItemDefault, + DropdownItemSeparator, + DropdownProps, + DropdownRef, } from "@yaakapp-internal/ui"; -import classNames from "classnames"; -import { atom } 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 { getNodeText } from "../../lib/getNodeText"; -import { jotaiStore } from "../../lib/jotai"; -import { fireAndForget } from "../../lib/fireAndForget"; -import { ErrorBoundary } from "../ErrorBoundary"; -import { Button } from "./Button"; -import { Hotkey } from "./Hotkey"; -import { IconButton } from "./IconButton"; -import type { SeparatorAction } from "./Separator"; -import { Separator } from "./Separator"; -import { generateId } from "@yaakapp-internal/lib/generateId"; - -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); - -export const Dropdown = forwardRef(function Dropdown( - { children, items, hotKeyAction, fullWidth, onOpen }: DropdownProps, - ref, -) { - const id = useRef(generateId()); - const [isOpen, setIsOpen] = useState(false); - - useEffect(() => { - return jotaiStore.sub(openAtom, () => { - const globalOpenId = jotaiStore.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) => { - jotaiStore.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) { - fireAndForget(handleSelect(item)); - } - }, - {}, - [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 = ( - - ); - - 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); -} +export { ContextMenu, Dropdown } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/components/core/Hotkey.tsx b/apps/yaak-client/components/core/Hotkey.tsx index faa86ceeb..fe8b3e9ca 100644 --- a/apps/yaak-client/components/core/Hotkey.tsx +++ b/apps/yaak-client/components/core/Hotkey.tsx @@ -1,45 +1 @@ -import { HStack } from "@yaakapp-internal/ui"; -import classNames from "classnames"; -import type { HotkeyAction } from "../../hooks/useHotKey"; -import { useFormattedHotkey } from "../../hooks/useHotKey"; - -interface Props { - action: HotkeyAction | null; - className?: string; - variant?: "text" | "with-bg"; -} - -export function Hotkey({ action, className, variant }: Props) { - const labelParts = useFormattedHotkey(action); - if (labelParts === null) { - return null; - } - - return ; -} - -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} -
- ))} -
- ); -} +export { Hotkey, HotkeyRaw } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/components/core/Separator.tsx b/apps/yaak-client/components/core/Separator.tsx index 40ae59609..9a0554a3e 100644 --- a/apps/yaak-client/components/core/Separator.tsx +++ b/apps/yaak-client/components/core/Separator.tsx @@ -1,77 +1,2 @@ -import type { Color } from "@yaakapp-internal/plugins"; -import type { IconProps } from "@yaakapp-internal/ui"; -import { IconButton } from "@yaakapp-internal/ui"; -import classNames from "classnames"; -import type { ReactNode } from "react"; - -/** - * A single control attached to a labelled separator, rendered between the label - * and the rule. - * - * Declared rather than passed as a node so the separator keeps ownership of the - * things that are easy to get wrong by hand: matching the label's colour, and - * staying out of the rule's way when the label is long. - */ -export interface SeparatorAction { - icon: IconProps["icon"]; - /** Tooltip and accessible name. Required — the control is icon-only. */ - title: string; - onClick: () => void; -} - -interface Props { - orientation?: "horizontal" | "vertical"; - dashed?: boolean; - className?: string; - children?: ReactNode; - color?: Color; - action?: SeparatorAction; -} - -export function Separator({ - color, - className, - dashed, - orientation = "horizontal", - children, - action, -}: Props) { - return ( -
- {children && ( -
{children}
- )} - {action && ( - - )} -
-
- ); -} +export type { SeparatorAction } from "@yaakapp-internal/ui"; +export { Separator } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/hooks/useClickOutside.ts b/apps/yaak-client/hooks/useClickOutside.ts index 665ddc72a..ab8a10c2c 100644 --- a/apps/yaak-client/hooks/useClickOutside.ts +++ b/apps/yaak-client/hooks/useClickOutside.ts @@ -1,41 +1 @@ -import type { RefObject } from "react"; -import { useEffect, useRef } from "react"; - -/** - * Get notified when a mouse click happens outside the target ref - * @param ref The element to be notified when a mouse click happens outside it - * @param onClickAway - * @param ignored Optional outside element to ignore (useful for dropdown triggers) - */ -export function useClickOutside( - ref: RefObject, - 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]); -} +export { useClickOutside } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/hooks/useHotKey.ts b/apps/yaak-client/hooks/useHotKey.ts index 237786b5d..42f3a52f5 100644 --- a/apps/yaak-client/hooks/useHotKey.ts +++ b/apps/yaak-client/hooks/useHotKey.ts @@ -1,11 +1,26 @@ import { platform } from "@yaakapp-internal/platform"; import { debounce } from "@yaakapp-internal/lib"; import { settingsAtom } from "@yaakapp-internal/models"; +import { + formatHotkey, + type HotkeyContextValue, + type HotKeyOptions, + useFormattedHotkey, + useHotKey, +} from "@yaakapp-internal/ui"; import { atom, useAtomValue } from "jotai"; -import { useEffect } from "react"; -import { capitalize } from "../lib/capitalize"; +import { useEffect, useMemo } from "react"; import { jotaiStore } from "../lib/jotai"; +export type { HotKeyOptions }; +export { useFormattedHotkey, useHotKey }; + +declare module "@yaakapp-internal/ui" { + interface HotkeyActionRegistry { + action: HotkeyAction; + } +} + const HOLD_KEYS = ["Shift", "Control", "Command", "Alt", "Meta"]; const SINGLE_WHITELIST = ["Delete", "Enter", "Backspace"]; @@ -188,12 +203,6 @@ export const hotkeyActions: HotkeyAction[] = ( return hotkeyLabels[a].localeCompare(hotkeyLabels[b]); }); -export type HotKeyOptions = { - enable?: boolean | (() => boolean); - priority?: number; - allowDefault?: boolean; -}; - interface Callback { action: HotkeyAction; callback: (e: KeyboardEvent) => void; @@ -210,25 +219,34 @@ const clearCurrentKeysDebounced = debounce(() => { jotaiStore.set(currentKeysAtom, new Set([])); }, 5000); -export function useHotKey( - action: HotkeyAction | null, +function registerHotkey( + action: HotkeyAction, callback: (e: KeyboardEvent) => void, - options: HotKeyOptions = {}, + options: HotKeyOptions, ) { - useEffect(() => { - if (action == null) return; - jotaiStore.set(callbacksAtom, (prev) => { - const without = prev.filter((cb) => { - const isTheSame = cb.action === action && cb.options.priority === options.priority; - return !isTheSame; - }); - const newCb: Callback = { action, callback, options }; - return [...without, newCb]; + jotaiStore.set(callbacksAtom, (prev) => { + const without = prev.filter((cb) => { + const isTheSame = cb.action === action && cb.options.priority === options.priority; + return !isTheSame; }); - return () => { - jotaiStore.set(callbacksAtom, (prev) => prev.filter((cb) => cb.callback !== callback)); - }; - }, [action, callback, options]); + const newCb: Callback = { action, callback, options }; + return [...without, newCb]; + }); + return () => { + jotaiStore.set(callbacksAtom, (prev) => prev.filter((cb) => cb.callback !== callback)); + }; +} + +export function useHotkeyContextValue(): HotkeyContextValue { + const hotkeys = useAtomValue(hotkeysAtom); + return useMemo( + () => ({ + os: platform.osType(), + bindingFor: (action: HotkeyAction) => hotkeys[action] ?? null, + register: registerHotkey, + }), + [hotkeys], + ); } export function useSubscribeHotKeys() { @@ -330,66 +348,7 @@ export function getHotkeyScope(action: HotkeyAction): string { } export function formatHotkeyString(trigger: string): string[] { - const os = platform.osType(); - const parts = trigger.split("+"); - const labelParts: string[] = []; - - for (const p of parts) { - if (os === "macos") { - if (p === "Meta") { - labelParts.push("⌘"); - } else if (p === "Shift") { - labelParts.push("⇧"); - } else if (p === "Control") { - labelParts.push("⌃"); - } else if (p === "Alt") { - labelParts.push("⌥"); - } else if (p === "Enter") { - // U+21A9 has an emoji presentation, which Chromium's font fallback picks - // (a blue glyph among monochrome ones). U+FE0E forces the text form. - labelParts.push("↩︎"); - } else if (p === "Tab") { - labelParts.push("⇥"); - } else if (p === "Backspace") { - labelParts.push("⌫"); - } else if (p === "Delete") { - labelParts.push("⌦"); - } else if (p === "Minus") { - labelParts.push("-"); - } else if (p === "Plus") { - labelParts.push("+"); - } else if (p === "Equal") { - labelParts.push("="); - } else if (p === "Space") { - labelParts.push("Space"); - } else { - labelParts.push(capitalize(p)); - } - } else { - if (p === "Control") { - labelParts.push("Ctrl"); - } else if (p === "Space") { - labelParts.push("Space"); - } else { - labelParts.push(capitalize(p)); - } - } - } - - if (os === "macos") { - return labelParts; - } - return [labelParts.join("+")]; -} - -export function useFormattedHotkey(action: HotkeyAction | null): string[] | null { - const hotkeys = useAtomValue(hotkeysAtom); - const trigger = action != null ? (hotkeys[action]?.[0] ?? null) : null; - if (trigger == null) { - return null; - } - - return formatHotkeyString(trigger); + return formatHotkey(trigger, platform.osType()); } function compareKeys(keysA: string[], keysB: string[]) { diff --git a/apps/yaak-client/lib/getNodeText.ts b/apps/yaak-client/lib/getNodeText.ts index 96a1274ec..a0d71dc50 100644 --- a/apps/yaak-client/lib/getNodeText.ts +++ b/apps/yaak-client/lib/getNodeText.ts @@ -1,22 +1 @@ -import type { ReactNode } from "react"; - -/** - * Get the text content from a ReactNode - * https://stackoverflow.com/questions/50428910/get-text-content-from-node-in-react - */ -export function getNodeText(node: ReactNode): string { - if (typeof node === "string" || typeof node === "number") { - return String(node); - } - - if (Array.isArray(node)) { - return node.map(getNodeText).join(""); - } - - if (typeof node === "object" && node) { - // oxlint-disable-next-line no-explicit-any - return getNodeText((node as any).props.children); - } - - return ""; -} +export { getNodeText } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/routes/__root.tsx b/apps/yaak-client/routes/__root.tsx index 14ad2227a..1c0380008 100644 --- a/apps/yaak-client/routes/__root.tsx +++ b/apps/yaak-client/routes/__root.tsx @@ -1,12 +1,14 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { createRootRoute, Outlet } from "@tanstack/react-router"; import { platform } from "@yaakapp-internal/platform"; +import { HotkeyProvider } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { Provider as JotaiProvider } from "jotai"; import { LazyMotion, MotionConfig } from "motion/react"; -import { lazy, Suspense } from "react"; +import { lazy, type ReactNode, Suspense } from "react"; import { GlobalHooks } from "../components/GlobalHooks"; import RouteError from "../components/RouteError"; +import { useHotkeyContextValue } from "../hooks/useHotKey"; import { jotaiStore } from "../lib/jotai"; import { queryClient } from "../lib/queryClient"; @@ -27,22 +29,28 @@ function RouteComponent() { return ( - - - - - - - - - - - + + + + + + + + + + + + + ); } +function Hotkeys({ children }: { children: ReactNode }) { + return {children}; +} + function Layout() { return (
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 ( +
+
{children}
+
+ ); +} 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 ( + + + + ); +} + +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 && ( + + )} +
+
+ ); +} + +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={ + + } + /> + ), + }, + { 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 ( + + ); +} + +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 (