Refactor proxy codebase

This commit is contained in:
Gregory Schier
2026-03-12 08:31:05 -07:00
parent 4968237ece
commit 5e3ef70d93
21 changed files with 437 additions and 408 deletions

View File

@@ -0,0 +1,31 @@
import type { ActionInvocation } from '@yaakapp-internal/proxy-lib';
import { Button, type ButtonProps } from '@yaakapp-internal/ui';
import { useCallback } from 'react';
import { useRpcMutation } from '../hooks/useRpcMutation';
import { useActionMetadata } from '../hooks/useActionMetadata';
type ActionButtonProps = Omit<ButtonProps, 'onClick' | 'children'> & {
action: ActionInvocation;
/** Override the label from metadata */
children?: React.ReactNode;
};
export function ActionButton({ action, children, ...props }: ActionButtonProps) {
const meta = useActionMetadata(action);
const { mutate, isPending } = useRpcMutation('execute_action');
const onClick = useCallback(() => {
mutate(action);
}, [action, mutate]);
return (
<Button
{...props}
disabled={props.disabled || isPending}
isLoading={isPending}
onClick={onClick}
>
{children ?? meta?.label ?? '…'}
</Button>
);
}

View File

@@ -0,0 +1,133 @@
import { type } from '@tauri-apps/plugin-os';
import type { ProxyHeader } from '@yaakapp-internal/proxy-lib';
import {
HeaderSize,
IconButton,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
TruncatedWideTableCell,
} from '@yaakapp-internal/ui';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import { useRpcQueryWithEvent } from '../hooks/useRpcQueryWithEvent';
import { ActionButton } from './ActionButton';
import { filteredExchangesAtom, Sidebar } from './Sidebar';
export function ProxyLayout() {
const osType = type();
const exchanges = useAtomValue(filteredExchangesAtom);
const { data: proxyState } = useRpcQueryWithEvent('get_proxy_state', {}, 'proxy_state_changed');
const isRunning = proxyState?.state === 'running';
return (
<div
className={classNames(
'h-full w-full grid grid-rows-[auto_1fr]',
osType === 'linux' && 'border border-border-subtle',
)}
>
<HeaderSize
size="lg"
osType={osType}
hideWindowControls={false}
useNativeTitlebar={false}
interfaceScale={1}
className="x-theme-appHeader bg-surface"
>
<div className="grid grid-cols-[minmax(0,1fr)_auto]">
<div data-tauri-drag-region className="flex items-center text-sm px-2">
Yaak Proxy
</div>
<div>
<IconButton icon="alarm_clock" title="Yo" />
</div>
</div>
</HeaderSize>
<div className="grid grid-cols-[auto_1fr] min-h-0">
<Sidebar />
<main className="overflow-auto p-4">
<div className="flex items-center gap-3 mb-4">
<ActionButton
action={{ scope: 'global', action: 'proxy_start' }}
size="sm"
tone="primary"
disabled={isRunning}
/>
<ActionButton
action={{ scope: 'global', action: 'proxy_stop' }}
size="sm"
variant="border"
disabled={!isRunning}
/>
<span
className={classNames(
'text-xs font-medium',
isRunning ? 'text-success' : 'text-text-subtlest',
)}
>
{isRunning ? 'Running on :9090' : 'Stopped'}
</span>
</div>
{exchanges.length === 0 ? (
<p className="text-text-subtlest text-sm">No traffic yet</p>
) : (
<Table scrollable>
<TableHead>
<TableRow>
<TableHeaderCell>Method</TableHeaderCell>
<TableHeaderCell>URL</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Type</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{exchanges.map((ex) => (
<TableRow key={ex.id}>
<TableCell className="font-mono text-2xs">{ex.method}</TableCell>
<TruncatedWideTableCell className="font-mono text-2xs">
{ex.url}
</TruncatedWideTableCell>
<TableCell>
<StatusBadge status={ex.resStatus} error={ex.error} />
</TableCell>
<TableCell className="text-text-subtle text-xs">
{getContentType(ex.resHeaders)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</main>
</div>
</div>
);
}
function StatusBadge({ status, error }: { status: number | null; error: string | null }) {
if (error) return <span className="text-xs text-danger">Error</span>;
if (status == null) return <span className="text-xs text-text-subtlest"></span>;
const color =
status >= 500
? 'text-danger'
: status >= 400
? 'text-warning'
: status >= 300
? 'text-notice'
: 'text-success';
return <span className={classNames('text-xs font-mono', color)}>{status}</span>;
}
function getContentType(headers: ProxyHeader[]): string {
const ct = headers.find((h) => h.name.toLowerCase() === 'content-type')?.value;
if (ct == null) return '—';
// Strip parameters (e.g. "; charset=utf-8")
return ct.split(';')[0]?.trim() ?? ct;
}

View File

@@ -0,0 +1,213 @@
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<Record<string, boolean>>({}));
export const SIDEBAR_TREE_ID = 'proxy-sidebar';
const sidebarTreeAtom = atom<TreeNode<SidebarItem>>((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<string>();
const nodeMap = new Map<string, SidebarItem>();
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<SidebarItem>, map: Map<string, SidebarItem>) {
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<SidebarItem> {
const root: SidebarItem = { id: 'root', label: 'All Traffic', exchangeIds: [] };
const rootNode: TreeNode<SidebarItem> = {
item: root,
parent: null,
depth: 0,
children: [],
draggable: false,
};
// Intermediate trie structure for building
type TrieNode = {
id: string;
label: string;
exchangeIds: string[];
children: Map<string, TrieNode>;
};
const domainMap = new Map<string, TrieNode>();
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<SidebarItem>,
depth: number,
): TreeNode<SidebarItem> {
const node: TreeNode<SidebarItem> = {
item: {
id: trie.id,
label: trie.label,
exchangeIds: trie.exchangeIds,
},
parent,
depth,
children: [],
draggable: false,
};
for (const child of trie.children.values()) {
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<SidebarItem> = {
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 (
<div className="flex items-center gap-2 w-full min-w-0">
<span className="truncate">{item.label}</span>
{count > 0 && <span className="text-text-subtlest text-2xs shrink-0">{count}</span>}
</div>
);
}
export function Sidebar() {
const tree = useAtomValue(sidebarTreeAtom);
const treeId = SIDEBAR_TREE_ID;
const getItemKey = useCallback((item: SidebarItem) => item.id, []);
return (
<aside className="x-theme-sidebar bg-surface h-full w-[250px] min-w-0 overflow-y-auto border-r border-border-subtle">
<div className="pt-2 text-xs">
<Tree
treeId={treeId}
collapsedAtom={collapsedAtom(treeId)}
className="px-2 pb-10"
root={tree}
getItemKey={getItemKey}
ItemInner={ItemInner}
/>
</div>
</aside>
);
}