diff --git a/apps/yaak-proxy/Sidebar.tsx b/apps/yaak-proxy/Sidebar.tsx new file mode 100644 index 00000000..1568493c --- /dev/null +++ b/apps/yaak-proxy/Sidebar.tsx @@ -0,0 +1,169 @@ +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 ( + + ); +}