From e0b435c416dd6cdf822625f9c14b6f2000214c6e Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 10 Nov 2026 11:01:54 -0700 Subject: [PATCH] Keep a multi-row sidebar selection through new traffic, and restore rows on Back The shared Tree reset its selection to the last focused row after every render, so several selected rows collapsed to one whenever the tree rendered again, as new traffic changing the counts makes it. It now leaves the selection alone while that row is still in view and selected. The proxy's sidebar sync also makes All traffic exclusive, groups a saved search's own OR in parentheses beside other rows, applies System's heading query, and on Back or Forward selects the rows whose queries make up the restored filter, compared by shape so AND and OR stay apart. Co-Authored-By: Claude Opus 5.5 --- apps/yaak-proxy/harness/backend.ts | 5 ++ apps/yaak-proxy/lib/navigation.test.ts | 87 ++++++++++++++++++- apps/yaak-proxy/lib/navigation.ts | 41 +++++---- apps/yaak-proxy/lib/query.test.ts | 18 ++++ apps/yaak-proxy/lib/query.ts | 42 ++++++++- apps/yaak-proxy/lib/sidebarTree.test.ts | 49 +++++++++++ apps/yaak-proxy/lib/sidebarTree.ts | 52 ++++++++++- packages/ui/src/components/tree/Tree.tsx | 15 +++- .../ui/src/components/tree/common.test.ts | 19 ++++ packages/ui/src/components/tree/common.ts | 14 +++ 10 files changed, 313 insertions(+), 29 deletions(-) create mode 100644 packages/ui/src/components/tree/common.test.ts 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,