mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Fill out Yaak Proxy's sidebar menus, and move System's toggle into its menu
Rows for apps, folders, sites, hosts and paths get Filter by This Value, Exclude This Value and Copy Value, as the list's cells have, covering every selected row. Opening a row's menu no longer applies the row, so the menu adds to the filter as it is. System's eye becomes Show in All Traffic in its menu, and Domains counts its hosts as a plain number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a14fc18b4b
commit
ca1494b54a
6 files changed
+346
-84
No files matched your search
@@ -3,23 +3,27 @@ import {
|
||||
CONTROL_TOOLTIP_DELAY,
|
||||
type DropdownItem,
|
||||
Icon,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Tree,
|
||||
type TreeProps,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { type PointerEvent, type SyntheticEvent, useCallback, useEffect } from "react";
|
||||
import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
|
||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
||||
import { SIDEBAR_TREE_ID } from "../lib/atoms";
|
||||
import { appliedFilterAtom, SIDEBAR_TREE_ID } from "../lib/atoms";
|
||||
import { blockerOf, decryptMenuAction, isDecrypted, listEdit, setDecrypted } from "../lib/decrypt";
|
||||
import { revealLabel, revealPath } from "../lib/opener";
|
||||
import { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse";
|
||||
import { type SidebarItem, SYSTEM_ID } from "../lib/sidebarTree";
|
||||
import { noteSidebarInput } from "../lib/navigation";
|
||||
import {
|
||||
copyRowsText,
|
||||
filterWithRows,
|
||||
type SidebarItem,
|
||||
SYSTEM_ID,
|
||||
valueRows,
|
||||
} from "../lib/sidebarTree";
|
||||
import { applyFilter, noteSidebarInput, noteSidebarMenu } from "../lib/navigation";
|
||||
import { hideSystemAtom, sidebarTreeAtom } from "../lib/store";
|
||||
import { getOsType } from "../lib/tauri";
|
||||
import { useShowToast } from "../lib/toast";
|
||||
@@ -48,7 +52,7 @@ const collapsedAtom = atom(
|
||||
);
|
||||
|
||||
function Count({ item }: { item: SidebarItem }) {
|
||||
const text = item.countLabel ?? (item.count > 0 ? item.count.toLocaleString() : null);
|
||||
const text = item.count > 0 ? item.count.toLocaleString() : null;
|
||||
if (text == null && item.kind !== "all") return null;
|
||||
return (
|
||||
<span className="ml-auto pl-2 text-text-subtlest text-2xs tabular-nums shrink-0">
|
||||
@@ -90,6 +94,23 @@ function ItemInner({ item }: { item: SidebarItem }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Input in the sidebar itself, not in a row's menu, whose events reach it through its portal */
|
||||
function inSidebar(e: SyntheticEvent<HTMLElement>): boolean {
|
||||
return e.target instanceof Node && e.currentTarget.contains(e.target);
|
||||
}
|
||||
|
||||
/** A press that opens a context menu: the secondary button, or Control and a click on macOS */
|
||||
function opensMenu(e: PointerEvent<HTMLElement>): boolean {
|
||||
return e.button !== 0 || (e.ctrlKey && getOsType() === "macos");
|
||||
}
|
||||
|
||||
/** The menu's sections that have items, a separator between each */
|
||||
function withSeparators(sections: DropdownItem[][]): DropdownItem[] {
|
||||
return sections
|
||||
.filter((s) => s.some((i) => !i.hidden))
|
||||
.flatMap((s, n) => (n === 0 ? s : [{ type: "separator" } as const, ...s]));
|
||||
}
|
||||
|
||||
/** A row with nothing in its menu: the menu closes as it opens, so the row is not left marked */
|
||||
function CloseAtOnce({ onClose }: { onClose: () => void }) {
|
||||
useEffect(() => onClose(), [onClose]);
|
||||
@@ -107,40 +128,15 @@ function DecryptedMark({ item }: { item: SidebarItem }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Beside the row's button rather than in it, since it is a button of its own */
|
||||
function ItemRightSlot({ item }: { item: SidebarItem }) {
|
||||
return item.id === SYSTEM_ID ? <HideSystemToggle /> : <span aria-hidden />;
|
||||
}
|
||||
|
||||
/** The eye on System's row, which leaves its daemons out of All traffic */
|
||||
function HideSystemToggle() {
|
||||
const [hidden, setHidden] = useAtom(hideSystemAtom);
|
||||
return (
|
||||
<IconButton
|
||||
size="2xs"
|
||||
icon={hidden ? "eye_closed" : "eye"}
|
||||
iconColor="secondary"
|
||||
title={hidden ? "Show System in All traffic" : "Hide System from All traffic"}
|
||||
tooltip
|
||||
className={classNames(
|
||||
"!h-4 !w-4 mr-1",
|
||||
!hidden && "opacity-0 [.tree-item:hover_&]:opacity-100 focus-visible:opacity-100",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setHidden(!hidden);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const store = useStore();
|
||||
const tree = useAtomValue(sidebarTreeAtom);
|
||||
const getItemKey = useCallback(
|
||||
// The label too, as a saved search's changes when it is renamed
|
||||
(item: SidebarItem) => `${item.id}:${item.count}:${item.countLabel ?? ""}:${item.label}`,
|
||||
(item: SidebarItem) => `${item.id}:${item.count}:${item.label}`,
|
||||
[],
|
||||
);
|
||||
const [hideSystem, setHideSystem] = useAtom(hideSystemAtom);
|
||||
const decryptList = useDecryptList();
|
||||
const setDecryptList = useEditDecryptList();
|
||||
const { mutate: saveDecryptList } = setDecryptList;
|
||||
@@ -178,10 +174,51 @@ export function Sidebar() {
|
||||
[fail],
|
||||
);
|
||||
|
||||
// Decrypt or Stop Decrypting the selected hosts, sites and apps, from new connections on.
|
||||
// A saved search is renamed or deleted.
|
||||
// The selected apps, folders, sites, hosts and paths added to the filter, either way, at once,
|
||||
// or their values copied, as a cell's menu in the list does.
|
||||
const filterItems = useCallback(
|
||||
(items: SidebarItem[]): DropdownItem[] => {
|
||||
if (valueRows(items).length === 0) return [];
|
||||
const add = (negated: boolean) =>
|
||||
applyFilter(store, filterWithRows(store.get(appliedFilterAtom), items, negated));
|
||||
return [
|
||||
{ label: "Filter by This Value", icon: "filter", onSelect: () => add(false) },
|
||||
{ label: "Exclude This Value", icon: "minus_circle", onSelect: () => add(true) },
|
||||
{
|
||||
label: "Copy Value",
|
||||
icon: "copy",
|
||||
onSelect: async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(copyRowsText(items));
|
||||
showToast({ message: "Copied value" });
|
||||
} catch (err) {
|
||||
showToast({ message: `Failed to copy value: ${String(err)}`, color: "danger" });
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
[store, showToast],
|
||||
);
|
||||
|
||||
// Filter by the selected rows, Decrypt or Stop Decrypting the selected hosts, sites and apps,
|
||||
// from new connections on, then a program's folder. A saved search is renamed or deleted, and
|
||||
// System is shown in All traffic or left out of it.
|
||||
const getContextMenu = useCallback(
|
||||
(items: SidebarItem[]): DropdownItem[] => {
|
||||
if (items.length === 1 && items[0]?.id === SYSTEM_ID) {
|
||||
return [
|
||||
{
|
||||
label: "Show in All Traffic",
|
||||
leftSlot: hideSystem ? (
|
||||
<span aria-hidden className="inline-block w-4 h-4" />
|
||||
) : (
|
||||
<Icon icon="check" size="sm" />
|
||||
),
|
||||
onSelect: () => setHideSystem(!hideSystem),
|
||||
},
|
||||
];
|
||||
}
|
||||
const paths = items.length === 1 ? pathItems(items[0]) : [];
|
||||
const saved = items.flatMap((i) => (i.savedId == null ? [] : [{ ...i, id: i.savedId }]));
|
||||
if (saved.length > 0) {
|
||||
@@ -208,32 +245,43 @@ export function Sidebar() {
|
||||
}
|
||||
const subjects = items.flatMap((i) => (i.decrypt == null ? [] : [i.decrypt]));
|
||||
const action = decryptMenuAction(decryptList, subjects);
|
||||
if (action == null) return paths;
|
||||
return [
|
||||
...paths,
|
||||
...(paths.length > 0 ? [{ type: "separator" } as const] : []),
|
||||
{
|
||||
label: action.label,
|
||||
icon: action.on ? "lock_open" : "lock",
|
||||
onSelect: () => {
|
||||
const blocked = action.on ? blockerOf(decryptList, subjects) : null;
|
||||
if (blocked != null) {
|
||||
showToast({
|
||||
id: "decrypt-blocked",
|
||||
color: "notice",
|
||||
message: `Excluded by ${blocked}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Only what this changes, applied to the list as it is then, so a second change
|
||||
// made before this one lands keeps it.
|
||||
const next = setDecrypted(decryptList, subjects, action.on);
|
||||
saveDecryptList(listEdit(decryptList, next));
|
||||
},
|
||||
},
|
||||
];
|
||||
const decrypt: DropdownItem[] =
|
||||
action == null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: action.label,
|
||||
icon: action.on ? "lock_open" : "lock",
|
||||
onSelect: () => {
|
||||
const blocked = action.on ? blockerOf(decryptList, subjects) : null;
|
||||
if (blocked != null) {
|
||||
showToast({
|
||||
id: "decrypt-blocked",
|
||||
color: "notice",
|
||||
message: `Excluded by ${blocked}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Only what this changes, applied to the list as it is then, so a second change
|
||||
// made before this one lands keeps it.
|
||||
const next = setDecrypted(decryptList, subjects, action.on);
|
||||
saveDecryptList(listEdit(decryptList, next));
|
||||
},
|
||||
},
|
||||
];
|
||||
return withSeparators([filterItems(items), decrypt, paths]);
|
||||
},
|
||||
[decryptList, saveDecryptList, showToast, setSavedDialog, deleteSaved, pathItems],
|
||||
[
|
||||
decryptList,
|
||||
saveDecryptList,
|
||||
showToast,
|
||||
setSavedDialog,
|
||||
deleteSaved,
|
||||
pathItems,
|
||||
filterItems,
|
||||
hideSystem,
|
||||
setHideSystem,
|
||||
],
|
||||
);
|
||||
const renderContextMenu = useCallback<NonNullable<TreeProps<SidebarItem>["renderContextMenu"]>>(
|
||||
({ items, position, onClose }) =>
|
||||
@@ -249,11 +297,18 @@ export function Sidebar() {
|
||||
// The tree scrolls itself, in a row of bounded height, so its virtualizer mounts only the rows
|
||||
// in view rather than every site, host and path.
|
||||
// A click or key here marks the selection change it makes as the person's, so it writes the
|
||||
// search; the tree's own upkeep does not.
|
||||
// search; the tree's own upkeep does not. Opening a row's menu selects it too, but leaves the
|
||||
// search as it is, for the menu's Filter by This Value to add to, and the rows as it says.
|
||||
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- only notes input the tree handles
|
||||
<aside
|
||||
onPointerDownCapture={noteSidebarInput}
|
||||
onKeyDownCapture={noteSidebarInput}
|
||||
onPointerDownCapture={(e) => {
|
||||
if (!inSidebar(e)) return;
|
||||
if (opensMenu(e)) noteSidebarMenu();
|
||||
else noteSidebarInput();
|
||||
}}
|
||||
onKeyDownCapture={(e) => {
|
||||
if (inSidebar(e)) noteSidebarInput();
|
||||
}}
|
||||
className="x-theme-sidebar bg-surface h-full w-full min-w-0 grid grid-rows-[minmax(0,1fr)] border-r border-border-subtle"
|
||||
>
|
||||
<Tree
|
||||
@@ -263,7 +318,6 @@ export function Sidebar() {
|
||||
root={tree}
|
||||
getItemKey={getItemKey}
|
||||
ItemInner={ItemInner}
|
||||
ItemRightSlot={ItemRightSlot}
|
||||
getContextMenu={getContextMenu}
|
||||
renderContextMenu={renderContextMenu}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createStore } from "jotai";
|
||||
import { describe, expect, test, vi } from "vite-plus/test";
|
||||
|
||||
// The store reads the engine and the tree's selection through these; the toggle needs neither.
|
||||
vi.mock("./rpc", () => ({ rpc: vi.fn(), listen: vi.fn() }));
|
||||
vi.mock("@yaakapp-internal/ui", async () => {
|
||||
const { atom } = await vi.importActual<typeof import("jotai")>("jotai");
|
||||
return { selectedIdsFamily: () => atom<string[]>([]) };
|
||||
});
|
||||
|
||||
const { activeQueryAtom, allTrafficTotalAtom, flowTotalAtom, hideSystemAtom, nonSystemTotalAtom } =
|
||||
await import("./store");
|
||||
|
||||
describe("Show in All Traffic, in System's menu", () => {
|
||||
test("shows System's traffic in All traffic by default", () => {
|
||||
const store = createStore();
|
||||
expect(store.get(hideSystemAtom)).toBe(false);
|
||||
expect(store.get(activeQueryAtom)).toBeNull();
|
||||
});
|
||||
|
||||
test("leaves System out of All traffic and brings it back at once", () => {
|
||||
const store = createStore();
|
||||
store.set(nonSystemTotalAtom, 3);
|
||||
expect(store.get(allTrafficTotalAtom)).toBe(store.get(flowTotalAtom));
|
||||
store.set(hideSystemAtom, true);
|
||||
expect(store.get(activeQueryAtom)).toEqual({ query: "", hideSystem: true });
|
||||
expect(store.get(allTrafficTotalAtom)).toBe(3);
|
||||
store.set(hideSystemAtom, false);
|
||||
expect(store.get(activeQueryAtom)).toBeNull();
|
||||
expect(store.get(allTrafficTotalAtom)).toBe(store.get(flowTotalAtom));
|
||||
});
|
||||
});
|
||||
@@ -283,6 +283,14 @@ export function noteSidebarInput() {
|
||||
lastSidebarInput = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes a press that opens a row's menu, which selects the row without applying its filter, so
|
||||
* the menu's items add to the filter as it is
|
||||
*/
|
||||
export function noteSidebarMenu() {
|
||||
lastSidebarInput = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the sidebar's selection and the filter in step. Selecting rows applies their query at
|
||||
* once, as a new place; a filter applied keeps a row selected while its query is still one of
|
||||
@@ -311,17 +319,19 @@ export function syncSidebarSelection(store: Store) {
|
||||
|
||||
// The tree writes its selection again as it renders, the same rows in a new array, or its last
|
||||
// row when the selection was emptied; only a change of rows right after a click or a key in
|
||||
// the sidebar is the person selecting.
|
||||
// the sidebar is the person selecting. A click on a row the tree selected again by itself, as
|
||||
// after its menu was opened, applies it then.
|
||||
let lastRows = store.get(selectedAtom);
|
||||
store.sub(selectedAtom, () => {
|
||||
const before = lastRows;
|
||||
const ids = store.get(selectedAtom);
|
||||
if (ids.join("\n") === before.join("\n")) return;
|
||||
const theirs = Date.now() - lastSidebarInput <= SIDEBAR_INPUT_MS;
|
||||
if (ids.join("\n") === before.join("\n") && !theirs) return;
|
||||
lastRows = ids;
|
||||
if (syncing) return;
|
||||
// Not the person's, such as the tree selecting its first row as it mounts over a filter
|
||||
// restored at launch: the rows go back to what the filter says.
|
||||
if (Date.now() - lastSidebarInput > SIDEBAR_INPUT_MS) {
|
||||
// restored at launch, or a row as its menu opens: the rows go back to what the filter says.
|
||||
if (!theirs) {
|
||||
keepRows(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,11 +6,14 @@ import {
|
||||
ALL_ID,
|
||||
buildSidebarTree,
|
||||
exclusiveAll,
|
||||
copyRowsText,
|
||||
filterWithRows,
|
||||
itemsById,
|
||||
queryInText,
|
||||
rowsForFilter,
|
||||
type SidebarItem,
|
||||
selectionText,
|
||||
valueRows,
|
||||
} from "./sidebarTree";
|
||||
|
||||
function flow(url: string, extra: Partial<Flow> = {}): Flow {
|
||||
@@ -37,8 +40,8 @@ const flows = [
|
||||
];
|
||||
|
||||
function shape(n: TreeNode<SidebarItem>, depth = 0): string[] {
|
||||
const { label, sublabel, count, countLabel } = n.item;
|
||||
const line = `${" ".repeat(depth)}${label}${sublabel ? ` (${sublabel})` : ""} ${countLabel ?? count}`;
|
||||
const { label, sublabel, count } = n.item;
|
||||
const line = `${" ".repeat(depth)}${label}${sublabel ? ` (${sublabel})` : ""} ${count}`;
|
||||
return [line, ...(n.children ?? []).flatMap((c) => shape(c, depth + 1))];
|
||||
}
|
||||
|
||||
@@ -60,7 +63,7 @@ describe("buildSidebarTree", () => {
|
||||
" slack.com 1",
|
||||
" edgeapi.slack.com 1",
|
||||
" /cache 1",
|
||||
"Domains 4 hosts",
|
||||
"Domains 4",
|
||||
" apple.com 1",
|
||||
" ocsp.apple.com 1",
|
||||
" /ocsp 1",
|
||||
@@ -345,3 +348,115 @@ describe("saved searches", () => {
|
||||
expect(itemsById(buildSidebarTree(flows, 4)).has("saved")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filtering from a row's menu", () => {
|
||||
const chrome = {
|
||||
clientName: "Google Chrome",
|
||||
clientBundleId: "com.google.Chrome",
|
||||
clientPath: "/Applications/Google Chrome.app",
|
||||
};
|
||||
const claude = {
|
||||
clientName: "claude",
|
||||
clientPath: "/usr/local/bin/claude",
|
||||
clientCwd: "/src/yaak",
|
||||
};
|
||||
const root = buildSidebarTree(
|
||||
[
|
||||
...flows,
|
||||
flow("https://api.stripe.com/v1/charges", chrome),
|
||||
flow("https://api.anthropic.com/v1/messages", claude),
|
||||
],
|
||||
6,
|
||||
[{ id: "sq_1", name: "Errors", query: "@status:>=400", count: 1 }],
|
||||
);
|
||||
const items = itemsById(root);
|
||||
const rows = (...ids: string[]) =>
|
||||
ids.map((id) => {
|
||||
const item = items.get(id);
|
||||
if (item == null) throw new Error(`no row ${id}`);
|
||||
return item;
|
||||
});
|
||||
|
||||
test("offers the filter items on apps, folders, sites, hosts and paths only", () => {
|
||||
const offered = [...items.values()].filter((i) => valueRows([i]).length > 0);
|
||||
expect(new Set(offered.map((i) => i.kind))).toEqual(
|
||||
new Set(["app", "folder", "site", "host", "path"]),
|
||||
);
|
||||
for (const id of ["all", "apps", "domains", "system", "saved", "saved:sq_1"]) {
|
||||
expect(valueRows(rows(id)), id).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("Filter by This Value adds the row's query to the filter", () => {
|
||||
const add = (text: string, ...ids: string[]) => filterWithRows(text, rows(...ids), false);
|
||||
expect(add("", "app:Google Chrome")).toBe('@app:"Google Chrome"');
|
||||
expect(add("@method:POST", "app:Google Chrome")).toBe('@method:POST @app:"Google Chrome"');
|
||||
expect(add("", "host:api.stripe.com")).toBe("@host:api.stripe.com");
|
||||
expect(add("", "site:stripe.com")).toBe("@host:*.stripe.com");
|
||||
expect(add("", "path:api.stripe.com/v1/charges")).toBe(
|
||||
"@host:api.stripe.com @path:/v1/charges/*",
|
||||
);
|
||||
expect(add("", "app:claude|cwd:yaak")).toBe("@app:claude @cwd:yaak");
|
||||
// A daemon's still shows while System is hidden from All traffic.
|
||||
expect(add("", "app:trustd")).toBe("@is:system @app:trustd");
|
||||
// A filter with OR at its top is grouped first, so the row narrows all of it.
|
||||
expect(add("@status:4xx OR @status:5xx", "host:api.stripe.com")).toBe(
|
||||
"(@status:4xx OR @status:5xx) @host:api.stripe.com",
|
||||
);
|
||||
});
|
||||
|
||||
test("Filter by This Value adds several rows as one OR group", () => {
|
||||
const picked = rows("host:api.stripe.com", "app:Google Chrome");
|
||||
expect(filterWithRows("", picked, false)).toBe('@host:api.stripe.com OR @app:"Google Chrome"');
|
||||
expect(filterWithRows("@method:GET", picked, false)).toBe(
|
||||
'@method:GET (@host:api.stripe.com OR @app:"Google Chrome")',
|
||||
);
|
||||
expect(filterWithRows("a OR b", picked, false)).toBe(
|
||||
'(a OR b) (@host:api.stripe.com OR @app:"Google Chrome")',
|
||||
);
|
||||
// Rows with no value, such as a heading, add nothing.
|
||||
expect(filterWithRows("@method:GET", rows("domains"), false)).toBe("@method:GET");
|
||||
expect(parseQuery(filterWithRows("x", picked, false))?.type).toBe("And");
|
||||
});
|
||||
|
||||
test("Exclude This Value adds the row's own value, negated", () => {
|
||||
const exclude = (text: string, ...ids: string[]) => filterWithRows(text, rows(...ids), true);
|
||||
expect(exclude("", "app:Google Chrome")).toBe('-@app:"Google Chrome"');
|
||||
expect(exclude("", "app:trustd")).toBe("-@app:trustd");
|
||||
expect(exclude("", "host:api.stripe.com")).toBe("-@host:api.stripe.com");
|
||||
expect(exclude("", "site:stripe.com")).toBe("-@host:*.stripe.com");
|
||||
expect(exclude("", "app:claude|cwd:yaak")).toBe("-@cwd:yaak");
|
||||
// A path's host and path, negated as a group
|
||||
expect(exclude("", "path:api.stripe.com/v1/charges")).toBe(
|
||||
"-(@host:api.stripe.com @path:/v1/charges/*)",
|
||||
);
|
||||
// Under an app, the row's own value, not the app's
|
||||
expect(exclude("", "app:Google Chrome|host:api.stripe.com")).toBe("-@host:api.stripe.com");
|
||||
expect(exclude("@status:4xx OR @status:5xx", "host:api.stripe.com")).toBe(
|
||||
"(@status:4xx OR @status:5xx) -@host:api.stripe.com",
|
||||
);
|
||||
});
|
||||
|
||||
test("Exclude This Value negates each of several rows", () => {
|
||||
expect(
|
||||
filterWithRows("@method:GET", rows("host:api.stripe.com", "app:Google Chrome"), true),
|
||||
).toBe('@method:GET -@host:api.stripe.com -@app:"Google Chrome"');
|
||||
for (const text of [
|
||||
filterWithRows("", rows("path:api.stripe.com/v1", "site:github.com"), true),
|
||||
filterWithRows("", rows("app:Google Chrome", "app:claude|cwd:yaak"), false),
|
||||
]) {
|
||||
expect(parseQuery(text), text).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("Copy Value copies each row's name, one a line", () => {
|
||||
expect(copyRowsText(rows("app:Google Chrome"))).toBe("Google Chrome");
|
||||
expect(copyRowsText(rows("app:claude|cwd:yaak"))).toBe("/src/yaak");
|
||||
expect(copyRowsText(rows("site:stripe.com"))).toBe("stripe.com");
|
||||
expect(copyRowsText(rows("host:api.stripe.com"))).toBe("api.stripe.com");
|
||||
expect(copyRowsText(rows("path:api.stripe.com/v1/charges"))).toBe("api.stripe.com/v1/charges");
|
||||
expect(copyRowsText(rows("host:api.stripe.com", "app:Google Chrome"))).toBe(
|
||||
"api.stripe.com\nGoogle Chrome",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -33,14 +33,19 @@ export interface SidebarItem {
|
||||
label: string;
|
||||
/** Dim beside the label: a generic runtime's folder */
|
||||
sublabel?: string;
|
||||
/** Flows under the row, of those loaded */
|
||||
/** Flows under the row, of those loaded; hosts, for Domains */
|
||||
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;
|
||||
/**
|
||||
* The row's own value excluded, without the rows above it, for Exclude This Value; set only on
|
||||
* the rows whose menu filters by them
|
||||
*/
|
||||
exclude?: string;
|
||||
/** What Copy Value copies, when not the label: a path row's host and path */
|
||||
value?: 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 */
|
||||
@@ -74,6 +79,8 @@ interface PlaceRow {
|
||||
kind: "folder" | "site" | "host" | "path";
|
||||
label: string;
|
||||
query: string;
|
||||
exclude: string;
|
||||
value?: string;
|
||||
decrypt?: DecryptSubject;
|
||||
/** A folder row's working directory, in full */
|
||||
path?: string;
|
||||
@@ -109,6 +116,7 @@ function placeOf(flow: Flow): Place {
|
||||
kind: "site",
|
||||
label: site,
|
||||
query: siteTerm(site),
|
||||
exclude: `-${siteTerm(site)}`,
|
||||
decrypt: { kind: "site", site },
|
||||
},
|
||||
{
|
||||
@@ -116,17 +124,21 @@ function placeOf(flow: Flow): Place {
|
||||
kind: "host",
|
||||
label: host,
|
||||
query: hostQuery,
|
||||
exclude: fieldTerm("host", host, true),
|
||||
decrypt: { kind: "host", host },
|
||||
},
|
||||
];
|
||||
let p = "";
|
||||
for (const segment of segments) {
|
||||
p += `/${segment}`;
|
||||
const query = `${hostQuery} ${pathTerm(p)}`;
|
||||
rows.push({
|
||||
id: `path:${host}${p}`,
|
||||
kind: "path",
|
||||
label: `/${segment}`,
|
||||
query: `${hostQuery} ${pathTerm(p)}`,
|
||||
query,
|
||||
exclude: `-(${query})`,
|
||||
value: `${host}${p}`,
|
||||
});
|
||||
}
|
||||
const system = isSystemFlow(flow);
|
||||
@@ -148,6 +160,7 @@ function placeOf(flow: Flow): Place {
|
||||
kind: "folder",
|
||||
label: folder,
|
||||
query: folderQuery,
|
||||
exclude: fieldTerm("cwd", folder, true),
|
||||
};
|
||||
if (flow.clientCwd != null) folderRow.path = flow.clientCwd;
|
||||
appRows = [
|
||||
@@ -192,9 +205,17 @@ 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 },
|
||||
item: {
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
label: row.label,
|
||||
count: 0,
|
||||
query: row.query,
|
||||
exclude: row.exclude,
|
||||
},
|
||||
children: new Map(),
|
||||
};
|
||||
if (row.value != null) node.item.value = row.value;
|
||||
if (row.decrypt != null) node.item.decrypt = row.decrypt;
|
||||
if (row.path != null) node.item.path = row.path;
|
||||
parent.children.set(row.id, node);
|
||||
@@ -268,7 +289,6 @@ export function buildSidebarTree(
|
||||
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;
|
||||
@@ -282,6 +302,7 @@ export function buildSidebarTree(
|
||||
label: flow.clientName ?? place.app,
|
||||
count: 0,
|
||||
query: appQueryFor(place.app, place.system),
|
||||
exclude: fieldTerm("app", place.app, true),
|
||||
};
|
||||
if (flow.clientName != null) {
|
||||
item.decrypt = { kind: "app", name: flow.clientName, bundleId: flow.clientBundleId };
|
||||
@@ -300,9 +321,7 @@ export function buildSidebarTree(
|
||||
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`;
|
||||
for (const site of domains.children.values()) domains.item.count += site.children.size;
|
||||
|
||||
root.children?.push({
|
||||
item: { id: ALL_ID, kind: "all", label: "All traffic", count: total, query: "" },
|
||||
@@ -428,3 +447,35 @@ export function queryInText(query: string, text: string): boolean {
|
||||
if (want.length === 0) return have.size === 0;
|
||||
return want.every((t) => have.has(t));
|
||||
}
|
||||
|
||||
/** The rows of a selection whose menu filters by them: apps, folders, sites, hosts and paths */
|
||||
export function valueRows(items: SidebarItem[]): SidebarItem[] {
|
||||
return items.filter((i) => i.exclude != null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The filter `text` with the rows added, for Filter by This Value: their queries as one `OR`
|
||||
* group, or their values each excluded. A filter with `OR` at its top is grouped first, so the
|
||||
* rows narrow all of it, as the list's cell menu does.
|
||||
*/
|
||||
export function filterWithRows(text: string, items: SidebarItem[], negated: boolean): string {
|
||||
const rows = valueRows(items);
|
||||
const terms = [
|
||||
...new Set(rows.map((i) => (negated ? (i.exclude ?? "") : i.query)).filter((t) => t !== "")),
|
||||
];
|
||||
if (terms.length === 0) return text;
|
||||
const term = negated ? terms.join(" ") : terms.join(" OR ");
|
||||
const current = text.trim();
|
||||
return current === "" ? term : `${asOneTerm(current)} ${asOneTerm(term)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* What Copy Value copies for the rows, one a line: an app's name, a folder's path, a site or
|
||||
* host, or a path's host and path
|
||||
*/
|
||||
export function copyRowsText(items: SidebarItem[]): string {
|
||||
const values = valueRows(items).map((i) =>
|
||||
i.kind === "folder" ? (i.path ?? i.label) : (i.value ?? i.label),
|
||||
);
|
||||
return [...new Set(values)].join("\n");
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export const systemProxyEverOnAtom = atomWithStorage("system_proxy_ever_on", fal
|
||||
/** Some flow held names the app that sent it, which only some platforms can tell */
|
||||
export const hasAppsAtom = atom((get) => get(flowsAtom).some((f) => f.clientName != null));
|
||||
|
||||
/** System's flows are left out of All traffic, by the eye on System's row */
|
||||
/** System's flows are left out of All traffic, by Show in All Traffic in System's menu */
|
||||
export const hideSystemAtom = atomWithStorage("sidebar_hide_system", false, undefined, {
|
||||
getOnInit: true,
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user