import type { HttpExchange } 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 { atomFamily } from "jotai/utils"; import { useCallback } from "react"; import { httpExchangesAtom } from "../lib/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>({})); export const SIDEBAR_TREE_ID = "proxy-sidebar"; const sidebarTreeAtom = atom>((get) => { const exchanges = get(httpExchangesAtom); return buildTree(exchanges); }); /** Exchanges filtered by the currently selected sidebar node(s). */ export const filteredExchangesAtom = atom((get) => { const exchanges = get(httpExchangesAtom); const tree = get(sidebarTreeAtom); const selectedIds = get(selectedIdsFamily(SIDEBAR_TREE_ID)); // Nothing selected or root selected → show all if (selectedIds.length === 0 || selectedIds.includes("root")) { return exchanges; } // Collect exchange IDs from all selected nodes const allowedIds = new Set(); const nodeMap = new Map(); collectNodes(tree, nodeMap); for (const selectedId of selectedIds) { const node = nodeMap.get(selectedId); if (node) { for (const id of node.exchangeIds) { allowedIds.add(id); } } } return exchanges.filter((ex) => allowedIds.has(ex.id)); }); function collectNodes(node: TreeNode, map: Map) { map.set(node.item.id, node.item); for (const child of node.children ?? []) { collectNodes(child, map); } } /** * 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: [], draggable: false, }; // 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: [], draggable: false, }; const sortedChildren = [...trie.children.values()].sort((a, b) => a.label.localeCompare(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 allExchangeIds = exchanges.map((ex) => ex.id); const domainsFolder: TreeNode = { item: { id: "domains", label: "Domains", exchangeIds: allExchangeIds }, parent: rootNode, depth: 1, children: [], draggable: false, }; const sortedDomains = [...domainMap.values()].sort((a, b) => a.label.localeCompare(b.label)); for (const domain of sortedDomains) { domainsFolder.children?.push(toTreeNode(domain, domainsFolder, 2)); } rootNode.children?.push(domainsFolder); 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 = SIDEBAR_TREE_ID; const getItemKey = useCallback( (item: SidebarItem) => `${item.id}:${item.exchangeIds.length}`, [], ); return ( ); }