Files
yaak-mountain-loop/apps/yaak-proxy/lib/sidebarTree.ts
T
Gregory SchierandClaude Opus 5.5 c78732ecaf Polish Yaak Proxy's flow list, sidebar and header
Columns keep fixed widths, Path included, and the list scrolls sideways
with the header in step instead of dropping columns or capping Host.
Resize handles sit on every column boundary, a double-click resets one,
and a third click on a sorted header goes back to start time, with empty
values sorting lowest. The search box takes up to 44rem and wraps over
the list while focused, its completions as wide as it. The list/detail
handle fills the gutter. Native title tooltips become Yaak's tooltip,
shared buttons gain an opt-in for it, and folder and program rows in the
sidebar get Reveal in Finder and Copy path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:45:46 -07:00

374 lines
11 KiB
TypeScript

import type { Flow } from "@yaakapp-internal/proxy-lib";
import type { TreeNode } from "@yaakapp-internal/ui";
import type { DecryptSubject } from "./decrypt";
import { fieldTerm, pathTerm, siteTerm, termKeys } from "./query";
import { appKeyOf, folderOf, isSystemFlow, runtimeFolderOf, sidebarPath, siteOf } from "./search";
export type SidebarKind =
| "all"
| "tunnelled"
| "saved"
| "group"
| "app"
| "folder"
| "site"
| "host"
| "path";
/**
* A row of the sidebar: All traffic, a saved search, a group, an app, a program's working
* directory, or a site, host or path under one
*/
export interface SidebarItem {
id: string;
kind: SidebarKind;
label: string;
/** Dim beside the label: a generic runtime's folder */
sublabel?: string;
/** Flows under the row, of those loaded */
count: number;
/** Shown instead of the count, as Domains says how many hosts */
countLabel?: string;
/** An app's path, for its icon */
appPath?: string;
/** What selecting the row writes into the search box; empty for all traffic */
query: string;
/** What Decrypt in the row's menu decrypts: its host, site or app */
decrypt?: DecryptSubject;
/** The saved search a Saved row is, for renaming and deleting it */
savedId?: string;
/**
* On disk: a folder row's working directory, in full, and a program's executable, for Reveal
* in Finder and, on a folder, Copy path
*/
path?: string;
}
/** A saved search as the sidebar lists it, with how many flows it covers */
export interface SavedRow {
id: string;
name: string;
query: string;
count: number;
}
/** The ids of All traffic and the groups */
export const ALL_ID = "all";
export const SAVED_ID = "saved";
export const APPS_ID = "apps";
export const DOMAINS_ID = "domains";
export const SYSTEM_ID = "system";
export const TUNNELLED_ID = "tunnelled";
/** A row a flow sits under, with what makes it, so a rebuild only looks it up */
interface PlaceRow {
id: string;
kind: "folder" | "site" | "host" | "path";
label: string;
query: string;
decrypt?: DecryptSubject;
/** A folder row's working directory, in full */
path?: string;
}
/**
* Where a flow sits, parsed once per flow rather than on every rebuild: its site, host and path
* rows in Domains, and the same rows under its app, below its working directory for a program
* outside an app that isn't a generic runtime (those are named by their folder instead).
*/
interface Place {
/** What `@app` names its app by, or null when unknown */
app: string | null;
system: boolean;
rows: PlaceRow[];
appRows: PlaceRow[];
}
const places = new WeakMap<Flow, Place>();
function placeOf(flow: Flow): Place {
let place = places.get(flow);
if (place != null) return place;
const path = sidebarPath(flow.url);
const schemeless = !flow.url.includes("://");
const [host = "", ...segments] = schemeless ? [path] : path.split("/");
const site = siteOf(host);
const app = appKeyOf(flow);
const hostQuery = fieldTerm("host", host);
const rows: PlaceRow[] = [
{
id: `site:${site}`,
kind: "site",
label: site,
query: siteTerm(site),
decrypt: { kind: "site", site },
},
{
id: `host:${host}`,
kind: "host",
label: host,
query: hostQuery,
decrypt: { kind: "host", host },
},
];
let p = "";
for (const segment of segments) {
p += `/${segment}`;
rows.push({
id: `path:${host}${p}`,
kind: "path",
label: `/${segment}`,
query: `${hostQuery} ${pathTerm(p)}`,
});
}
const system = isSystemFlow(flow);
const appQuery = app == null ? "" : appQueryFor(app, system);
let appRows: PlaceRow[] = [];
if (app != null) {
const folder = runtimeFolderOf(flow) == null ? folderOf(flow) : null;
if (folder == null) {
appRows = rows.map((r) => ({
...r,
id: `app:${app}|${r.id}`,
query: `${appQuery} ${r.query}`,
}));
} else {
const folderId = `app:${app}|cwd:${folder}`;
const folderQuery = `${appQuery} ${fieldTerm("cwd", folder)}`;
const folderRow: PlaceRow = {
id: folderId,
kind: "folder",
label: folder,
query: folderQuery,
};
if (flow.clientCwd != null) folderRow.path = flow.clientCwd;
appRows = [
folderRow,
...rows.map((r) => ({
...r,
id: `${folderId}|${r.id}`,
query: `${folderQuery} ${r.query}`,
})),
];
}
}
place = { app, system, rows, appRows };
places.set(flow, place);
return place;
}
/**
* What an app's row writes. A daemon's says `@is:system` too, so selecting it shows its flows
* while System is hidden from All traffic.
*/
function appQueryFor(app: string, system: boolean): string {
const query = fieldTerm("app", app);
return system ? `${fieldTerm("is", "system")} ${query}` : query;
}
/** A node being built, its children by id */
interface Trie {
item: SidebarItem;
children: Map<string, Trie>;
}
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
/** Counts a flow under `parent`'s child `row`, making the child the first time */
function child(parent: Trie, row: PlaceRow): Trie {
let node = parent.children.get(row.id);
if (node == null) {
node = {
item: { id: row.id, kind: row.kind, label: row.label, count: 0, query: row.query },
children: new Map(),
};
if (row.decrypt != null) node.item.decrypt = row.decrypt;
if (row.path != null) node.item.path = row.path;
parent.children.set(row.id, node);
}
node.item.count++;
return node;
}
/** Adds a flow under `parent` by where it went: its site, then its host, then its path */
function addPlace(parent: Trie, rows: PlaceRow[]) {
let node = parent;
for (const row of rows) node = child(node, row);
}
function toNodes(
tries: Iterable<Trie>,
parent: TreeNode<SidebarItem>,
sort = byLabel,
): TreeNode<SidebarItem>[] {
return [...tries].sort(sort).map((trie) => {
const node: TreeNode<SidebarItem> = {
item: trie.item,
parent,
depth: parent.depth + 1,
children: [],
draggable: false,
};
node.children = toNodes(trie.children.values(), node);
return node;
});
}
/** By label, a program's working directories before the sites it reached directly */
function byLabel(a: Trie, b: Trie): number {
const folders = Number(b.item.kind === "folder") - Number(a.item.kind === "folder");
return folders || collator.compare(a.item.label, b.item.label);
}
function group(id: string, label: string): Trie {
return { item: { id, kind: "group", label, count: 0, query: "" }, children: new Map() };
}
/**
* The sidebar: All traffic, then Apps, each app opening to its own sites, hosts and paths, then
* Domains by site, host and path, then System, macOS's daemons, apart from the apps. A group with
* nothing in it is left out. Not decrypted, after All traffic, gathers the HTTPS passed through
* undecrypted, when there is any, and Saved lists the saved searches, each with its count from
* the database. `total` is All traffic's count: every flow it covers in the database.
*/
export function buildSidebarTree(
flows: Flow[],
total: number,
saved: SavedRow[] = [],
): TreeNode<SidebarItem> {
const root: TreeNode<SidebarItem> = {
item: { id: "root", kind: "group", label: "", count: 0, query: "" },
parent: null,
depth: 0,
children: [],
draggable: false,
};
const apps = group(APPS_ID, "Apps");
const domains = group(DOMAINS_ID, "Domains");
const system = { ...group(SYSTEM_ID, "System") };
system.item.query = fieldTerm("is", "system");
let tunnelled = 0;
for (const flow of flows) {
const place = placeOf(flow);
if (flow.tunnelled) tunnelled++;
domains.item.count++;
addPlace(domains, place.rows);
if (place.app == null) continue;
const parent = place.system ? system : apps;
const id = `app:${place.app}`;
let app = parent.children.get(id);
if (app == null) {
const folder = runtimeFolderOf(flow);
const item: SidebarItem = {
id,
kind: "app",
label: flow.clientName ?? place.app,
count: 0,
query: appQueryFor(place.app, place.system),
};
if (flow.clientName != null) {
item.decrypt = { kind: "app", name: flow.clientName, bundleId: flow.clientBundleId };
}
if (folder != null) item.sublabel = folder;
if (flow.clientPath != null) item.appPath = flow.clientPath;
// A program outside an app bundle: its executable
if (flow.clientBundleId == null && flow.clientPath != null) item.path = flow.clientPath;
app = { item, children: new Map() };
parent.children.set(id, app);
}
app.item.count++;
parent.item.count++;
addPlace(app, place.appRows);
}
let hosts = 0;
for (const site of domains.children.values()) hosts += site.children.size;
domains.item.countLabel = hosts === 1 ? "1 host" : `${hosts} hosts`;
root.children?.push({
item: { id: ALL_ID, kind: "all", label: "All traffic", count: total, query: "" },
parent: root,
depth: 1,
draggable: false,
});
if (tunnelled > 0) {
root.children?.push({
item: {
id: TUNNELLED_ID,
kind: "tunnelled",
label: "Not decrypted",
count: tunnelled,
query: fieldTerm("is", "tunnelled"),
},
parent: root,
depth: 1,
draggable: false,
});
}
if (saved.length > 0) {
const node: TreeNode<SidebarItem> = {
item: { id: SAVED_ID, kind: "group", label: "Saved", count: 0, query: "" },
parent: root,
depth: 1,
children: [],
draggable: false,
};
node.children = saved.map((s) => ({
item: {
id: `saved:${s.id}`,
kind: "saved",
label: s.name,
count: s.count,
query: s.query,
savedId: s.id,
},
parent: node,
depth: 2,
draggable: false,
}));
root.children?.push(node);
}
for (const g of [apps, domains, system]) {
if (g.children.size === 0) continue;
const node: TreeNode<SidebarItem> = {
item: g.item,
parent: root,
depth: 1,
children: [],
draggable: false,
};
node.children = toNodes(g.children.values(), node);
root.children?.push(node);
}
return root;
}
/** Every row's item by id, for reading a selection */
export function itemsById(root: TreeNode<SidebarItem>): Map<string, SidebarItem> {
const map = new Map<string, SidebarItem>();
const walk = (node: TreeNode<SidebarItem>) => {
map.set(node.item.id, node.item);
for (const c of node.children ?? []) walk(c);
};
walk(root);
return map;
}
/** The search text for the selected rows: their queries, OR'd; empty for All traffic */
export function selectionText(items: SidebarItem[]): string {
const queries = [...new Set(items.map((i) => i.query))];
if (queries.includes("")) return "";
return queries.join(" OR ");
}
/**
* Whether a row's query is still in the search text, every term of it among the text's: a row
* stays selected while it is, so typing after selecting keeps it.
*/
export function queryInText(query: string, text: string): boolean {
const have = new Set(termKeys(text));
const want = termKeys(query);
if (want.length === 0) return have.size === 0;
return want.every((t) => have.has(t));
}