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 ( ); }