From c629d4c66c3e6797811be6e6f57177a77d258ef1 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Fri, 6 Nov 2026 14:45:44 -0700 Subject: [PATCH] Proxy shell from the design: list, sidebar, header, empty states and instant Clear (#820) Co-authored-by: Claude Opus 5.5 --- .../components/ClearFlowsButton.tsx | 28 +- apps/yaak-proxy/components/FirstRun.tsx | 18 +- apps/yaak-proxy/components/FlowList.tsx | 511 ++++++++++---- apps/yaak-proxy/components/FlowListEmpty.tsx | 120 ++++ apps/yaak-proxy/components/FlowListHeader.tsx | 151 +++++ apps/yaak-proxy/components/ProxyLayout.tsx | 69 +- apps/yaak-proxy/components/ProxyStatus.tsx | 193 +++--- apps/yaak-proxy/components/SearchBar.tsx | 170 ++--- apps/yaak-proxy/components/Sidebar.tsx | 260 ++----- apps/yaak-proxy/font-size.ts | 5 +- apps/yaak-proxy/harness/fixtures.ts | 2 + apps/yaak-proxy/lib/atoms.ts | 3 + apps/yaak-proxy/lib/columns.test.ts | 128 ++++ apps/yaak-proxy/lib/columns.ts | 314 +++++++++ apps/yaak-proxy/lib/firstRun.test.ts | 27 + apps/yaak-proxy/lib/firstRun.ts | 21 + apps/yaak-proxy/lib/flowActions.ts | 32 +- apps/yaak-proxy/lib/flowViews.test.ts | 2 + apps/yaak-proxy/lib/search.test.ts | 188 +++++- apps/yaak-proxy/lib/search.ts | 238 ++++++- apps/yaak-proxy/lib/sidebarCollapse.test.ts | 46 +- apps/yaak-proxy/lib/sidebarCollapse.ts | 13 +- apps/yaak-proxy/lib/sidebarTree.test.ts | 139 ++++ apps/yaak-proxy/lib/sidebarTree.ts | 263 ++++++++ apps/yaak-proxy/lib/store.ts | 260 ++++++- apps/yaak-proxy/main.css | 25 +- apps/yaak-proxy/main.tsx | 3 +- apps/yaak-proxy/package.json | 1 + .../yaak-proxy-lib/bindings/gen_models.ts | 12 +- .../yaak-proxy-lib/bindings/gen_rpc.ts | 26 +- .../00000007_flow_cwd_and_clear_mark.sql | 10 + .../00000008_flow_clear_cleaner.sql | 4 + .../yaak-proxy-lib/src/attribution.rs | 53 +- crates-proxy/yaak-proxy-lib/src/db.rs | 128 +++- crates-proxy/yaak-proxy-lib/src/flows.rs | 637 ++++++++++++++++-- crates-proxy/yaak-proxy-lib/src/lib.rs | 146 +++- crates-proxy/yaak-proxy-lib/src/models.rs | 11 + crates-proxy/yaak-proxy-lib/tests/streams.rs | 6 + package-lock.json | 19 + packages/ui/src/components/CountBadge.tsx | 1 + packages/ui/src/components/Icon.tsx | 6 + 41 files changed, 3575 insertions(+), 714 deletions(-) create mode 100644 apps/yaak-proxy/components/FlowListEmpty.tsx create mode 100644 apps/yaak-proxy/components/FlowListHeader.tsx create mode 100644 apps/yaak-proxy/lib/columns.test.ts create mode 100644 apps/yaak-proxy/lib/columns.ts create mode 100644 apps/yaak-proxy/lib/firstRun.test.ts create mode 100644 apps/yaak-proxy/lib/firstRun.ts create mode 100644 apps/yaak-proxy/lib/sidebarTree.test.ts create mode 100644 apps/yaak-proxy/lib/sidebarTree.ts create mode 100644 crates-proxy/yaak-proxy-lib/migrations/00000007_flow_cwd_and_clear_mark.sql create mode 100644 crates-proxy/yaak-proxy-lib/migrations/00000008_flow_clear_cleaner.sql diff --git a/apps/yaak-proxy/components/ClearFlowsButton.tsx b/apps/yaak-proxy/components/ClearFlowsButton.tsx index eb7cbdf25..522ac1142 100644 --- a/apps/yaak-proxy/components/ClearFlowsButton.tsx +++ b/apps/yaak-proxy/components/ClearFlowsButton.tsx @@ -1,12 +1,16 @@ import { Dropdown, type DropdownItem, IconButton } from "@yaakapp-internal/ui"; -import { useAtomValue } from "jotai"; -import { useRpcMutation } from "../hooks/useRpcMutation"; -import { flowTotalAtom } from "../lib/store"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useState } from "react"; +import { clearFlowsAtom, flowTotalAtom } from "../lib/store"; -/** Deletes every flow, once confirmed in its menu, which its hotkey opens too */ +/** + * Clears every flow, once confirmed in its menu, which its hotkey opens too. The list empties at + * once; the flows are deleted in the background. + */ export function ClearFlowsButton() { const total = useAtomValue(flowTotalAtom); - const clear = useRpcMutation("clear_flows"); + const clear = useSetAtom(clearFlowsAtom); + const [error, setError] = useState(null); const items: DropdownItem[] = [ { @@ -14,11 +18,11 @@ export function ClearFlowsButton() { label: (
- {total === 1 ? "Clear 1 flow?" : `Clear all ${total} flows?`} + {total === 1 ? "Clear 1 flow?" : `Clear all ${total.toLocaleString()} flows?`}
- {clear.error != null ? ( - {clear.error.message} + {error != null ? ( + {error} ) : ( "They are deleted from disk with their bodies, for good." )} @@ -31,7 +35,13 @@ export function ClearFlowsButton() { label: "Clear", icon: "trash", color: "danger", - onSelect: () => clear.mutate({}), + onSelect: () => { + setError(null); + clear().catch((err: unknown) => { + const message = (err as { message?: unknown } | null)?.message; + setError(typeof message === "string" ? message : String(err)); + }); + }, }, ]; diff --git a/apps/yaak-proxy/components/FirstRun.tsx b/apps/yaak-proxy/components/FirstRun.tsx index 987bb07b4..c799108e4 100644 --- a/apps/yaak-proxy/components/FirstRun.tsx +++ b/apps/yaak-proxy/components/FirstRun.tsx @@ -1,4 +1,4 @@ -import { Button, Icon, LoadingIcon } from "@yaakapp-internal/ui"; +import { Button, Icon, InlineCode, LoadingIcon } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useAtomValue } from "jotai"; import type { ReactNode } from "react"; @@ -54,8 +54,8 @@ export function FirstRun() {
Set up Yaak Proxy
- Three steps, once. Afterwards every app on this Mac sends its traffic here, and HTTPS - reads as plain text. + Three steps, once. Then every app on this Mac sends its traffic here, and HTTPS reads as + plain text.
trust.mutate({})} > - {trustState === "fail" ? "Try again" : "Trust certificate"} + {trustState === "fail" ? "Try Again" : "Trust Certificate"} ) : null } @@ -104,7 +104,7 @@ export function FirstRun() { color={systemState === "fail" ? "default" : "primary"} onClick={() => setSystemProxy.mutate({ enabled: true })} > - {systemState === "fail" ? "Try again" : "Turn on"} + {systemState === "fail" ? "Try Again" : "Turn On"} ) : null } @@ -139,7 +139,7 @@ export function FirstRun() { color="primary" onClick={() => execute.mutate({ scope: "global", action: "recording_toggle" })} > - Resume recording + Resume Recording ) : null } @@ -154,9 +154,13 @@ export function FirstRun() { leftSlot={} onClick={() => void copyEnvironment()} > - Copy environment + Copy Environment
+
+ Working with an agent? yaak mcp gives it the proxy's flows and + actions. +
); diff --git a/apps/yaak-proxy/components/FlowList.tsx b/apps/yaak-proxy/components/FlowList.tsx index 9308e7e6d..37c12e924 100644 --- a/apps/yaak-proxy/components/FlowList.tsx +++ b/apps/yaak-proxy/components/FlowList.tsx @@ -3,75 +3,116 @@ import { formatSize } from "@yaakapp-internal/lib/formatSize"; import type { Flow } from "@yaakapp-internal/proxy-lib"; import { ContextMenu, + CountBadge, formatMillis, HttpMethodTagRaw, HttpStatusTagRaw, Icon, + useContainerSize, } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import type { CSSProperties, KeyboardEvent, MouseEvent, WheelEvent } from "react"; -import { memo, useCallback, useLayoutEffect, useRef, useState } from "react"; +import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import { selectedFlowAtom, selectedFlowIdAtom } from "../lib/atoms"; import { shortContentType } from "../lib/body"; -import { useFlowActions } from "../lib/flowActions"; +import { utcTimestamp } from "../lib/flowViews"; +import { + type Column, + type ColumnId, + gridTemplate, + hostOf, + nextSort, + shownColumns, +} from "../lib/columns"; +import { useExportShownHar, useFlowActions } from "../lib/flowActions"; import { compareFlows } from "../lib/flowWindow"; import { countNewFlows } from "../lib/follow"; +import { folderOf } from "../lib/search"; import { - activeQueryAtom, - followAtom, + columnPrefsAtom, hasAppsAtom, + flowSortAtom, + followAtom, loadOlderFlowsAtom, newestStartedAtAtom, oldestFlowAtom, - searchWindowAtom, + proxyStateAtom, shownFlowsAtom, } from "../lib/store"; import { AppIcon } from "./AppIcon"; -import { SearchBar } from "./SearchBar"; +import { FlowListEmpty, PausedRow } from "./FlowListEmpty"; +import { FlowListHeader } from "./FlowListHeader"; -const ROW_HEIGHT = 24; const BOTTOM_PADDING = 6; /** Rows from the top at which older flows start loading */ const LOAD_OLDER_ROWS = 20; +/** How long a row that arrives while following stays tinted */ +const ARRIVAL_MS = 1400; +/** Most flows arriving together that still tint; more is a burst */ +const ARRIVAL_MAX_BATCH = 5; + +/** `h-xs`, the height of Yaak's event rows and menu items, in pixels at the root font size */ +function rowHeight(): number { + const root = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 15; + return Math.round(1.8 * root); +} interface Props { - sideBySide: boolean; style?: CSSProperties; className?: string; } /** * Follows new traffic until a flow is selected or the list is scrolled, then holds. Scrolling to - * the top loads older flows from the database. + * the top loads older flows from the database. Catching up leaves a divider where the hold + * began. Sorting by a column sorts the flows loaded and stops following. */ -export function FlowList({ sideBySide, style, className }: Props) { +export function FlowList({ style, className }: Props) { const flows = useAtomValue(shownFlowsAtom); - const hasApps = useAtomValue(hasAppsAtom); const [menu, setMenu] = useState<{ flow: Flow; x: number; y: number } | null>(null); const flowActions = useFlowActions(); + const exportShown = useExportShownHar(); const selectedId = useAtomValue(selectedFlowIdAtom); const setSelectedFlow = useSetAtom(selectedFlowAtom); const loadOlder = useSetAtom(loadOlderFlowsAtom); const oldestFlow = useAtomValue(oldestFlowAtom); + const prefs = useAtomValue(columnPrefsAtom); + const [sort, setSort] = useAtom(flowSortAtom); + const proxyState = useAtomValue(proxyStateAtom); + const paused = proxyState?.state === "running" && proxyState.recording === false; + const containerRef = useRef(null); + const { width } = useContainerSize(containerRef); const scrollRef = useRef(null); const ignoreScroll = useRef(false); const [following, setFollowing] = useAtom(followAtom); const [heldAt, setHeldAt] = useState(null); + /** The first flow after the last catch-up's hold, and how many came after it */ + const [divider, setDivider] = useState<{ id: string; count: number } | null>(null); // The newest flow across all traffic, not just this list, so a filter change keeps the hold const newestStartedAt = useAtomValue(newestStartedAtAtom); + const ROW_HEIGHT = useMemo(rowHeight, []); // Read by the callbacks below, which stay the same across new traffic so rows don't re-render - const latest = useRef({ flows, following, newestStartedAt }); + const latest = useRef({ flows, following, newestStartedAt, heldAt, sort }); useLayoutEffect(() => { - latest.current = { flows, following, newestStartedAt }; + latest.current = { flows, following, newestStartedAt, heldAt, sort }; }); + // Where the platform cannot tell which app sent a flow, there is no App column. + const hasApps = useAtomValue(hasAppsAtom); + const columns = useMemo( + () => shownColumns(prefs, width).filter((c) => hasApps || c.id !== "app"), + [prefs, width, hasApps], + ); + const template = useMemo(() => gridTemplate(columns), [columns]); + const arrivals = useArrivals(flows, following && sort == null); + const virtualizer = useVirtualizer({ count: flows.length, getScrollElement: () => scrollRef.current, estimateSize: () => ROW_HEIGHT, getItemKey: (index) => flows[index]?.id ?? index, - paddingEnd: BOTTOM_PADDING, + paddingEnd: BOTTOM_PADDING + (paused && flows.length > 0 ? ROW_HEIGHT + 8 : 0), overscan: 40, }); @@ -92,30 +133,44 @@ export function FlowList({ sideBySide, style, className }: Props) { }, [setFollowing]); const follow = useCallback(() => { + const { flows, heldAt, following, sort } = latest.current; + // Catching up from a hold marks where it began. + if (!following && sort == null) { + const count = countNewFlows(flows, heldAt); + const first = flows[flows.length - count]; + setDivider(count > 0 && first != null ? { id: first.id, count } : null); + } + setSort(null); setFollowing(true); setHeldAt(null); setScrollTop(Number.MAX_SAFE_INTEGER); - }, [setFollowing, setScrollTop]); + }, [setFollowing, setScrollTop, setSort]); useLayoutEffect(() => { if (following) setScrollTop(Number.MAX_SAFE_INTEGER); - }, [following, flows, setScrollTop]); + }, [following, flows, paused, setScrollTop]); + + // A new sort starts from its top. + useLayoutEffect(() => { + if (sort != null) setScrollTop(0); + }, [sort, setScrollTop]); // Older flows that loaded in above push the rows down; scroll by as much, so they stay put. const anchor = useRef(null); useLayoutEffect(() => { const previous = anchor.current; anchor.current = oldestFlow; - if (following || previous == null || oldestFlow == null) return; + if (following || sort != null || previous == null || oldestFlow == null) return; if (compareFlows(oldestFlow, previous) >= 0) return; const added = flows.findIndex((f) => compareFlows(f, previous) >= 0); const el = scrollRef.current; if (added > 0 && el != null) setScrollTop(el.scrollTop + added * ROW_HEIGHT); - }, [oldestFlow, flows, following, setScrollTop]); + }, [oldestFlow, flows, following, sort, setScrollTop, ROW_HEIGHT]); + // Sorted, the flows loaded are what is sorted; older ones would land anywhere. const loadOlderHeld = useCallback(() => { hold(); - void loadOlder(); + if (latest.current.sort == null) void loadOlder(); }, [hold, loadOlder]); const handleScroll = useCallback(() => { @@ -126,14 +181,16 @@ export function FlowList({ sideBySide, style, className }: Props) { const el = scrollRef.current; if (el == null) return; const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 2; - if (atEnd) { + if (atEnd && latest.current.sort == null) { setFollowing(true); setHeldAt(null); } else { hold(); - if (el.scrollTop < LOAD_OLDER_ROWS * ROW_HEIGHT) void loadOlder(); + if (el.scrollTop < LOAD_OLDER_ROWS * ROW_HEIGHT && latest.current.sort == null) { + void loadOlder(); + } } - }, [hold, loadOlder, setFollowing]); + }, [hold, loadOlder, setFollowing, ROW_HEIGHT]); // A list too short to scroll still loads older flows on scrolling up. const handleWheel = useCallback( @@ -157,11 +214,17 @@ export function FlowList({ sideBySide, style, className }: Props) { setScrollTop(top + ROW_HEIGHT - el.clientHeight); } }, - [hold, setSelectedFlow, setScrollTop], + [hold, setSelectedFlow, setScrollTop, ROW_HEIGHT], ); const handleKeyDown = useCallback( (e: KeyboardEvent) => { + // ⌘↓ catches up, as the pill does. + if (e.metaKey && !e.shiftKey && !e.altKey && e.key === "ArrowDown") { + e.preventDefault(); + follow(); + return; + } if (e.metaKey || e.ctrlKey || e.altKey) return; if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; e.preventDefault(); @@ -170,7 +233,7 @@ export function FlowList({ sideBySide, style, className }: Props) { else if (index === 0 && e.key === "ArrowUp") loadOlderHeld(); else select(e.key === "ArrowDown" ? index + 1 : index - 1); }, - [flows, loadOlderHeld, select, selectedId], + [flows, follow, loadOlderHeld, select, selectedId], ); const openMenu = useCallback( @@ -184,39 +247,32 @@ export function FlowList({ sideBySide, style, className }: Props) { [select], ); - const newCount = following ? 0 : countNewFlows(flows, heldAt); + const handleSort = useCallback( + (column: ColumnId) => { + hold(); + setSort((prev) => nextSort(prev, column)); + }, + [hold, setSort], + ); - const columns = sideBySide - ? hasApps - ? "grid-cols-[0.9rem_1rem_2.6rem_minmax(0,2fr)_minmax(0,3fr)_2.4rem_3.6rem]" - : "grid-cols-[0.9rem_2.6rem_minmax(0,2fr)_minmax(0,3fr)_2.4rem_3.6rem]" - : hasApps - ? "grid-cols-[0.9rem_8.5rem_2.6rem_13rem_minmax(0,1fr)_2.4rem_5rem_4.5rem_3.8rem]" - : "grid-cols-[0.9rem_2.6rem_13rem_minmax(0,1fr)_2.4rem_5rem_4.5rem_3.8rem]"; + const newCount = following || sort != null ? 0 : countNewFlows(flows, heldAt); + const dividerIndex = divider == null ? -1 : flows.findIndex((f) => f.id === divider.id); + const totalSize = virtualizer.getTotalSize(); return (
- -
- - {hasApps && {sideBySide ? "" : "App"}} - Method - Host - Path - Status - {!sideBySide && Type} - {!sideBySide && Size} - Time -
+
{flows.length === 0 ? ( - + ) : ( -
+
{virtualizer.getVirtualItems().map((item) => { const flow = flows[item.index]; if (flow == null) return null; @@ -241,15 +297,28 @@ export function FlowList({ sideBySide, style, className }: Props) { flow={flow} index={item.index} start={item.start} + height={ROW_HEIGHT} columns={columns} - sideBySide={sideBySide} - showApp={hasApps} + template={template} selected={flow.id === selectedId} + arrivedAt={arrivals.get(flow.id) ?? null} onSelect={select} onContextMenu={openMenu} /> ); })} + {dividerIndex > 0 && divider != null && ( +
+ + {divider.count === 1 ? "1 new" : `${divider.count} new`} + +
+ )} + {paused && }
)}
@@ -270,7 +339,7 @@ export function FlowList({ sideBySide, style, className }: Props) { )} setMenu(null)} />
@@ -278,26 +347,53 @@ export function FlowList({ sideBySide, style, className }: Props) { ); } -/** Nothing to list, or nothing a search matches */ -function EmptyList() { - const query = useAtomValue(activeQueryAtom); - const search = useAtomValue(searchWindowAtom); - if (search?.loading) return null; - return ( -

- {query == null ? "No traffic yet" : "No flows match"} -

- ); +/** + * When each flow that arrived while following came in, for the tint that fades over + * `ARRIVAL_MS`. Only the newest end of the list is checked, back to the first flow already + * known, so a new flow costs the same however many are held. + */ +function useArrivals(flows: Flow[], following: boolean): Map { + const seen = useRef<{ known: Set | null; arrivals: Map }>({ + known: null, + arrivals: new Map(), + }); + // Worked out in the render the new flows cause, rather than in a second one after it. + return useMemo(() => { + const s = seen.current; + if (s.known == null || !following) { + s.known = new Set(flows.map((f) => f.id)); + return s.arrivals; + } + const now = Date.now(); + const arrived: string[] = []; + for (let i = flows.length - 1; i >= 0; i--) { + const flow = flows[i]; + if (flow == null || s.known.has(flow.id)) break; + s.known.add(flow.id); + arrived.push(flow.id); + } + // Kept to the flows held, which the window trims. + if (s.known.size > flows.length * 2) s.known = new Set(flows.map((f) => f.id)); + // A burst renews every row in view, so tinting it says nothing and costs a layer a row. + if (arrived.length === 0 || arrived.length > ARRIVAL_MAX_BATCH) return s.arrivals; + const next = new Map(); + for (const [id, at] of s.arrivals) if (now - at < ARRIVAL_MS) next.set(id, at); + for (const id of arrived) next.set(id, now); + s.arrivals = next; + return next; + }, [flows, following]); } interface RowProps { flow: Flow; index: number; start: number; - columns: string; - sideBySide: boolean; - showApp: boolean; + height: number; + columns: Column[]; + template: string; selected: boolean; + /** When it arrived while following, for its tint, or null */ + arrivedAt: number | null; onSelect: (index: number) => void; onContextMenu: (index: number, e: MouseEvent) => void; } @@ -306,18 +402,21 @@ const FlowRow = memo(function FlowRow({ flow, index, start, + height, columns, - sideBySide, - showApp, + template, selected, + arrivedAt, onSelect, onContextMenu, }: RowProps) { - const { host, path, query } = splitUrl(flow.url); const failed = flow.error != null; // Failed or cancelled before any response; after the headers, the body was cut short mid-way. const noResponse = (failed || flow.cancelled) && flow.status == null; - const type = shortContentType(flow.contentType); + const ids = columns.map((c) => c.id); + // A failure's reason, or Cancelled, takes the type and size cells together. + const spanReason = noResponse && ids.includes("type") && ids.includes("size"); + const sinceArrival = arrivedAt == null ? null : Date.now() - arrivedAt; return (
onContextMenu(index, e)} - style={{ height: ROW_HEIGHT, transform: `translateY(${start}px)` }} + style={{ + height, + transform: `translateY(${start}px)`, + gridTemplateColumns: template, + // Continues the fade where it was when a row scrolls back into view. + ...(sinceArrival != null && sinceArrival < ARRIVAL_MS + ? { animationDelay: `-${sinceArrival}ms` } + : {}), + }} 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", + "[&>*]:overflow-hidden [&>*]:text-ellipsis [&>*]:min-w-0 tabular-nums", + flow.cancelled && "text-text-subtlest", selected ? "text-text bg-surface-highlight group-focus-within/flows:bg-surface-active" - : index % 2 === 1 && "bg-surface-highlight/30", + : "hover:bg-surface-highlight/50", + sinceArrival != null && sinceArrival < ARRIVAL_MS && !selected && "flow-arrived", )} > - - {flow.savedRequestId != null ? ( - - ) : failed ? ( - - ) : flow.cancelled ? ( - - ) : flow.responseTruncated ? ( - - ) : null} - - {showApp && ( - - {flow.clientPath != null && } - {!sideBySide && ( - - {flow.clientName ?? --} - - )} - - )} - - {host} - - {path} - {query} - - {flow.cancelled && flow.status == null ? ( - -- - ) : ( - - )} - {!sideBySide && - (failed ? ( - - {flow.error} - - ) : flow.cancelled ? ( - Cancelled - ) : ( - - {type ?? --} - - ))} - {!sideBySide && ( - - {noResponse ? ( - -- - ) : ( - formatSize(flow.responseSize) - )} - - )} - - {flow.elapsedMs == null ? "--" : formatMillis(flow.elapsedMs)} - + + {columns.map((c) => { + if (spanReason && c.id === "size") return null; + return ( + + ); + })}
); }); -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: "" }; +/** + * One mark per row, by a fixed priority: failed, cancelled, cut, saved. Its tooltip lists every + * state the flow has. Streaming and agent reads join it once the engine records them. + */ +function FlowMark({ flow }: { flow: Flow }) { + const open = isOpenStream(flow); + const states: string[] = []; + if (flow.error != null) states.push(`Failed: ${flow.error}`); + if (flow.cancelled) states.push("Cancelled by the app"); + if (open) states.push("Streaming"); + if (flow.responseTruncated) states.push("Body cut at the capture limit"); + if (flow.savedRequestId != null) states.push("Saved to Yaak"); + const title = states.length === 0 ? undefined : states.join("\n"); + return ( + + {flow.error != null ? ( + + ) : flow.cancelled ? ( + + ) : open ? ( + + ) : flow.responseTruncated ? ( + + ) : flow.savedRequestId != null ? ( + + ) : null} + + ); +} + +const mono = "font-mono text-xs"; + +/** A WebSocket, event stream or gRPC call still open: written as it opens, done once it ends */ +function isOpenStream(flow: Flow): boolean { + return flow.streamKind != null && flow.elapsedMs == null && flow.error == null && !flow.cancelled; +} + +function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span: boolean }) { + const failed = flow.error != null; + const noResponse = (failed || flow.cancelled) && flow.status == null; + switch (column) { + case "number": + return ( + + {flow.number || ""} + + ); + case "app": { + if (flow.clientPath == null) return ; + const folder = folderOf(flow); + const name = flow.clientName ?? flow.clientPath; + return ( + + + + ); + } + case "method": + return ( + + ); + case "host": + // Cut from the front, so the part that names the service stays. + return ( + + {hostOf(flow.url)} + + ); + case "path": { + const { path, query } = splitPath(flow.url); + if (flow.streamKind == null) { + return ( + + {path} + {query} + + ); + } + // A stream's message count rides the end of the path, so it survives the narrow width, + // ticking while the stream is open. + return ( + + + {path} + {query} + + + + ); + } + case "status": + return flow.cancelled && flow.status == null ? ( + -- + ) : ( + + ); + case "type": + return failed && noResponse ? ( + + {flow.error} + + ) : flow.cancelled && noResponse ? ( + Cancelled + ) : ( + + {shortContentType(flow.contentType) ?? "--"} + + ); + case "size": + return ( + + {noResponse ? "--" : formatSize(flow.responseSize)} + + ); + case "time": + // An open stream shows how long it has been open, quietly, as its messages land. + if (isOpenStream(flow)) { + const open = Date.now() - Date.parse(utcTimestamp(flow.startedAt)); + return ( + + {Number.isFinite(open) ? formatMillis(Math.max(0, open)) : "--"} + + ); + } + return ( + = 1000 && !flow.cancelled + ? "text-text" + : "text-text-subtle", + )} + > + {flow.elapsedMs == null ? "--" : formatMillis(flow.elapsedMs)} + + ); + case "remote": + return ( + {flow.remoteAddr ?? "--"} + ); + case "version": + return ( + + {flow.upstreamHttpVersion ?? flow.clientHttpVersion} + + ); + } +} + +function splitPath(url: string): { path: string; query: string } { + try { + const u = new URL(url); + return { path: u.pathname, query: u.search }; + } catch { + return { path: url, query: "" }; } } diff --git a/apps/yaak-proxy/components/FlowListEmpty.tsx b/apps/yaak-proxy/components/FlowListEmpty.tsx new file mode 100644 index 000000000..5c3b28a8e --- /dev/null +++ b/apps/yaak-proxy/components/FlowListEmpty.tsx @@ -0,0 +1,120 @@ +import { EmptyStateText, Hotkey, Icon, InlineCode } from "@yaakapp-internal/ui"; +import classNames from "classnames"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useEffect, useState } from "react"; +import { rpc } from "../lib/rpc"; +import { parseSearch, widerQueries } from "../lib/search"; +import { + hideSystemAtom, + pausedAtAtom, + proxyStateAtom, + searchTermsAtom, + searchWindowAtom, + setSearchTextAtom, +} from "../lib/store"; + +/** Nothing to list: no flows yet, recording paused, or a query nothing matches */ +export function FlowListEmpty() { + const text = useAtomValue(searchTermsAtom); + const search = useAtomValue(searchWindowAtom); + const proxyState = useAtomValue(proxyStateAtom); + if (search?.loading) return null; + if (text.trim() !== "") return ; + + const other = proxyState?.otherOwner ?? null; + const port = proxyState?.port ?? other?.proxyPort ?? null; + const running = proxyState?.state === "running" || other != null; + const recording = proxyState?.recording !== false; + return ( + +
{recording ? "No flows yet" : "Recording is paused"}
+
+ {!running + ? "The proxy is not running. Retry from the menu by the state." + : !recording + ? "Traffic passes through without being stored." + : `Recording on port ${port ?? "--"}. Flows appear as apps send requests.`} +
+
+ ); +} + +/** + * The list's last row while recording is paused, where the next flow would appear: since when, + * and that nothing is stored. + */ +export function PausedRow({ style }: { style?: React.CSSProperties }) { + const pausedAt = useAtomValue(pausedAtAtom); + return ( +
+ + + {pausedAt == null ? "Paused." : `Paused at ${timeOf(pausedAt)}.`} Traffic passes through + without being stored. + + +
+ ); +} + +/** + * A query nothing matches: wider ones that do, each the query with one term left out, as Yaak's + * sidebar answers, with how many each would match. + */ +function NoResults({ text }: { text: string }) { + const hideSystem = useAtomValue(hideSystemAtom); + const setText = useSetAtom(setSearchTextAtom); + const [wider, setWider] = useState<{ text: string; count: number }[]>([]); + + useEffect(() => { + let current = true; + const candidates = widerQueries(text); + void Promise.all( + candidates.map(async (candidate) => { + const query = parseSearch(candidate, hideSystem); + const res = await rpc("list_flows", { ...query, limit: 0 }); + return { text: candidate, count: query == null ? res.total : (res.matching ?? res.total) }; + }), + ) + .then((found) => { + if (current) setWider(found.filter((f) => f.count > 0).slice(0, 3)); + }) + .catch(() => { + if (current) setWider([]); + }); + return () => { + current = false; + }; + }, [text, hideSystem]); + + return ( + +
+ No flows match. + {wider.length > 0 && " Found matches for "} + {wider.map((w, i) => ( + + {i > 0 && (i === wider.length - 1 ? ", or " : ", ")} + {" "} + ({w.count.toLocaleString()}) + + ))} +
+
+ ); +} + +function timeOf(ms: number): string { + return new Date(ms).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); +} diff --git a/apps/yaak-proxy/components/FlowListHeader.tsx b/apps/yaak-proxy/components/FlowListHeader.tsx new file mode 100644 index 000000000..b65c56fd0 --- /dev/null +++ b/apps/yaak-proxy/components/FlowListHeader.tsx @@ -0,0 +1,151 @@ +import { + ContextMenu, + Dropdown, + type DropdownItem, + Icon, + IconButton, + ResizeHandle, +} from "@yaakapp-internal/ui"; +import classNames from "classnames"; +import { useSetAtom } from "jotai"; +import { memo, type MouseEvent, useCallback, useRef, useState } from "react"; +import { + type Column, + COLUMNS, + type ColumnId, + type ColumnPrefs, + type FlowSort, + resetWidths, + resizeColumn, + toggleColumn, +} from "../lib/columns"; +import { useExportShownHar } from "../lib/flowActions"; +import { columnPrefsAtom } from "../lib/store"; + +interface Props { + columns: Column[]; + template: string; + prefs: ColumnPrefs; + sort: FlowSort | null; + onSort: (column: ColumnId) => void; + onSortByStart: () => void; +} + +/** + * The list's only control surface: a click on a label sorts by it and a second reverses it, + * dragging the edge after a label resizes its column, and the picker sits in the empty corner on + * hover, or anywhere on a right-click. + */ +export const FlowListHeader = memo(function FlowListHeader({ + columns, + template, + prefs, + sort, + onSort, + onSortByStart, +}: Props) { + const setPrefs = useSetAtom(columnPrefsAtom); + const exportShown = useExportShownHar(); + const [menu, setMenu] = useState<{ x: number; y: number } | null>(null); + // Widths when a drag began, so each move sets the width from where it started. + const startWidth = useRef(0); + + const items: DropdownItem[] = [ + { + type: "content", + label:
Columns
, + }, + ...COLUMNS.map((c): DropdownItem => ({ + label: c.label, + keepOpenOnSelect: true, + disabled: c.id === "path", + leftSlot: + (prefs.visible[c.id] ?? c.defaultVisible) || c.id === "path" ? ( + + ) : ( + + ), + rightSlot: + c.id === "path" ? always : undefined, + onSelect: () => setPrefs((p) => toggleColumn(p, c.id)), + })), + { type: "separator" }, + { label: "Sort by Start Time", disabled: sort == null, onSelect: onSortByStart }, + { label: "Reset Column Widths", onSelect: () => setPrefs((p) => resetWidths(p)) }, + { type: "separator" }, + exportShown(), + ]; + + const openMenu = useCallback((e: MouseEvent) => { + e.preventDefault(); + setMenu({ x: e.clientX, y: e.clientY }); + }, []); + + return ( + // oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the same menu as the picker +
+ + + + + + {columns.map((c) => ( + + + {c.id !== "path" && ( + { + startWidth.current = c.current; + }} + onResizeMove={(e) => + setPrefs((p) => resizeColumn(p, c.id, startWidth.current + e.x - e.xStart)) + } + onReset={() => + setPrefs((p) => ({ ...p, widths: { ...p.widths, [c.id]: undefined } })) + } + /> + )} + + ))} + setMenu(null)} /> +
+ ); +}); diff --git a/apps/yaak-proxy/components/ProxyLayout.tsx b/apps/yaak-proxy/components/ProxyLayout.tsx index 46e3017e9..fb90f28c5 100644 --- a/apps/yaak-proxy/components/ProxyLayout.tsx +++ b/apps/yaak-proxy/components/ProxyLayout.tsx @@ -7,26 +7,28 @@ import { Toasts, } from "@yaakapp-internal/ui"; import classNames from "classnames"; -import { useAtom, useAtomValue } from "jotai"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useState } from "react"; import { useLocalStorage } from "react-use"; -import { flowLayoutAtom } from "../lib/atoms"; -import { searchWindowAtom, showFirstRunAtom, shownTotalAtom } from "../lib/store"; +import { flowLayoutAtom, settingsRequestedAtom } from "../lib/atoms"; +import { setupIncomplete } from "../lib/firstRun"; +import { noFlowsAtom, systemProxyEverOnAtom } from "../lib/store"; import { getOsType } from "../lib/tauri"; import { ClearFlowsButton } from "./ClearFlowsButton"; import { FirstRun } from "./FirstRun"; import { FlowDetail } from "./FlowDetail"; import { FlowList } from "./FlowList"; -import { ProxyStatus } from "./ProxyStatus"; +import { ProxyStatus, useProxySetup } from "./ProxyStatus"; import { HelperDialog } from "./HelperDialog"; import { SaveDialog } from "./SaveDialog"; -import { selectedSidebarLabelAtom, Sidebar } from "./Sidebar"; +import { SearchBar } from "./SearchBar"; +import { Sidebar } from "./Sidebar"; import { TakeOverBanner } from "./TakeOverBanner"; export function ProxyLayout() { const os = getOsType(); - const title = useAtomValue(selectedSidebarLabelAtom); const [flowLayout, setFlowLayout] = useAtom(flowLayoutAtom); + const requestSettings = useSetAtom(settingsRequestedAtom); const [sidebarWidth, setSidebarWidth] = useLocalStorage("sidebar_width", 250); const [sidebarHidden, setSidebarHidden] = useLocalStorage("sidebar_hidden", false); const [floatingSidebarHidden, setFloatingSidebarHidden] = useLocalStorage( @@ -52,8 +54,10 @@ export function ProxyLayout() { interfaceScale={1} className="x-theme-appHeader bg-surface" > -
-
+ {/* Yaak's silhouette: the sidebar toggle and the state pill where Yaak has the toggle and + the environment, the search where it names the request, then its icons. */} +
+
-
-
- {title} - -
-
+
+
+ +
+
(prev === "horizontal" ? "vertical" : "horizontal")) } /> + requestSettings((n) => n + 1)} + />
@@ -146,29 +154,28 @@ export function ProxyLayout() { ); } -/** Its own component, so new traffic re-renders the count rather than the whole layout */ -function FlowCount() { - const count = useAtomValue(shownTotalAtom); - const searching = useAtomValue(searchWindowAtom) != null; - const noun = searching ? (count === 1 ? "match" : "matches") : count === 1 ? "flow" : "flows"; - return {`${count} ${noun}`}; -} - /** - * The flow list above the detail pane, or, while the database has no flows at all, the first-run - * card across the whole area: it is the empty state until traffic replaces it. + * The flow list beside or above the detail pane, or, while setup is incomplete and there are no + * flows, the first-run card across the whole area. Once setup is done an empty list says so + * itself, after a Clear too. */ function FlowsOrFirstRun({ flowLayout }: { flowLayout: SplitLayoutLayout }) { - if (useAtomValue(showFirstRunAtom)) return ; + const noFlows = useAtomValue(noFlowsAtom); + const { caStatus, systemProxy } = useProxySetup(); + const systemProxyEverOn = useAtomValue(systemProxyEverOnAtom); + const incomplete = setupIncomplete({ + trusted: caStatus.data?.trusted ?? null, + systemProxySupported: systemProxy.data?.supported ?? null, + systemProxyEverOn, + }); + if (noFlows && incomplete) return ; return ( ( - - )} + firstSlot={({ style }) => } secondSlot={({ style, orientation }) => ( )} diff --git a/apps/yaak-proxy/components/ProxyStatus.tsx b/apps/yaak-proxy/components/ProxyStatus.tsx index 48a9779f5..ae04c19e9 100644 --- a/apps/yaak-proxy/components/ProxyStatus.tsx +++ b/apps/yaak-proxy/components/ProxyStatus.tsx @@ -9,7 +9,7 @@ import { Icon, } from "@yaakapp-internal/ui"; import classNames from "classnames"; -import { useAtomValue, useSetAtom, useStore } from "jotai"; +import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; import type { HTMLAttributes, ReactNode, Ref } from "react"; import { useEffect, useRef } from "react"; import { useRpcEvent } from "../hooks/useRpcEvent"; @@ -17,7 +17,7 @@ import { useRpcMutation } from "../hooks/useRpcMutation"; import { useRpcQuery } from "../hooks/useRpcQuery"; import { helperDialogOpenAtom, helperRegisteredAtAtom } from "../lib/atoms"; import { environmentExports } from "../lib/env"; -import { proxyStateAtom, refreshProxyState } from "../lib/store"; +import { proxyStateAtom, refreshProxyState, systemProxyEverOnAtom } from "../lib/store"; import { helperPollInterval, helperRow, @@ -28,9 +28,10 @@ import { onWindowFocus } from "../lib/tauri"; import { useShowToast } from "../lib/toast"; /** - * The header's state pill and its menu: recording, the system proxy, the certificate and HTTP/2. The - * engine runs for as long as the app does, so the pill shows whether flows are being stored. A - * certificate that is not trusted steps out of the menu beside the pill until it is. + * The header's state pill and its menu of four rows: the state, Pause or Resume Recording, + * Override system proxy, and the certificate. The engine runs for as long as the app does, so the + * pill shows whether flows are being stored. A certificate that is not trusted steps out of the + * menu beside the pill until it is. More… holds the rows that move to Settings until it exists. */ export function ProxyStatus() { const dropdownRef = useRef(null); @@ -64,8 +65,8 @@ export function ProxyStatus() { title={recording ? `Recording on port ${port}` : `Paused on port ${port}`} description={ recording - ? "Listening on 127.0.0.1, and storing every flow." - : "Traffic passes through untouched, and no flows are stored." + ? "Every flow is stored as it passes." + : "Traffic passes through untouched, and nothing is stored." } /> ) : ( @@ -93,45 +94,22 @@ export function ProxyStatus() { /> ); - const items: DropdownItem[] = [ - { type: "content", label: stateRow }, - { type: "separator", hidden: systemProxy.data?.supported !== true }, + // Until the helper is approved, every change asks for the password; said where it is felt. + const helperNotice = + systemProxy.data != null && + systemProxy.data.backend !== "helper" && + systemProxy.data.helper !== "enabled" && + systemProxy.data.helper !== "unavailable"; + + // The rows that move to Settings, reachable here until it exists. + const moreItems: DropdownItem[] = [ { type: "content", hidden: systemProxy.data?.supported !== true, label: ( - } - title="Override system proxy" - description={ - - {systemSwitch.description} - - } - action={ - - setSystemProxy.mutate({ enabled })} - /> - - } - /> - ), - }, - { - type: "content", - hidden: systemProxy.data?.supported !== true, - label: ( - + Override system proxy at launch - } + title={Override system proxy at launch} checked={proxyState?.systemProxyAtLaunch ?? true} disabled={setAtLaunch.isPending} onChange={(enabled) => setAtLaunch.mutate({ enabled })} @@ -152,6 +130,83 @@ export function ProxyStatus() { /> ), }, + { + type: "content", + label: ( + } + title="HTTP/2" + description="Lets browsers send many requests at once. Turn off for apps that misbehave on it. Applies to new connections." + action={ + + + setHttpVersion.mutate({ httpVersion: enabled ? "auto" : "http1Only" }) + } + /> + + } + /> + ), + }, + { type: "separator" }, + { + label: "Copy Environment for a Terminal", + icon: "square_terminal", + onSelect: copyEnvironment, + }, + ]; + + const items: DropdownItem[] = [ + { type: "content", label: stateRow }, + { + label: recordingLabel(recording), + icon: recording ? "circle_pause" : "circle_play", + hidden: !running, + hotKeyAction: "recording_toggle", + hotKeyLabelOnly: true, + onSelect: () => execute.mutate({ scope: "global", action: "recording_toggle" }), + }, + { type: "separator" }, + { + type: "content", + hidden: systemProxy.data?.supported !== true, + label: ( + } + title="Override system proxy" + description={ + <> + + {systemSwitch.description} + + {helperNotice && ( + + macOS asks for your password each time until the helper is approved, under More. + + )} + + } + action={ + + setSystemProxy.mutate({ enabled })} + /> + + } + /> + ), + }, { type: "content", label: caStatus.data?.trusted ? ( @@ -190,54 +245,12 @@ export function ProxyStatus() { /> ), }, - { - type: "content", - label: ( - } - title="HTTP/2" - description="Lets browsers send many requests at once. Turn off for apps that misbehave on it. Applies to new connections." - action={ - - - setHttpVersion.mutate({ httpVersion: enabled ? "auto" : "http1Only" }) - } - /> - - } - /> - ), - }, { type: "separator" }, - { - label: "Copy Environment for a Terminal", - icon: "square_terminal", - onSelect: copyEnvironment, - }, - { - label: recordingLabel(recording), - icon: recording ? "circle_pause" : "circle_play", - hidden: !running, - hotKeyAction: "recording_toggle", - hotKeyLabelOnly: true, - onSelect: () => execute.mutate({ scope: "global", action: "recording_toggle" }), - }, + { label: "More…", icon: "settings", submenu: moreItems }, ]; return ( <> - {untrusted && ( - dropdownRef.current?.open()}> - - Certificate not trusted - - )} {other != null ? ( @@ -263,6 +276,12 @@ export function ProxyStatus() { + {untrusted && ( + dropdownRef.current?.open()}> + + Certificate not trusted + + )} ); } @@ -300,6 +319,12 @@ export function useProxySetup() { // The engine turns the system proxy on as it starts, and says so once the prompt is // answered, and turns it off as it stops. useRpcEvent("proxy_state_changed", () => void systemProxy.refetch()); + // Setup counts as done once the system proxy has been on, whatever it is now. + const [everOn, setEverOn] = useAtom(systemProxyEverOnAtom); + const on = systemProxy.data?.enabled === true; + useEffect(() => { + if (on && !everOn) setEverOn(true); + }, [on, everOn, setEverOn]); return { caStatus, trust, systemProxy, setSystemProxy }; } diff --git a/apps/yaak-proxy/components/SearchBar.tsx b/apps/yaak-proxy/components/SearchBar.tsx index ed9d107f3..d376558b9 100644 --- a/apps/yaak-proxy/components/SearchBar.tsx +++ b/apps/yaak-proxy/components/SearchBar.tsx @@ -1,30 +1,36 @@ import { Icon, IconButton, PlainInput } from "@yaakapp-internal/ui"; -import { useAtomValue, useSetAtom } from "jotai"; -import { memo, useEffect, useRef, useState } from "react"; -import { exportHar } from "../lib/flowActions"; -import { searchTerms } from "../lib/search"; -import { activeQueryAtom, searchTermsAtom } from "../lib/store"; -import { useShowToast } from "../lib/toast"; +import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; +import { memo, type RefObject, useEffect, useRef } from "react"; +import { + allTrafficTotalAtom, + searchTermsAtom, + searchTextAtom, + searchTextVersionAtom, + searchWindowAtom, + setSearchTextAtom, + shownTotalAtom, +} from "../lib/store"; /** How long typing settles before the search runs */ const SEARCH_DEBOUNCE_MS = 200; /** - * Searches every flow in the database by URL, method, status and content type, within the - * sidebar's selection. ⌘F focuses it and Escape clears it. + * The header's search over every flow in the database, by URL, method, status and content type, + * and the `@app`, `@host` and `@path` terms the sidebar writes. Its placeholder counts the flows; + * a search's matches show inside it. ⌘F focuses it and Escape clears it. */ export const SearchBar = memo(function SearchBar() { - const [text, setText] = useState(""); + const [text, setText] = useAtom(searchTextAtom); const setTerms = useSetAtom(searchTermsAtom); - const query = useAtomValue(activeQueryAtom); - const showToast = useShowToast(); + const setSearchText = useSetAtom(setSearchTextAtom); + const version = useAtomValue(searchTextVersionAtom); + // Read, not subscribed: `PlaceholderCount` keeps it current without re-rendering the field. + const total = useStore().get(allTrafficTotalAtom); // PlainInput hands back its as the ref const inputRef = useRef(null); - // Bumped to empty the field, which only takes a value from outside when this changes - const [resetKey, setResetKey] = useState(0); useEffect(() => { - const timer = setTimeout(() => setTerms(searchTerms(text)), SEARCH_DEBOUNCE_MS); + const timer = setTimeout(() => setTerms(text), SEARCH_DEBOUNCE_MS); return () => clearTimeout(timer); }, [text, setTerms]); @@ -40,72 +46,84 @@ export const SearchBar = memo(function SearchBar() { return () => window.removeEventListener("keydown", onKeyDown); }, []); - // The field is replaced to empty it, so focus moves to the new one. + // The field is replaced to take a value from outside, so focus moves to the new one. const clear = () => { - setText(""); - setTerms(null); - setResetKey((k) => k + 1); + setSearchText(""); requestAnimationFrame(() => inputRef.current?.focus()); }; return ( -
-
- { - if (e.key === "Escape" && text !== "") { - e.stopPropagation(); - clear(); - } - }} - leftSlot={ - - } - rightSlot={ - text === "" ? ( - - ⌘F - - ) : ( - - ) - } - /> -
- { - try { - const written = await exportHar(query == null ? {} : { filter: query }, "flows.har"); - if (written != null) { - showToast({ - message: - written === 1 ? "Exported 1 flow as HAR" : `Exported ${written} flows as HAR`, - }); - } - } catch (err) { - showToast({ message: `Failed to export HAR: ${String(err)}`, color: "danger" }); - } - }} - /> -
+ { + if (e.key === "Escape" && text !== "") { + e.stopPropagation(); + clear(); + } + }} + leftSlot={ + <> + + + + } + rightSlot={ + text === "" ? ( + + ⌘F + + ) : ( +
+ + +
+ ) + } + /> ); }); + +/** + * Keeps the placeholder's count current as flows arrive, alone, so new traffic re-renders this + * and not the whole field + */ +function PlaceholderCount({ inputRef }: { inputRef: RefObject }) { + const total = useAtomValue(allTrafficTotalAtom); + useEffect(() => { + if (inputRef.current != null) inputRef.current.placeholder = placeholderFor(total); + }, [inputRef, total]); + return null; +} + +/** "Search 4,102 flows", the title and count that sat in the header's middle */ +function placeholderFor(total: number): string { + if (total === 0) return "Search flows"; + return `Search ${total.toLocaleString()} ${total === 1 ? "flow" : "flows"}`; +} + +/** A search's matches of all traffic, "12 of 4,102", inside the field before its x */ +function MatchCount() { + const matches = useAtomValue(shownTotalAtom); + const total = useAtomValue(allTrafficTotalAtom); + const search = useAtomValue(searchWindowAtom); + if (search == null || search.loading) return null; + return ( + + {matches.toLocaleString()} of {total.toLocaleString()} + + ); +} diff --git a/apps/yaak-proxy/components/Sidebar.tsx b/apps/yaak-proxy/components/Sidebar.tsx index 91c3ff932..a7d1b1d18 100644 --- a/apps/yaak-proxy/components/Sidebar.tsx +++ b/apps/yaak-proxy/components/Sidebar.tsx @@ -1,31 +1,15 @@ -import type { Flow } from "@yaakapp-internal/proxy-lib"; -import type { TreeNode } from "@yaakapp-internal/ui"; -import { selectedIdsFamily, Tree } from "@yaakapp-internal/ui"; -import { atom, useAtomValue } from "jotai"; +import { Icon, IconButton, Tree } from "@yaakapp-internal/ui"; +import classNames from "classnames"; +import { atom, useAtom, useAtomValue } from "jotai"; import { atomWithStorage } from "jotai/utils"; import { useCallback } from "react"; import { SIDEBAR_TREE_ID } from "../lib/atoms"; -import { appKeyOf, appNodeId, sidebarPath } from "../lib/search"; import { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse"; -import { flowsAtom, flowTotalAtom } from "../lib/store"; +import { type SidebarItem, SYSTEM_ID } from "../lib/sidebarTree"; +import { hideSystemAtom, noteSidebarInput, sidebarTreeAtom } from "../lib/store"; import { AppIcon } from "./AppIcon"; -/** A node in the sidebar tree: a domain, a path segment, or an app. */ -export type SidebarItem = { - id: string; - label: string; - /** Flows held under this node; for the Domains folder, every flow in the database */ - count: number; - /** An app's path, for its icon */ - appPath?: string; -}; - -const sidebarTreeAtom = atom>((get) => { - const exchanges = get(flowsAtom); - return buildTree(exchanges, get(flowTotalAtom)); -}); - -/** The domains and paths the user expanded, by node id, kept across launches */ +/** The apps, sites, hosts and paths the user expanded, by node id, kept across launches */ const expandedAtom = atomWithStorage>("sidebar_expanded", {}, undefined, { getOnInit: true, }); @@ -33,7 +17,7 @@ const expandedAtom = atomWithStorage>("sidebar_expanded" /** The other nodes' collapsed flags, for this launch, as before */ const otherCollapsedAtom = atom({}); -/** Domains and paths collapsed until the user expands them, for the tree */ +/** Every app's tree, site, host and path, and System, collapsed until the user expands them */ const collapsedAtom = atom( (get) => collapsedMap(folderIds(get(sidebarTreeAtom)), get(expandedAtom), get(otherCollapsedAtom)), @@ -46,196 +30,90 @@ const collapsedAtom = atom( }, ); -/** 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); - if (selectedId?.startsWith("app:")) { - const apps = get(sidebarTreeAtom).children?.find((c) => c.item.id === "apps"); - return apps?.children?.find((c) => c.item.id === selectedId)?.item.label ?? "All traffic"; - } - return "All traffic"; -}); - -const nodeIdsByFlow = new WeakMap(); - -/** - * The ids of the nodes a flow sits under, its domain first and then each path segment's, parsed - * once per flow rather than on every change to the list. - */ -function nodeIdsOf(ex: Flow): string[] { - let ids = nodeIdsByFlow.get(ex); - if (ids != null) return ids; - // By the rules the database filters by, so a node shows the flows it counts. - const path = sidebarPath(ex.url); - const schemeless = !ex.url.includes("://"); - const [hostname = "", ...segments] = schemeless ? [path] : path.split("/"); - ids = [`domain:${hostname}`]; - let node = `path:${hostname}`; - for (const seg of segments) { - node += `/${seg}`; - ids.push(node); - } - nodeIdsByFlow.set(ex, ids); - return ids; -} - -const collator = new Intl.Collator(); - -/** - * Build a domain → path-segment trie from a flat list of exchanges. - * - * Example: Given URLs - * GET https://api.example.com/v1/users - * GET https://api.example.com/v1/users/123 - * POST https://api.example.com/v1/orders - * - * Produces: - * api.example.com - * /v1 - * /users - * /123 - * /orders - */ -function buildTree(exchanges: Flow[], total: number): TreeNode { - const root: SidebarItem = { id: "root", label: "All Traffic", count: 0 }; - const rootNode: TreeNode = { - item: root, - parent: null, - depth: 0, - children: [], - draggable: false, - }; - - // Intermediate trie structure for building - type TrieNode = { - id: string; - label: string; - count: number; - children: Map; - }; - - const domainMap = new Map(); - - for (const ex of exchanges) { - const ids = nodeIdsOf(ex); - let current: TrieNode | undefined; - for (const id of ids) { - const siblings = current == null ? domainMap : current.children; - let node = siblings.get(id); - if (node == null) { - // The domain, or the path's last segment - const label = current == null ? id.slice("domain:".length) : id.slice(id.lastIndexOf("/")); - node = { id, label, count: 0, children: new Map() }; - siblings.set(id, node); - } - node.count++; - current = node; - } - } - - // Convert trie to TreeNode structure - function toTreeNode( - trie: TrieNode, - parent: TreeNode, - depth: number, - ): TreeNode { - const node: TreeNode = { - item: { - id: trie.id, - label: trie.label, - count: trie.count, - }, - parent, - depth, - children: [], - draggable: false, - }; - const sortedChildren = [...trie.children.values()].sort((a, b) => - collator.compare(a.label, b.label), - ); - for (const child of sortedChildren) { - node.children?.push(toTreeNode(child, node, depth + 1)); - } - return node; - } - - // Add a "Domains" folder between root and domain nodes - const domainsFolder: TreeNode = { - item: { id: "domains", label: "Domains", count: total }, - parent: rootNode, - depth: 1, - children: [], - draggable: false, - }; - - const sortedDomains = [...domainMap.values()].sort((a, b) => collator.compare(a.label, b.label)); - for (const domain of sortedDomains) { - domainsFolder.children?.push(toTreeNode(domain, domainsFolder, 2)); - } - - // The apps flows came from, where the platform can tell; none on others, so no group. - const apps = new Map(); - for (const ex of exchanges) { - const key = appKeyOf(ex); - if (key == null) continue; - const id = appNodeId(key); - const app = apps.get(id); - if (app == null) { - apps.set(id, { id, label: ex.clientName ?? key, count: 1, appPath: ex.clientPath ?? key }); - } else { - app.count++; - } - } - if (apps.size > 0) { - const appsFolder: TreeNode = { - item: { id: "apps", label: "Apps", count: 0 }, - parent: rootNode, - depth: 1, - children: [], - draggable: false, - }; - const sortedApps = [...apps.values()].sort((a, b) => collator.compare(a.label, b.label)); - for (const item of sortedApps) { - appsFolder.children?.push({ item, parent: appsFolder, depth: 2, draggable: false }); - } - rootNode.children?.push(appsFolder); - } - - rootNode.children?.push(domainsFolder); - - return rootNode; +function Count({ item }: { item: SidebarItem }) { + const text = item.countLabel ?? (item.count > 0 ? item.count.toLocaleString() : null); + if (text == null && item.kind !== "all") return null; + return ( + + {text ?? "0"} + + ); } function ItemInner({ item }: { item: SidebarItem }) { - const count = item.count; + if (item.kind === "group") { + return ( +
+ {item.label} + +
+ ); + } return (
+ {item.kind === "all" && } {item.appPath != null && } {item.label} - {count > 0 && {count}} + {item.sublabel != null && ( + {item.sublabel} + )} +
); } +/** Beside the row's button rather than in it, since it is a button of its own */ +function ItemRightSlot({ item }: { item: SidebarItem }) { + return item.id === SYSTEM_ID ? : ; +} + +/** The eye on System's row, which leaves its daemons out of All traffic */ +function HideSystemToggle() { + const [hidden, setHidden] = useAtom(hideSystemAtom); + return ( + { + e.stopPropagation(); + setHidden(!hidden); + }} + /> + ); +} + export function Sidebar() { const tree = useAtomValue(sidebarTreeAtom); - - const getItemKey = useCallback((item: SidebarItem) => `${item.id}:${item.count}`, []); - const treeId = SIDEBAR_TREE_ID; + const getItemKey = useCallback( + (item: SidebarItem) => `${item.id}:${item.count}:${item.countLabel ?? ""}`, + [], + ); return ( // The tree scrolls itself, in a row of bounded height, so its virtualizer mounts only the rows - // in view rather than every domain and path. -