@dxos/app-graph 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/lib/chunk-graph-builder.mjs +1901 -0
  2. package/dist/lib/chunk-graph-builder.mjs.map +1 -0
  3. package/dist/lib/index.mjs +281 -0
  4. package/dist/lib/index.mjs.map +1 -0
  5. package/dist/lib/scheduler.browser.mjs +2 -0
  6. package/dist/lib/scheduler.mjs +12 -0
  7. package/dist/lib/scheduler.mjs.map +1 -0
  8. package/dist/lib/testing.mjs +28 -0
  9. package/dist/lib/testing.mjs.map +1 -0
  10. package/dist/types/src/atoms.d.ts +1 -1
  11. package/dist/types/src/atoms.d.ts.map +1 -1
  12. package/dist/types/src/graph-builder.d.ts +128 -9
  13. package/dist/types/src/graph-builder.d.ts.map +1 -1
  14. package/dist/types/src/graph.d.ts +4 -1
  15. package/dist/types/src/graph.d.ts.map +1 -1
  16. package/dist/types/src/index.d.ts +1 -0
  17. package/dist/types/src/index.d.ts.map +1 -1
  18. package/dist/types/src/node-matcher.d.ts +1 -1
  19. package/dist/types/src/node-matcher.d.ts.map +1 -1
  20. package/dist/types/src/node.d.ts +14 -2
  21. package/dist/types/src/node.d.ts.map +1 -1
  22. package/dist/types/src/path-resolution.d.ts +69 -0
  23. package/dist/types/src/path-resolution.d.ts.map +1 -0
  24. package/dist/types/src/path-resolution.test.d.ts +2 -0
  25. package/dist/types/src/path-resolution.test.d.ts.map +1 -0
  26. package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
  27. package/dist/types/src/testing/setup-graph-builder.d.ts +1 -1
  28. package/dist/types/src/testing/setup-graph-builder.d.ts.map +1 -1
  29. package/dist/types/src/util.test.d.ts +2 -0
  30. package/dist/types/src/util.test.d.ts.map +1 -0
  31. package/dist/types/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +22 -21
  33. package/src/atoms.ts +1 -1
  34. package/src/graph-builder.test.ts +21 -1
  35. package/src/graph-builder.ts +327 -58
  36. package/src/graph.test.ts +1 -1
  37. package/src/graph.ts +22 -16
  38. package/src/index.ts +1 -0
  39. package/src/node-matcher.test.ts +1 -1
  40. package/src/node-matcher.ts +1 -1
  41. package/src/node.ts +19 -2
  42. package/src/path-resolution.test.ts +520 -0
  43. package/src/path-resolution.ts +355 -0
  44. package/src/stories/EchoGraph.stories.tsx +97 -121
  45. package/src/testing/setup-graph-builder.ts +1 -1
  46. package/src/util.test.ts +85 -0
  47. package/dist/lib/neutral/chunk-J5LGTIGS.mjs +0 -10
  48. package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +0 -7
  49. package/dist/lib/neutral/chunk-YMTZ7MPW.mjs +0 -1509
  50. package/dist/lib/neutral/chunk-YMTZ7MPW.mjs.map +0 -7
  51. package/dist/lib/neutral/index.mjs +0 -40
  52. package/dist/lib/neutral/index.mjs.map +0 -7
  53. package/dist/lib/neutral/meta.json +0 -1
  54. package/dist/lib/neutral/scheduler.mjs +0 -15
  55. package/dist/lib/neutral/scheduler.mjs.map +0 -7
  56. package/dist/lib/neutral/testing/index.mjs +0 -40
  57. package/dist/lib/neutral/testing/index.mjs.map +0 -7
@@ -0,0 +1,355 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Array from 'effect/Array';
6
+ import * as Effect from 'effect/Effect';
7
+ import * as Function from 'effect/Function';
8
+ import * as Option from 'effect/Option';
9
+ import * as Record from 'effect/Record';
10
+
11
+ import { EffectEx } from '@dxos/effect';
12
+ import { EntityId, SpaceId } from '@dxos/keys';
13
+ import { log } from '@dxos/log';
14
+ import { Position } from '@dxos/util';
15
+
16
+ import * as Graph from './graph';
17
+ import * as GraphBuilder from './graph-builder';
18
+ import * as Node from './node';
19
+
20
+ /**
21
+ * A single `(prefix, id?)` pair as parsed by `@dxos/app-toolkit`'s `UrlPath.parse`. Kept as a
22
+ * plain structural type here (rather than importing `UrlPath.Pair`) because app-graph must not
23
+ * depend on app-toolkit.
24
+ */
25
+ export type UrlPair = {
26
+ key: string;
27
+ id?: string;
28
+ workspace: string;
29
+ };
30
+
31
+ /**
32
+ * A resolved pair: the index it occupied in the parsed chain, and the qualified graph node id it
33
+ * resolved to. `null` in the caller's result array means the pair didn't resolve (unknown key or
34
+ * no matching node); how an unresolved pair is surfaced is the caller's concern.
35
+ */
36
+ export type ResolvedPair = {
37
+ pairIndex: number;
38
+ nodeId: string;
39
+ };
40
+
41
+ /** The graph-path representation of a node, the reverse of a `UrlPair` (`id` is absent for singleton keys). */
42
+ export type RepresentedNode = {
43
+ key: string;
44
+ id?: string;
45
+ workspace: string;
46
+ };
47
+
48
+ /** Reserved words that can never be registered as a `urlKey`, duplicated from `UrlPath.isReservedKey`
49
+ * (rather than imported) to keep app-graph free of an app-toolkit dependency. A key declared by a
50
+ * binding — including the `anchor` and `linked` tiers — is never reserved. */
51
+ const RESERVED_URL_KEYS = new Set(['reset', 'redirect', 'not-found']);
52
+
53
+ const isReservedUrlKey = (key: string): boolean =>
54
+ RESERVED_URL_KEYS.has(key) || SpaceId.isValid(key) || EntityId.isValid(key);
55
+
56
+ /**
57
+ * Ordered `urlKey`-declaring extensions: sorted by Position then insertion order (matching
58
+ * connector-ordering semantics elsewhere in this package), with reserved-word keys dropped (each with
59
+ * a `log.warn`). A single key may legitimately be shared by more than one extension (e.g. plugin-space
60
+ * declares `collection` on both the root-collection children connector and the nested-collection
61
+ * children connector, which together address any object reachable through a space's collection tree),
62
+ * so keys are NOT deduped here — {@link buildKeyTable} groups the sharers under one key and forward
63
+ * resolution matches a node produced by any of them. Shared with {@link buildUrlKeyTable} so the
64
+ * reservation rule is expressed exactly once.
65
+ */
66
+ type UrlKeyedExtension = GraphBuilder.BuilderExtension & { url: GraphBuilder.UrlBinding };
67
+
68
+ /** Narrows to an extension that declared a URL binding, so callers need no non-null assertion. */
69
+ const isUrlKeyed = (extension: GraphBuilder.BuilderExtension): extension is UrlKeyedExtension => !!extension.url?.key;
70
+
71
+ const getKeyedExtensions = (builder: GraphBuilder.GraphBuilder): UrlKeyedExtension[] => {
72
+ const extensions = Function.pipe(Record.values(builder.getExtensions()), Array.sortBy(Position.compare));
73
+
74
+ const keyed: UrlKeyedExtension[] = [];
75
+ for (const extension of extensions) {
76
+ if (!isUrlKeyed(extension)) {
77
+ continue;
78
+ }
79
+ if (isReservedUrlKey(extension.url.key)) {
80
+ log.warn('reserved URL prefix key', { key: extension.url.key, extension: extension.id });
81
+ continue;
82
+ }
83
+ keyed.push(extension);
84
+ }
85
+ return keyed;
86
+ };
87
+
88
+ /**
89
+ * Build the global `urlKey -> extensionIds` table from the builder's current extensions. Recomputed
90
+ * on every call — cheap (a synchronous scan of already-registered extensions) and always current, so
91
+ * activating/deactivating plugins can never leave a stale table around. A key maps to the ordered list
92
+ * of every extension that declared it (usually one); forward resolution treats a node produced by any
93
+ * of them as a match for the key.
94
+ */
95
+ const buildKeyTable = (builder: GraphBuilder.GraphBuilder): Map<string, string[]> => {
96
+ const table = new Map<string, string[]>();
97
+ for (const extension of getKeyedExtensions(builder)) {
98
+ const key = extension.url.key;
99
+ const existing = table.get(key);
100
+ if (existing) {
101
+ existing.push(extension.id);
102
+ } else {
103
+ table.set(key, [extension.id]);
104
+ }
105
+ }
106
+ return table;
107
+ };
108
+
109
+ /**
110
+ * A single registered URL prefix key, in the shape `UrlPath.parse` expects. Kept as a plain
111
+ * structural type here (rather than importing `UrlPath.KeyTableEntry`) because app-graph must not
112
+ * depend on app-toolkit.
113
+ */
114
+ export type UrlKeyTableEntry = { key: string; hasId: boolean; anchor: boolean };
115
+
116
+ /**
117
+ * Build the `urlKey -> { key, hasId }` table consumed by `UrlPath.parse`, straight from the
118
+ * builder's current `urlKey`/`urlKeyHasId` declarations — the "registration, not parser" property
119
+ * the URL grammar requires. Callers (the layout url-handler) pass this to `UrlPath.parse`
120
+ * to tokenize a pathname into a pair chain.
121
+ */
122
+ export const buildUrlKeyTable = (builder: GraphBuilder.GraphBuilder): Map<string, UrlKeyTableEntry> => {
123
+ const table = new Map<string, UrlKeyTableEntry>();
124
+ // The grammar's fixed tiers are configured on the builder, not declared by any extension: the anchor
125
+ // rebases the chain, and the linked key addresses a `~<variant>` child of the preceding item.
126
+ const { anchorKey, linkedKey } = builder.urlGrammar;
127
+ if (anchorKey) {
128
+ table.set(anchorKey, { key: anchorKey, hasId: true, anchor: true });
129
+ }
130
+ if (linkedKey) {
131
+ table.set(linkedKey, { key: linkedKey, hasId: true, anchor: false });
132
+ }
133
+ for (const extension of getKeyedExtensions(builder)) {
134
+ const key = extension.url.key;
135
+ // The tokenizer's flat lookup is derived from `kind`: a singleton has no id.
136
+ const hasId = extension.url.kind !== 'singleton';
137
+ const anchor = false;
138
+ const existing = table.get(key);
139
+ if (existing && existing.hasId !== hasId) {
140
+ // Extensions that share a key must agree on their kind — the parse table has one entry per key.
141
+ // A mismatch is a declaration bug; keep the first and warn.
142
+ log.warn('conflicting kind for shared URL prefix key', { key, extension: extension.id });
143
+ continue;
144
+ }
145
+ table.set(key, { key, hasId, anchor });
146
+ }
147
+ return table;
148
+ };
149
+
150
+ /**
151
+ * Expand every ancestor prefix of a qualified node id (including the id itself), then flush once.
152
+ * Mirrors `@dxos/app-toolkit`'s `NotFound.expandPath` technique, reimplemented locally so app-graph
153
+ * doesn't depend on app-toolkit.
154
+ */
155
+ const expandAncestors = async (builder: GraphBuilder.GraphBuilder, qualifiedId: string): Promise<void> => {
156
+ const segments = qualifiedId.split('/');
157
+ for (let index = 1; index <= segments.length; index++) {
158
+ Graph.expand(builder.graph, segments.slice(0, index).join('/'), 'child');
159
+ }
160
+ await GraphBuilder.flush(builder);
161
+ };
162
+
163
+ /** An extension registered for a URL key: its path (static segments or a dynamic resolver). */
164
+ type KeyedExtension = { id: string; path: string[] | GraphBuilder.PathResolver };
165
+
166
+ /**
167
+ * Materialize a candidate qualified node id and confirm it exists: expand its ancestors, then check
168
+ * the node is known. Returns the id on success, `null` otherwise.
169
+ */
170
+ const materializeCandidate = async (
171
+ builder: GraphBuilder.GraphBuilder,
172
+ candidateId: string,
173
+ ): Promise<string | null> => {
174
+ await expandAncestors(builder, candidateId);
175
+ return Option.isSome(Graph.getNode(builder.graph, candidateId)) ? candidateId : null;
176
+ };
177
+
178
+ /**
179
+ * Resolve a single `(key, id)` pair to a qualified node id, anchored at the workspace base. Resolution
180
+ * is fully explicit — no search. Each keyed extension's `path` is one of:
181
+ * 1. Static segments (`string[]`, the preferred deterministic case): the id is the `+`-joined node
182
+ * segments *after* the path, so a fixed-depth nested shape (e.g. `db/<slug>+<id>`) resolves with
183
+ * no resolver — split the id back into segments and expand the exact path.
184
+ * 2. A dynamic {@link GraphBuilder.PathResolver} (recursive/mutable shapes, i.e. nested collections),
185
+ * whose candidate is materialized and verified the same way.
186
+ * Static paths are tried before resolvers; an unmatched pair yields `null`.
187
+ */
188
+ const resolveKeyId = async (
189
+ builder: GraphBuilder.GraphBuilder,
190
+ workspaceBaseId: string,
191
+ workspace: string,
192
+ extensions: ReadonlyArray<KeyedExtension>,
193
+ id: string,
194
+ ): Promise<string | null> => {
195
+ // 1. Static segments: an exact candidate, no search (type sections, database/inbox objects, etc.).
196
+ const idSegments = id.split(builder.urlGrammar.tailSeparator);
197
+ for (const extension of extensions) {
198
+ if (Array.isArray(extension.path)) {
199
+ const resolved = await materializeCandidate(
200
+ builder,
201
+ [workspaceBaseId, ...extension.path, ...idSegments].join('/'),
202
+ );
203
+ if (resolved) {
204
+ return resolved;
205
+ }
206
+ }
207
+ }
208
+
209
+ // 2. Dynamic resolver: the extension computes the candidate id from runtime data (self-contained
210
+ // Effect; a defect degrades to no candidate rather than crashing resolution).
211
+ for (const extension of extensions) {
212
+ if (typeof extension.path === 'function') {
213
+ const candidateId = await EffectEx.runPromise(
214
+ extension.path({ id, workspace, workspaceBaseId }).pipe(Effect.catchAllDefect(() => Effect.succeed(null))),
215
+ );
216
+ if (candidateId) {
217
+ const resolved = await materializeCandidate(builder, candidateId);
218
+ if (resolved) {
219
+ return resolved;
220
+ }
221
+ }
222
+ }
223
+ }
224
+
225
+ return null;
226
+ };
227
+
228
+ /**
229
+ * Resolve a linked pair (`<key>/<variant>`) against the item it attaches to: the linked-segment child
230
+ * (`<precedingNodeId>/~<variant>`) of `precedingNodeId`. A single expand, no BFS — a linked node is
231
+ * always a direct child of the item it attaches to. Matched by the variant (the `~`-stripped last
232
+ * segment), so it works regardless of which extension produced the node.
233
+ */
234
+ const resolveLinked = async (
235
+ builder: GraphBuilder.GraphBuilder,
236
+ precedingNodeId: string,
237
+ variant: string,
238
+ ): Promise<string | null> => {
239
+ Graph.expand(builder.graph, precedingNodeId, 'child');
240
+ await GraphBuilder.flush(builder);
241
+
242
+ const linkedSegment = `${builder.urlGrammar.linkedPrefix}${variant}`;
243
+ const match = Graph.getConnections(builder.graph, precedingNodeId, 'child').find(
244
+ (child) => child.id.slice(child.id.lastIndexOf('/') + 1) === linkedSegment,
245
+ );
246
+ return match?.id ?? null;
247
+ };
248
+
249
+ const resolveUrlAsync = async (
250
+ builder: GraphBuilder.GraphBuilder,
251
+ parsed: { workspace: string; pairs: ReadonlyArray<UrlPair> },
252
+ ): Promise<Array<ResolvedPair | null>> => {
253
+ const keyTable = buildKeyTable(builder);
254
+ const allExtensions = builder.getExtensions();
255
+ const results: Array<ResolvedPair | null> = [];
256
+ // Tracks the most recently resolved *item* node, the base for `linked` pairs — a linked pair
257
+ // always attaches to the preceding item, never to another linked pair.
258
+ let lastItemNodeId: string | undefined;
259
+
260
+ for (let pairIndex = 0; pairIndex < parsed.pairs.length; pairIndex++) {
261
+ const pair = parsed.pairs[pairIndex];
262
+
263
+ // Linked pair: resolves against the preceding item by variant, not the workspace base — the linked
264
+ // resolution tier. Produced by no extension (it is a grammar key), so it is matched before the key
265
+ // table, and it is not itself an item, so it does not become the base for a following linked pair.
266
+ if (pair.key === builder.urlGrammar.linkedKey) {
267
+ const nodeId = lastItemNodeId && pair.id ? await resolveLinked(builder, lastItemNodeId, pair.id) : null;
268
+ results.push(nodeId ? { pairIndex, nodeId } : null);
269
+ continue;
270
+ }
271
+
272
+ const extensionIdList = keyTable.get(pair.key);
273
+ if (!extensionIdList || extensionIdList.length === 0) {
274
+ log.warn('unknown URL prefix key', { key: pair.key });
275
+ results.push(null);
276
+ if (pair.id !== undefined) {
277
+ lastItemNodeId = undefined;
278
+ }
279
+ continue;
280
+ }
281
+
282
+ const workspaceBaseId = `${Node.RootId}/${pair.workspace}`;
283
+ const extensions: KeyedExtension[] = [];
284
+ for (const extensionId of extensionIdList) {
285
+ const url = allExtensions[extensionId]?.url;
286
+ if (url) {
287
+ extensions.push({ id: extensionId, path: url.path });
288
+ }
289
+ }
290
+ // A normal key addresses a node by id; an id-less singleton key (e.g. `home`) addresses a fixed node
291
+ // whose terminal segment IS the key — resolve it the same way with the key standing in for the id
292
+ // (`root/<ws>/<...path>/<key>`).
293
+ const nodeId = await resolveKeyId(builder, workspaceBaseId, pair.workspace, extensions, pair.id ?? pair.key);
294
+ results.push(nodeId ? { pairIndex, nodeId } : null);
295
+ lastItemNodeId = nodeId ?? undefined;
296
+ }
297
+
298
+ return results;
299
+ };
300
+
301
+ /**
302
+ * Resolve a parsed URL's pair chain to graph node ids, walking left to right. Resolution is fully
303
+ * explicit — each keyed extension declares either a static `urlPath` template (preferred) or a dynamic
304
+ * `resolve` Effect (data-dependent shapes); there is no generic search. Reverse mapping still uses the
305
+ * provenance the builder tracks (see `GraphBuilder.getNodeExtensionId`).
306
+ *
307
+ * An unknown key, or a key whose extension produces no matching node, yields `null` at that index;
308
+ * how a `null` is surfaced is the caller's concern. A linked pair resolves against the *preceding
309
+ * item's* node, not the raw preceding pair.
310
+ */
311
+ export const resolveUrl = (
312
+ builder: GraphBuilder.GraphBuilder,
313
+ parsed: { workspace: string; pairs: ReadonlyArray<UrlPair> },
314
+ ): Effect.Effect<Array<ResolvedPair | null>> => Effect.promise(() => resolveUrlAsync(builder, parsed));
315
+
316
+ /**
317
+ * Reverse-map a graph node id back to its `(key, id?, workspace)` representation, the inverse of
318
+ * `resolveUrl`. A linked node (a `~<variant>` segment) maps to the declared `linked` key
319
+ * with the variant as its id — independent of the producing extension, so every linked node is
320
+ * addressable. Any other node maps via its producing extension's `urlKey` (`getNodeExtensionId`);
321
+ * a node with no key-declaring producer returns `Option.none()` (unmapped — serialization skips it
322
+ * with a dev-time warning one layer up, per the design's "unmapped nodes" rule).
323
+ */
324
+ export const representNode = (builder: GraphBuilder.GraphBuilder, nodeId: string): Option.Option<RepresentedNode> => {
325
+ const segments = nodeId.split('/');
326
+ // Canonical node ids are `root/<workspace>/...`; the workspace is always the second segment.
327
+ const workspace = segments[1];
328
+ if (!workspace) {
329
+ return Option.none();
330
+ }
331
+
332
+ const lastSegment = segments[segments.length - 1];
333
+ if (lastSegment.startsWith(builder.urlGrammar.linkedPrefix)) {
334
+ // Linked node: keyed by the grammar's `linked` key, with the variant (the `~`-stripped segment) as
335
+ // its id — matched by the convention, independent of the producing extension.
336
+ const linkedKey = builder.urlGrammar.linkedKey;
337
+ if (linkedKey) {
338
+ return Option.some({ key: linkedKey, id: lastSegment.slice(builder.urlGrammar.linkedPrefix.length), workspace });
339
+ }
340
+ }
341
+
342
+ const extensionId = builder.getNodeExtensionId(nodeId);
343
+ if (!extensionId) {
344
+ return Option.none();
345
+ }
346
+ const url = builder.getExtensions()[extensionId]?.url;
347
+ if (!url) {
348
+ return Option.none();
349
+ }
350
+
351
+ // The (key, id?) representation is derived from the node id + binding (a singleton has no id; a
352
+ // resolver-backed key keeps just the object id; a static path `+`-joins the segments after the path) —
353
+ // the same derivation the builder uses to stamp `urlSegment`.
354
+ return Option.some({ ...GraphBuilder.urlRepresentation(nodeId, url, builder.urlGrammar.tailSeparator), workspace });
355
+ };
@@ -6,7 +6,7 @@ import { Atom, type Registry, RegistryContext, useAtomValue } from '@effect-atom
6
6
  import { type Meta, type StoryObj } from '@storybook/react-vite';
7
7
  import * as Function from 'effect/Function';
8
8
  import * as Option from 'effect/Option';
9
- import React, { type PropsWithChildren, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
9
+ import React, { type PropsWithChildren, useCallback, useContext, useEffect, useMemo, useState } from 'react';
10
10
 
11
11
  import { type Space, SpaceState, isSpace } from '@dxos/client/echo';
12
12
  import { Filter, Obj, Query } from '@dxos/echo';
@@ -15,7 +15,6 @@ import { random } from '@dxos/random';
15
15
  import { type Client, useClient } from '@dxos/react-client';
16
16
  import { withClientProvider } from '@dxos/react-client/testing';
17
17
  import { Icon, IconButton, Input, Select } from '@dxos/react-ui';
18
- import { Path, Tree, type TreeModel } from '@dxos/react-ui-list';
19
18
  import { withTheme } from '@dxos/react-ui/testing';
20
19
  import { getSize, mx } from '@dxos/ui-theme';
21
20
  import { safeParseInt } from '@dxos/util';
@@ -281,134 +280,111 @@ export const JsonView: Story = {
281
280
  },
282
281
  };
283
282
 
284
- export const TreeView: Story = {
285
- render: () => {
286
- const client = useClient();
287
- const registry = useContext(RegistryContext);
288
- const graph = useMemo(() => createGraph(client, registry), [client, registry]);
289
- const stateRef = useRef(new Map<string, Atom.Writable<{ open: boolean; current: boolean }>>());
290
-
291
- const getOrCreateState = useMemo(
292
- () => (pathKey: string) => {
293
- let atom = stateRef.current.get(pathKey);
294
- if (!atom) {
295
- atom = Atom.make({ open: true, current: false }).pipe(Atom.keepAlive);
296
- stateRef.current.set(pathKey, atom);
297
- }
298
- return atom;
299
- },
300
- [],
301
- );
302
-
303
- const childIdsFamily = useMemo(
304
- () =>
305
- Atom.family((id: string) =>
306
- Atom.make((get) => {
307
- const connections = get(graph.connections(id, 'child'));
308
- return connections.map((connection) => connection.id);
309
- }),
310
- ),
311
- [graph],
312
- );
283
+ /**
284
+ * One row of {@link GraphTree}. Subscribes to just its own node and child list, so a mutation anywhere in
285
+ * the graph re-renders only the rows it actually touches — which is the behaviour this story exists to show.
286
+ */
287
+ const GraphTreeItem = ({
288
+ graph,
289
+ id,
290
+ ancestors,
291
+ selectedId,
292
+ onSelect,
293
+ }: {
294
+ graph: Graph.ExpandableGraph;
295
+ id: string;
296
+ ancestors: readonly string[];
297
+ selectedId?: string;
298
+ onSelect: (id: string) => void;
299
+ }) => {
300
+ const [open, setOpen] = useState(true);
301
+ const node = Option.getOrUndefined(useAtomValue(graph.node(id)));
302
+ const children = useAtomValue(graph.connections(id, 'child'));
303
+
304
+ // The graph may be cyclic; recursing into an id already on the path would never terminate.
305
+ const path = useMemo(() => [...ancestors, id], [ancestors, id]);
306
+ const safeChildren = useMemo(() => children.filter((child) => !path.includes(child.id)), [children, path]);
307
+
308
+ const icon = node?.type === 'org.dxos.type.space' ? 'ph--planet--regular' : 'ph--circle-dashed--regular';
309
+ const expandable = safeChildren.length > 0;
313
310
 
314
- const itemFamily = useMemo(
315
- () =>
316
- Atom.family((id: string) =>
317
- Atom.make((get) => {
318
- const node = get(graph.node(id));
319
- return Option.isSome(node) ? node.value : undefined;
320
- }),
321
- ),
322
- [graph],
323
- );
324
-
325
- const itemPropsFamily = useMemo(
326
- () =>
327
- Atom.family((pathKey: string) => {
328
- const path = pathKey.split('~');
329
- const id = path[path.length - 1];
330
- return Atom.make((get) => {
331
- const nodeOpt = get(graph.node(id));
332
- const node = Option.isSome(nodeOpt) ? nodeOpt.value : undefined;
333
- if (!node) {
334
- return { id, label: id };
335
- }
336
- const connections = get(graph.connections(node.id, 'child'));
337
- const safeChildren = connections.filter((n) => !path.includes(n.id));
338
- const parentOf =
339
- safeChildren.length > 0
340
- ? safeChildren.map(({ id }) => id)
341
- : node.properties.role === 'branch'
342
- ? []
343
- : undefined;
344
- return {
345
- id: node.id,
346
- label: node.id,
347
- icon: node.type === 'org.dxos.type.space' ? 'ph--planet--regular' : 'ph--circle-dashed--regular',
348
- parentOf,
349
- };
350
- });
351
- }),
352
- [graph],
353
- );
354
-
355
- const itemOpenFamily = useMemo(
356
- () =>
357
- Atom.family((pathKey: string) => {
358
- const stateAtom = getOrCreateState(pathKey);
359
- return Atom.make((get) => get(stateAtom).open);
360
- }),
361
- [getOrCreateState],
362
- );
311
+ return (
312
+ <div role='treeitem' aria-expanded={expandable ? open : undefined}>
313
+ <div
314
+ className={mx(
315
+ 'flex items-center gap-1 p-1 rounded cursor-pointer',
316
+ selectedId === id && 'bg-primary-500 text-primary-100',
317
+ )}
318
+ style={{ paddingInlineStart: `${ancestors.length}rem` }}
319
+ onClick={() => onSelect(id)}
320
+ >
321
+ {expandable ? (
322
+ <IconButton
323
+ iconOnly
324
+ variant='ghost'
325
+ density='sm'
326
+ icon={open ? 'ph--caret-down--regular' : 'ph--caret-right--regular'}
327
+ label={open ? 'Collapse' : 'Expand'}
328
+ onClick={(event) => {
329
+ // Toggling disclosure must not also select the row.
330
+ event.stopPropagation();
331
+ setOpen((open) => !open);
332
+ }}
333
+ />
334
+ ) : (
335
+ <Icon icon='ph--dot--regular' classNames={getSize(4)} />
336
+ )}
337
+ <Icon icon={icon} classNames={getSize(4)} />
338
+ <span className='truncate'>{node?.id ?? id}</span>
339
+ </div>
340
+ {expandable && open && (
341
+ <div role='group'>
342
+ {safeChildren.map((child) => (
343
+ <GraphTreeItem
344
+ key={child.id}
345
+ graph={graph}
346
+ id={child.id}
347
+ ancestors={path}
348
+ selectedId={selectedId}
349
+ onSelect={onSelect}
350
+ />
351
+ ))}
352
+ </div>
353
+ )}
354
+ </div>
355
+ );
356
+ };
363
357
 
364
- const itemCurrentFamily = useMemo(
365
- () =>
366
- Atom.family((pathKey: string) => {
367
- const stateAtom = getOrCreateState(pathKey);
368
- return Atom.make((get) => get(stateAtom).current);
369
- }),
370
- [getOrCreateState],
371
- );
358
+ const NO_ANCESTORS: readonly string[] = [];
372
359
 
373
- const model: TreeModel<Node.Node> = useMemo(
374
- () => ({
375
- childIds: (parentId?: string) => childIdsFamily(parentId ?? Node.RootId),
376
- item: (id: string) => itemFamily(id),
377
- itemProps: (path: string[]) => itemPropsFamily(path.join('~')),
378
- itemOpen: (path: string[]) => itemOpenFamily(Path.create(...path)),
379
- itemCurrent: (path: string[]) => itemCurrentFamily(Path.create(...path)),
380
- }),
381
- [childIdsFamily, itemFamily, itemPropsFamily, itemOpenFamily, itemCurrentFamily],
382
- );
360
+ /** Minimal tree view over the graph, built from `@dxos/react-ui` primitives only. */
361
+ const GraphTree = ({ graph }: { graph: Graph.ExpandableGraph }) => {
362
+ const [selectedId, setSelectedId] = useState<string>();
363
+ const onSelect = useCallback((id: string) => setSelectedId((current) => (current === id ? undefined : id)), []);
383
364
 
384
- const onOpenChange = useCallback(
385
- ({ path: _path, open }: { path: string[]; open: boolean }) => {
386
- const path = Path.create(..._path);
387
- const atom = stateRef.current.get(path);
388
- if (atom) {
389
- const prev = registry.get(atom);
390
- registry.set(atom, { ...prev, open });
391
- }
392
- },
393
- [registry],
394
- );
365
+ return (
366
+ <div role='tree' className='p-2 overflow-auto'>
367
+ <GraphTreeItem
368
+ graph={graph}
369
+ id={Node.RootId}
370
+ ancestors={NO_ANCESTORS}
371
+ selectedId={selectedId}
372
+ onSelect={onSelect}
373
+ />
374
+ </div>
375
+ );
376
+ };
395
377
 
396
- const onSelect = useCallback(
397
- ({ path: _path, current }: { path: string[]; current: boolean }) => {
398
- const path = Path.create(..._path);
399
- const atom = stateRef.current.get(path);
400
- if (atom) {
401
- const prev = registry.get(atom);
402
- registry.set(atom, { ...prev, current });
403
- }
404
- },
405
- [registry],
406
- );
378
+ export const TreeView: Story = {
379
+ render: () => {
380
+ const client = useClient();
381
+ const registry = useContext(RegistryContext);
382
+ const graph = useMemo(() => createGraph(client, registry), [client, registry]);
407
383
 
408
384
  return (
409
385
  <>
410
386
  <Controls />
411
- <Tree model={model} id={Node.RootId} onOpenChange={onOpenChange} onSelect={onSelect} />
387
+ <GraphTree graph={graph} />
412
388
  </>
413
389
  );
414
390
  },
@@ -2,7 +2,7 @@
2
2
  // Copyright 2026 DXOS.org
3
3
  //
4
4
 
5
- import { Registry } from '@effect-atom/atom-react';
5
+ import { Registry } from '@effect-atom/atom';
6
6
  import * as Option from 'effect/Option';
7
7
 
8
8
  import * as Graph from '../graph';