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 <noreply@anthropic.com>
This commit is contained in:
Gregory SchierandClaude Opus 5.5 authored and Stetta task tk_mwaxu5pizxqxbmzj committed 2026-11-10 11:01:54 -07:00
1 parent cc83c7cd1e
commit e0b435c416
10 files changed
+313 -29

No files matched your search

+5
View File
@@ -14,6 +14,8 @@ import { appIconPng, fixtures } from "./fixtures";
export function installFixtureBackend() { export function installFixtureBackend() {
const internals = (window as unknown as { __TAURI_INTERNALS__: Record<string, unknown> }) const internals = (window as unknown as { __TAURI_INTERNALS__: Record<string, unknown> })
.__TAURI_INTERNALS__; .__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) => { internals.transformCallback = (callback: (event: unknown) => void) => {
const id = callbacks.size + 1; const id = callbacks.size + 1;
callbacks.set(id, callback); callbacks.set(id, callback);
@@ -99,6 +101,9 @@ function matches(flow: Flow, query: string): boolean {
} }
})(); })();
const text = `${flow.method} ${flow.url} ${flow.status ?? ""}`.toLowerCase(); 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 return query
.toLowerCase() .toLowerCase()
.split(/\s+/) .split(/\s+/)
+84 -3
View File
@@ -33,10 +33,20 @@ vi.mock("./rpc", () => ({
})); }));
vi.mock("./tauri", () => ({ command: vi.fn(async () => {}), subscribe: vi.fn(() => () => {}) })); 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. // 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<string[]>([]) })); const sidebarSelection = vi.hoisted(() => ({ atom: null as unknown }));
vi.mock("@yaakapp-internal/ui", () => ({ selectedIdsFamily: () => sidebarSelection.atom }));
const { applyFilter, goToPlace, searchDraftAtom, searchDraftVersionAtom, syncNavigation } = const {
await import("./navigation"); 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 { function flow(id: string): Flow {
return { id } as Flow; return { id } as Flow;
@@ -51,6 +61,7 @@ function newStore() {
const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
beforeEach(() => { beforeEach(() => {
sidebarSelection.atom = atom<string[]>([]);
existing.clear(); existing.clear();
recorded.length = 0; recorded.length = 0;
lastFilter = ""; lastFilter = "";
@@ -211,3 +222,73 @@ describe("goToPlace", () => {
expect(store.get(searchDraftVersionAtom)).toBe(version + 1); 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<typeof atom<string[]>>;
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());
});
});
+23 -18
View File
@@ -31,8 +31,9 @@ import {
ALL_ID, ALL_ID,
APPS_ID, APPS_ID,
DOMAINS_ID, DOMAINS_ID,
exclusiveAll,
itemsById, itemsById,
queryInText, rowsForFilter,
SAVED_ID, SAVED_ID,
selectionText, selectionText,
} from "./sidebarTree"; } from "./sidebarTree";
@@ -291,45 +292,49 @@ export function syncSidebarSelection(store: Store) {
const selectedAtom = selectedIdsFamily(SIDEBAR_TREE_ID); const selectedAtom = selectedIdsFamily(SIDEBAR_TREE_ID);
let syncing = false; let syncing = false;
function keepRows() { function select(next: string[]) {
if (syncing) return;
const text = store.get(appliedFilterAtom);
const items = itemsById(store.get(sidebarTreeAtom));
const ids = store.get(selectedAtom); 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; if (next.length === ids.length && next.every((id, i) => id === ids[i])) return;
syncing = true; syncing = true;
store.set(selectedAtom, next); store.set(selectedAtom, next);
syncing = false; 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 // 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 // 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.
let lastRows = store.get(selectedAtom).join("\n"); let lastRows = store.get(selectedAtom);
store.sub(selectedAtom, () => { store.sub(selectedAtom, () => {
const before = lastRows;
const ids = store.get(selectedAtom); const ids = store.get(selectedAtom);
const key = ids.join("\n"); if (ids.join("\n") === before.join("\n")) return;
if (key === lastRows) return; lastRows = ids;
lastRows = key;
if (syncing) return; if (syncing) return;
// Not the person's, such as the tree selecting its first row as it mounts over a filter // 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. // restored at launch: the rows go back to what the filter says.
if (Date.now() - lastSidebarInput > SIDEBAR_INPUT_MS) { if (Date.now() - lastSidebarInput > SIDEBAR_INPUT_MS) {
keepRows(); keepRows(false);
return; return;
} }
const exclusive = exclusiveAll(ids, before);
if (exclusive !== ids) select(exclusive);
// Group headings are not filters. // 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; if (rows.length === 0) return;
const items = itemsById(store.get(sidebarTreeAtom)); const items = itemsById(store.get(sidebarTreeAtom));
const selected = rows.flatMap((id) => { const selected = rows.flatMap((id) => {
const item = items.get(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; if (selected.length === 0) return;
const text = selectionText(selected); const text = selectionText(selected);
@@ -338,7 +343,7 @@ export function syncSidebarSelection(store: Store) {
applyFilter(store, text); applyFilter(store, text);
syncing = false; 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. // A filter restored at launch selects no row; none at all is All traffic.
if (store.get(selectedAtom).length === 0 && store.get(appliedFilterAtom).trim() === "") { if (store.get(selectedAtom).length === 0 && store.get(appliedFilterAtom).trim() === "") {
+18
View File
@@ -5,7 +5,9 @@ import {
asOneTerm, asOneTerm,
countsBackFromNow, countsBackFromNow,
fieldTerm, fieldTerm,
orGroupShapes,
pathTerm, pathTerm,
queryShape,
termKeys, termKeys,
widerQueries, widerQueries,
} from "./query"; } from "./query";
@@ -32,6 +34,22 @@ describe("terms", () => {
]); ]);
expect(termKeys("")).toEqual([]); 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", () => { describe("activeQuery", () => {
+40 -2
View File
@@ -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 */ /** The terms of a query, each as written and with a `-` when it is excluded, lowercased */
export function termKeys(text: string): string[] { 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 keys: string[] = [];
const walk = (node: Node, negated: boolean) => { const walk = (node: Node, negated: boolean) => {
switch (node.type) { switch (node.type) {
@@ -73,8 +112,7 @@ export function termKeys(text: string): string[] {
); );
} }
}; };
const root = parseQuery(text); walk(node, false);
if (root != null) walk(root, false);
return keys; return keys;
} }
+49
View File
@@ -3,9 +3,12 @@ import type { TreeNode } from "@yaakapp-internal/ui";
import { describe, expect, test } from "vite-plus/test"; import { describe, expect, test } from "vite-plus/test";
import { parseQuery } from "@yaakapp-internal/query"; import { parseQuery } from "@yaakapp-internal/query";
import { import {
ALL_ID,
buildSidebarTree, buildSidebarTree,
exclusiveAll,
itemsById, itemsById,
queryInText, queryInText,
rowsForFilter,
type SidebarItem, type SidebarItem,
selectionText, selectionText,
} from "./sidebarTree"; } from "./sidebarTree";
@@ -262,6 +265,52 @@ describe("selection and the search text", () => {
expect(selectionText([a, all])).toBe(""); 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", () => { 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 card")).toBe(true);
expect(queryInText("@app:slack", "@app:Slack")).toBe(true); expect(queryInText("@app:slack", "@app:Slack")).toBe(true);
+49 -3
View File
@@ -1,7 +1,15 @@
import type { Flow } from "@yaakapp-internal/proxy-lib"; import type { Flow } from "@yaakapp-internal/proxy-lib";
import type { TreeNode } from "@yaakapp-internal/ui"; import type { TreeNode } from "@yaakapp-internal/ui";
import type { DecryptSubject } from "./decrypt"; 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"; import { appKeyOf, folderOf, isSystemFlow, runtimeFolderOf, sidebarPath, siteOf } from "./search";
export type SidebarKind = export type SidebarKind =
@@ -365,11 +373,49 @@ export function itemsById(root: TreeNode<SidebarItem>): Map<string, SidebarItem>
return 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 { export function selectionText(items: SidebarItem[]): string {
const queries = [...new Set(items.map((i) => i.query))]; const queries = [...new Set(items.map((i) => i.query))];
if (queries.includes("")) return ""; 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<string, SidebarItem>,
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;
} }
/** /**
+12 -3
View File
@@ -28,7 +28,13 @@ import { useAtomValue, useStore } from "jotai";
import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from "./atoms"; import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from "./atoms";
import { type CollapsedAtom, CollapsedAtomContext } from "./context"; import { type CollapsedAtom, CollapsedAtomContext } from "./context";
import type { ContextMenuRenderer, TreeNode } from "./common"; 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 { TreeDragOverlay } from "./TreeDragOverlay";
import type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from "./TreeItem"; import type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from "./TreeItem";
import type { TreeItemListProps } from "./TreeItemList"; import type { TreeItemListProps } from "./TreeItemList";
@@ -222,9 +228,12 @@ function TreeInner<T extends { id: string }>(
} }
const closest = closestVisibleNode(store, collapsedAtom, lastSelectedItem.node); const closest = closestVisibleNode(store, collapsedAtom, lastSelectedItem.node);
if (closest != null) { if (closest != null && lastSelectedId != null) {
const id = closest.item.id; 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 }); store.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
} }
}, [selectableItems, treeId]); }, [selectableItems, treeId]);
@@ -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"]);
});
});
+14
View File
@@ -85,6 +85,20 @@ export function isVisibleNode<T extends { id: string }>(
return true; 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<T extends { id: string }>( export function closestVisibleNode<T extends { id: string }>(
store: JotaiStore, store: JotaiStore,
collapsedAtom: CollapsedAtom, collapsedAtom: CollapsedAtom,