diff --git a/apps/yaak-proxy/harness/backend.ts b/apps/yaak-proxy/harness/backend.ts index c87286c53..646ece45c 100644 --- a/apps/yaak-proxy/harness/backend.ts +++ b/apps/yaak-proxy/harness/backend.ts @@ -14,6 +14,8 @@ import { appIconPng, fixtures } from "./fixtures"; export function installFixtureBackend() { const internals = (window as unknown as { __TAURI_INTERNALS__: Record }) .__TAURI_INTERNALS__; + // For checking the window as traffic arrives: `__harnessEmit("model_write", payload)` + (window as unknown as { __harnessEmit: typeof emit }).__harnessEmit = emit; internals.transformCallback = (callback: (event: unknown) => void) => { const id = callbacks.size + 1; callbacks.set(id, callback); @@ -99,6 +101,9 @@ function matches(flow: Flow, query: string): boolean { } })(); const text = `${flow.method} ${flow.url} ${flow.status ?? ""}`.toLowerCase(); + // `OR` between groups, as the sidebar writes several rows + const groups = query.replace(/[()]/g, " ").split(/\s+OR\s+/); + if (groups.length > 1) return groups.some((g) => matches(flow, g)); return query .toLowerCase() .split(/\s+/) diff --git a/apps/yaak-proxy/lib/navigation.test.ts b/apps/yaak-proxy/lib/navigation.test.ts index 591e30f66..228b7678d 100644 --- a/apps/yaak-proxy/lib/navigation.test.ts +++ b/apps/yaak-proxy/lib/navigation.test.ts @@ -33,10 +33,20 @@ vi.mock("./rpc", () => ({ })); vi.mock("./tauri", () => ({ command: vi.fn(async () => {}), subscribe: vi.fn(() => () => {}) })); // The package's index reaches for the window as it loads; the sidebar's selection is all used. -vi.mock("@yaakapp-internal/ui", () => ({ selectedIdsFamily: () => atom([]) })); +const sidebarSelection = vi.hoisted(() => ({ atom: null as unknown })); +vi.mock("@yaakapp-internal/ui", () => ({ selectedIdsFamily: () => sidebarSelection.atom })); -const { applyFilter, goToPlace, searchDraftAtom, searchDraftVersionAtom, syncNavigation } = - await import("./navigation"); +const { + applyFilter, + goToPlace, + noteSidebarInput, + searchDraftAtom, + searchDraftVersionAtom, + syncNavigation, + syncSidebarSelection, +} = await import("./navigation"); +const { sidebarFlowsAtom, sidebarTreeAtom } = await import("./store"); +const { itemsById } = await import("./sidebarTree"); function flow(id: string): Flow { return { id } as Flow; @@ -51,6 +61,7 @@ function newStore() { const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); beforeEach(() => { + sidebarSelection.atom = atom([]); existing.clear(); recorded.length = 0; lastFilter = ""; @@ -211,3 +222,73 @@ describe("goToPlace", () => { expect(store.get(searchDraftVersionAtom)).toBe(version + 1); }); }); + +describe("syncSidebarSelection", () => { + function sidebar() { + const store = newStore(); + const flows = ["https://a.example.com/x", "https://api.github.com/y", "https://api.stripe.com"]; + store.set( + sidebarFlowsAtom, + flows.map((url, i) => ({ id: `f${i}`, url, method: "GET" }) as Flow), + ); + syncSidebarSelection(store); + const selected = sidebarSelection.atom as ReturnType>; + const idOf = (label: string) => + [...itemsById(store.get(sidebarTreeAtom)).values()].find((i) => i.label === label)?.id ?? ""; + // As a click in the sidebar does + const choose = (ids: string[]) => { + noteSidebarInput(); + store.set(selected, ids); + }; + return { store, selected, idOf, choose }; + } + + test("rows chosen together apply as one filter and stay selected", () => { + const { store, selected, idOf, choose } = sidebar(); + const [example, github] = [idOf("example.com"), idOf("github.com")]; + choose([example]); + choose([example, github]); + expect(store.get(appliedFilterAtom)).toBe("@host:*.example.com OR @host:*.github.com"); + expect(store.get(selected)).toEqual([example, github]); + }); + + test("System's heading applies its query; the other headings apply nothing", () => { + const { store, idOf, choose } = sidebar(); + store.set(sidebarFlowsAtom, [ + { + id: "s", + url: "https://ocsp.apple.com/x", + method: "GET", + clientName: "trustd", + clientPath: "/usr/libexec/trustd", + } as Flow, + ]); + choose([idOf("System")]); + expect(store.get(appliedFilterAtom)).toBe("@is:system"); + choose([idOf("Domains")]); + expect(store.get(appliedFilterAtom)).toBe("@is:system"); + }); + + test("All traffic is exclusive either way round", () => { + const { store, selected, idOf, choose } = sidebar(); + const [all, github] = [idOf("All traffic"), idOf("github.com")]; + choose([github]); + choose([github, all]); + expect(store.get(selected)).toEqual([all]); + expect(store.get(appliedFilterAtom)).toBe(""); + choose([all, github]); + expect(store.get(selected)).toEqual([github]); + expect(store.get(appliedFilterAtom)).toBe("@host:*.github.com"); + }); + + test("a box filter keeps the rows in it, and Back finds the rows again", () => { + const { store, selected, idOf, choose } = sidebar(); + const [example, github] = [idOf("example.com"), idOf("github.com")]; + choose([example]); + choose([example, github]); + applyFilter(store, "@host:*.example.com @method:get", { record: true }); + expect(store.get(selected)).toEqual([example]); + goToPlace(store, "back"); + expect(store.get(selected).sort()).toEqual([example, github].sort()); + }); +}); diff --git a/apps/yaak-proxy/lib/navigation.ts b/apps/yaak-proxy/lib/navigation.ts index bd7fdbb9d..7a7952a3d 100644 --- a/apps/yaak-proxy/lib/navigation.ts +++ b/apps/yaak-proxy/lib/navigation.ts @@ -31,8 +31,9 @@ import { ALL_ID, APPS_ID, DOMAINS_ID, + exclusiveAll, itemsById, - queryInText, + rowsForFilter, SAVED_ID, selectionText, } from "./sidebarTree"; @@ -291,45 +292,49 @@ export function syncSidebarSelection(store: Store) { const selectedAtom = selectedIdsFamily(SIDEBAR_TREE_ID); let syncing = false; - function keepRows() { - if (syncing) return; - const text = store.get(appliedFilterAtom); - const items = itemsById(store.get(sidebarTreeAtom)); + function select(next: string[]) { const ids = store.get(selectedAtom); - const kept = ids.filter((id) => { - const item = items.get(id); - return item != null && item.query !== "" && queryInText(item.query, text); - }); - const next = text.trim() === "" ? [ALL_ID] : kept; if (next.length === ids.length && next.every((id, i) => id === ids[i])) return; syncing = true; store.set(selectedAtom, next); syncing = false; } + // `add`: rows whose query is a whole group of the filter join, as when Back returns to a + // filter the rows wrote; otherwise only those selected are kept or dropped. + function keepRows(add: boolean) { + if (syncing) return; + const items = itemsById(store.get(sidebarTreeAtom)); + const next = rowsForFilter(store.get(appliedFilterAtom), store.get(selectedAtom), items, add); + select(next); + } + // 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. - let lastRows = store.get(selectedAtom).join("\n"); + let lastRows = store.get(selectedAtom); store.sub(selectedAtom, () => { + const before = lastRows; const ids = store.get(selectedAtom); - const key = ids.join("\n"); - if (key === lastRows) return; - lastRows = key; + if (ids.join("\n") === before.join("\n")) 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) { - keepRows(); + keepRows(false); return; } + const exclusive = exclusiveAll(ids, before); + if (exclusive !== ids) select(exclusive); // Group headings are not filters. - const rows = ids.filter((id) => id !== APPS_ID && id !== DOMAINS_ID && id !== SAVED_ID); + const rows = exclusive.filter((id) => id !== APPS_ID && id !== DOMAINS_ID && id !== SAVED_ID); if (rows.length === 0) return; const items = itemsById(store.get(sidebarTreeAtom)); const selected = rows.flatMap((id) => { const item = items.get(id); - return item == null ? [] : [item]; + // A heading has no query to apply; System's has its own. + return item == null || (item.query === "" && item.kind !== "all") ? [] : [item]; }); if (selected.length === 0) return; const text = selectionText(selected); @@ -338,7 +343,7 @@ export function syncSidebarSelection(store: Store) { applyFilter(store, text); syncing = false; }); - store.sub(appliedFilterAtom, keepRows); + store.sub(appliedFilterAtom, () => keepRows(true)); // A filter restored at launch selects no row; none at all is All traffic. if (store.get(selectedAtom).length === 0 && store.get(appliedFilterAtom).trim() === "") { diff --git a/apps/yaak-proxy/lib/query.test.ts b/apps/yaak-proxy/lib/query.test.ts index dea324fbf..ed66497b6 100644 --- a/apps/yaak-proxy/lib/query.test.ts +++ b/apps/yaak-proxy/lib/query.test.ts @@ -5,7 +5,9 @@ import { asOneTerm, countsBackFromNow, fieldTerm, + orGroupShapes, pathTerm, + queryShape, termKeys, widerQueries, } from "./query"; @@ -32,6 +34,22 @@ describe("terms", () => { ]); expect(termKeys("")).toEqual([]); }); + + test("come in the groups `OR` separates at the top, a parenthesised one whole", () => { + expect(orGroupShapes("@host:x.com @app:Slack OR (a OR b) OR -@is:system")).toEqual([ + "And(@app:slack,@host:x.com)", + "Or(a,b)", + "-@is:system", + ]); + expect(orGroupShapes("@app:Slack")).toEqual(["@app:slack"]); + expect(orGroupShapes(" ")).toEqual([]); + }); + + test("have a shape that keeps AND and OR apart, whatever the order or case", () => { + expect(queryShape("@app:Slack @host:x.com")).toBe(queryShape("@HOST:x.com @app:slack")); + expect(queryShape("a OR b")).not.toBe(queryShape("a b")); + expect(queryShape("")).toBeNull(); + }); }); describe("activeQuery", () => { diff --git a/apps/yaak-proxy/lib/query.ts b/apps/yaak-proxy/lib/query.ts index c0eb4021b..9064edfd9 100644 --- a/apps/yaak-proxy/lib/query.ts +++ b/apps/yaak-proxy/lib/query.ts @@ -57,6 +57,45 @@ export function nodeText(node: Node, source: string, nested = false): string { /** The terms of a query, each as written and with a `-` when it is excluded, lowercased */ export function termKeys(text: string): string[] { + const root = parseQuery(text); + return root == null ? [] : nodeKeys(root, text); +} + +/** + * A query's shape, for telling whether two say the same: its terms lowercased, with its `AND` + * and `OR` groups kept apart and their members in a set's order. Null for an empty query. + */ +export function queryShape(text: string): string | null { + const root = parseQuery(text); + return root == null ? null : shapeOf(root, text); +} + +/** + * The shape of each group `OR` separates at the top of a query: one for a query with no `OR` + * there, none for an empty one + */ +export function orGroupShapes(text: string): string[] { + const root = parseQuery(text); + if (root == null) return []; + const groups = root.type === "Or" ? root.children : [root]; + return groups.map((g) => shapeOf(g, text)); +} + +function shapeOf(node: Node, text: string): string { + switch (node.type) { + case "And": + case "Or": { + const members = [...new Set(node.children.map((c) => shapeOf(c, text)))].sort(); + return `${node.type}(${members.join(",")})`; + } + case "Not": + return `-${shapeOf(node.node, text)}`; + default: + return text.slice(node.span.start, node.span.end).toLowerCase(); + } +} + +function nodeKeys(node: Node, text: string): string[] { const keys: string[] = []; const walk = (node: Node, negated: boolean) => { switch (node.type) { @@ -73,8 +112,7 @@ export function termKeys(text: string): string[] { ); } }; - const root = parseQuery(text); - if (root != null) walk(root, false); + walk(node, false); return keys; } diff --git a/apps/yaak-proxy/lib/sidebarTree.test.ts b/apps/yaak-proxy/lib/sidebarTree.test.ts index b3a4b973f..2b48c53fc 100644 --- a/apps/yaak-proxy/lib/sidebarTree.test.ts +++ b/apps/yaak-proxy/lib/sidebarTree.test.ts @@ -3,9 +3,12 @@ import type { TreeNode } from "@yaakapp-internal/ui"; import { describe, expect, test } from "vite-plus/test"; import { parseQuery } from "@yaakapp-internal/query"; import { + ALL_ID, buildSidebarTree, + exclusiveAll, itemsById, queryInText, + rowsForFilter, type SidebarItem, selectionText, } from "./sidebarTree"; @@ -262,6 +265,52 @@ describe("selection and the search text", () => { expect(selectionText([a, all])).toBe(""); }); + test("puts a saved search with `OR` of its own in parentheses beside other rows", () => { + const saved = { query: "@status:5xx OR @status:4xx" } as SidebarItem; + const host = { query: "@host:*.github.com" } as SidebarItem; + expect(selectionText([saved, host])).toBe("(@status:5xx OR @status:4xx) OR @host:*.github.com"); + expect(selectionText([saved])).toBe("@status:5xx OR @status:4xx"); + }); + + test("All traffic is selected alone, whichever was chosen last", () => { + expect(exclusiveAll(["a", ALL_ID], ["a"])).toEqual([ALL_ID]); + expect(exclusiveAll([ALL_ID, "a"], [ALL_ID])).toEqual(["a"]); + const rows = ["a", "b"]; + expect(exclusiveAll(rows, ["a"])).toBe(rows); + expect(exclusiveAll([ALL_ID], ["a"])).toEqual([ALL_ID]); + }); + + test("a filter keeps the rows still in it, and finds those whose query is a group of it", () => { + const items = itemsById(buildSidebarTree(flows, 4)); + const github = "app:fieldwork|host:api.github.com"; + const slack = "app:Slack|site:slack.com"; + const both = selectionText([items.get(github), items.get(slack)] as SidebarItem[]); + + // Applied from the box: kept while in it, dropped once not, none added. + expect(rowsForFilter(`${both} card`, [github, slack], items, false)).toEqual([github, slack]); + expect(rowsForFilter("@app:Slack @host:*.slack.com", [github, slack], items, false)).toEqual([ + slack, + ]); + // Gone back to, the rows that wrote it are found again, from none selected. + expect(rowsForFilter(both, [], items, true).sort()).toEqual([github, slack].sort()); + // A group that is only part of a row's query finds nothing. + expect(rowsForFilter("@app:Slack", [], items, true)).not.toContain(slack); + expect(rowsForFilter(" ", [github], items, true)).toEqual([ALL_ID]); + }); + + test("a row is found only by a group that says what its query says", () => { + const either = { id: "saved:either", kind: "saved", query: "@method:GET OR @host:x.com" }; + const system = { id: "system", kind: "group", query: "@is:system" }; + const items = new Map([either, system].map((i) => [i.id, i as SidebarItem])); + // Both terms, but together rather than either + expect(rowsForFilter("@method:GET @host:x.com", [], items, true)).toEqual([]); + expect(rowsForFilter("(@method:GET OR @host:x.com) OR @app:a", [], items, true)).toEqual([ + "saved:either", + ]); + // A heading with a query of its own, as System's, is found as any row is. + expect(rowsForFilter("@is:system", [], items, true)).toEqual(["system"]); + }); + test("keeps a row selected while every term of its query is in the text", () => { expect(queryInText("@app:Slack", "@app:Slack card")).toBe(true); expect(queryInText("@app:slack", "@app:Slack")).toBe(true); diff --git a/apps/yaak-proxy/lib/sidebarTree.ts b/apps/yaak-proxy/lib/sidebarTree.ts index b8728fc0f..68ab80fcf 100644 --- a/apps/yaak-proxy/lib/sidebarTree.ts +++ b/apps/yaak-proxy/lib/sidebarTree.ts @@ -1,7 +1,15 @@ 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 { + asOneTerm, + fieldTerm, + orGroupShapes, + pathTerm, + queryShape, + siteTerm, + termKeys, +} from "./query"; import { appKeyOf, folderOf, isSystemFlow, runtimeFolderOf, sidebarPath, siteOf } from "./search"; export type SidebarKind = @@ -365,11 +373,49 @@ export function itemsById(root: TreeNode): Map return map; } -/** The search text for the selected rows: their queries, OR'd; empty for All traffic */ +/** + * The search text for the selected rows: their queries, OR'd, one with `OR` of its own in + * parentheses so it stays one group; 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 "); + return queries.length === 1 ? (queries[0] ?? "") : queries.map(asOneTerm).join(" OR "); +} + +/** + * All traffic is selected alone: chosen beside other rows it takes their place, and another row + * chosen beside it takes its. `before` is the selection as it was. + */ +export function exclusiveAll(ids: string[], before: string[]): string[] { + if (!ids.includes(ALL_ID) || ids.length === 1) return ids; + return before.includes(ALL_ID) ? ids.filter((id) => id !== ALL_ID) : [ALL_ID]; +} + +/** + * The rows a filter applied leaves selected: those selected whose query is still in it, and, + * with `add`, every row whose query is one of its `OR` groups whole, as the filter the rows + * wrote is when Back returns to it. All traffic alone for no filter. + */ +export function rowsForFilter( + text: string, + selected: string[], + items: Map, + add: boolean, +): string[] { + if (text.trim() === "") return [ALL_ID]; + const rows = selected.filter((id) => { + const item = items.get(id); + return item != null && item.query !== "" && queryInText(item.query, text); + }); + if (!add) return rows; + const groups = new Set(orGroupShapes(text)); + for (const [id, item] of items) { + if (item.query === "" || rows.includes(id)) continue; + const shape = queryShape(item.query); + if (shape != null && groups.has(shape)) rows.push(id); + } + return rows; } /** diff --git a/packages/ui/src/components/tree/Tree.tsx b/packages/ui/src/components/tree/Tree.tsx index 697dedca7..597f6e93f 100644 --- a/packages/ui/src/components/tree/Tree.tsx +++ b/packages/ui/src/components/tree/Tree.tsx @@ -28,7 +28,13 @@ import { useAtomValue, useStore } from "jotai"; import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from "./atoms"; import { type CollapsedAtom, CollapsedAtomContext } from "./context"; import type { ContextMenuRenderer, TreeNode } from "./common"; -import { closestVisibleNode, equalSubtree, getSelectedItems, hasAncestor } from "./common"; +import { + closestVisibleNode, + equalSubtree, + getSelectedItems, + hasAncestor, + tabbableSelection, +} from "./common"; import { TreeDragOverlay } from "./TreeDragOverlay"; import type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from "./TreeItem"; import type { TreeItemListProps } from "./TreeItemList"; @@ -222,9 +228,12 @@ function TreeInner( } const closest = closestVisibleNode(store, collapsedAtom, lastSelectedItem.node); - if (closest != null) { + if (closest != null && lastSelectedId != null) { const id = closest.item.id; - store.set(selectedIdsFamily(treeId), [id]); + const selected = store.get(selectedIdsFamily(treeId)); + const next = tabbableSelection(selected, lastSelectedId, id); + if (next == null) return; + store.set(selectedIdsFamily(treeId), next); store.set(focusIdsFamily(treeId), { anchorId: id, lastId: id }); } }, [selectableItems, treeId]); diff --git a/packages/ui/src/components/tree/common.test.ts b/packages/ui/src/components/tree/common.test.ts new file mode 100644 index 000000000..2b246dd41 --- /dev/null +++ b/packages/ui/src/components/tree/common.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "vite-plus/test"; +import { tabbableSelection } from "./common"; + +describe("tabbableSelection", () => { + test("leaves several selected rows alone while the last one focused is in view", () => { + // As the tree renders again, say for new counts, the selection stands. + expect(tabbableSelection(["a", "b", "c"], "b", "b")).toBeNull(); + expect(tabbableSelection(["b"], "b", "b")).toBeNull(); + }); + + test("moves to the closest row in view once the last one focused is hidden", () => { + expect(tabbableSelection(["a", "child"], "child", "parent")).toEqual(["parent"]); + }); + + test("selects the last row focused when the selection no longer holds it", () => { + expect(tabbableSelection([], "b", "b")).toEqual(["b"]); + expect(tabbableSelection(["a"], "b", "b")).toEqual(["b"]); + }); +}); diff --git a/packages/ui/src/components/tree/common.ts b/packages/ui/src/components/tree/common.ts index e03a77b20..7c4f06425 100644 --- a/packages/ui/src/components/tree/common.ts +++ b/packages/ui/src/components/tree/common.ts @@ -85,6 +85,20 @@ export function isVisibleNode( return true; } +/** + * The selection that keeps a row tabbable, from the last row focused and the closest of it still + * in view, or null to leave the selection as it is: while that row is in view and selected, a + * selection of several rows stands, rather than shrinking to it each time the tree renders. + */ +export function tabbableSelection( + selected: readonly string[], + lastId: string, + closestId: string, +): string[] | null { + if (closestId === lastId && selected.includes(lastId)) return null; + return [closestId]; +} + export function closestVisibleNode( store: JotaiStore, collapsedAtom: CollapsedAtom,