import type { HttpExchange } from "@yaakapp-internal/proxy-lib"; import { Tree } from "@yaakapp-internal/ui"; import type { TreeNode } from "@yaakapp-internal/ui"; import { atom, useAtomValue } from "jotai"; import { atomFamily } from "jotai/utils"; import { useCallback, useMemo } from "react"; import { httpExchangesAtom } from "./store"; /** A node in the sidebar tree — either a domain or a path segment. */ export type SidebarItem = { id: string; label: string; exchangeIds: string[]; }; const collapsedAtom = atomFamily((treeId: string) => atom>({}), ); const sidebarTreeAtom = atom>((get) => { const exchanges = get(httpExchangesAtom); return buildTree(exchanges); }); /** * 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: HttpExchange[]): TreeNode { const root: SidebarItem = { id: "root", label: "All Traffic", exchangeIds: [] }; const rootNode: TreeNode = { item: root, parent: null, depth: 0, children: [], }; // Intermediate trie structure for building type TrieNode = { id: string; label: string; exchangeIds: string[]; children: Map; }; const domainMap = new Map(); for (const ex of exchanges) { let hostname: string; let segments: string[]; try { const url = new URL(ex.url); hostname = url.host; segments = url.pathname.split("/").filter(Boolean); } catch { hostname = ex.url; segments = []; } // Get or create domain node let domainNode = domainMap.get(hostname); if (!domainNode) { domainNode = { id: `domain:${hostname}`, label: hostname, exchangeIds: [], children: new Map(), }; domainMap.set(hostname, domainNode); } domainNode.exchangeIds.push(ex.id); // Walk path segments let current = domainNode; const pathSoFar: string[] = []; for (const seg of segments) { pathSoFar.push(seg); let child = current.children.get(seg); if (!child) { child = { id: `path:${hostname}/${pathSoFar.join("/")}`, label: `/${seg}`, exchangeIds: [], children: new Map(), }; current.children.set(seg, child); } child.exchangeIds.push(ex.id); current = child; } } // Convert trie to TreeNode structure function toTreeNode( trie: TrieNode, parent: TreeNode, depth: number, ): TreeNode { const node: TreeNode = { item: { id: trie.id, label: trie.label, exchangeIds: trie.exchangeIds, }, parent, depth, children: [], }; for (const child of trie.children.values()) { node.children!.push(toTreeNode(child, node, depth + 1)); } return node; } // Sort domains alphabetically, add to root const sortedDomains = [...domainMap.values()].sort((a, b) => a.label.localeCompare(b.label), ); for (const domain of sortedDomains) { rootNode.children!.push(toTreeNode(domain, rootNode, 1)); } return rootNode; } function ItemInner({ item }: { item: SidebarItem }) { const count = item.exchangeIds.length; return (
{item.label} {count > 0 && ( {count} )}
); } export function Sidebar() { const tree = useAtomValue(sidebarTreeAtom); const treeId = "proxy-sidebar"; const getItemKey = useCallback((item: SidebarItem) => item.id, []); return ( ); }