@dxos/app-graph 0.8.4-main.72ec0f3 → 0.8.4-main.74a063c4e0

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 (60) hide show
  1. package/dist/lib/browser/chunk-AKBGYELG.mjs +1603 -0
  2. package/dist/lib/browser/chunk-AKBGYELG.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +29 -827
  4. package/dist/lib/browser/index.mjs.map +4 -4
  5. package/dist/lib/browser/meta.json +1 -1
  6. package/dist/lib/browser/testing/index.mjs +39 -0
  7. package/dist/lib/browser/testing/index.mjs.map +7 -0
  8. package/dist/lib/node-esm/chunk-HR5S4XYH.mjs +1604 -0
  9. package/dist/lib/node-esm/chunk-HR5S4XYH.mjs.map +7 -0
  10. package/dist/lib/node-esm/index.mjs +29 -828
  11. package/dist/lib/node-esm/index.mjs.map +4 -4
  12. package/dist/lib/node-esm/meta.json +1 -1
  13. package/dist/lib/node-esm/testing/index.mjs +40 -0
  14. package/dist/lib/node-esm/testing/index.mjs.map +7 -0
  15. package/dist/types/src/atoms.d.ts +8 -0
  16. package/dist/types/src/atoms.d.ts.map +1 -0
  17. package/dist/types/src/graph-builder.d.ts +111 -65
  18. package/dist/types/src/graph-builder.d.ts.map +1 -1
  19. package/dist/types/src/graph.d.ts +179 -213
  20. package/dist/types/src/graph.d.ts.map +1 -1
  21. package/dist/types/src/index.d.ts +7 -3
  22. package/dist/types/src/index.d.ts.map +1 -1
  23. package/dist/types/src/node-matcher.d.ts +244 -0
  24. package/dist/types/src/node-matcher.d.ts.map +1 -0
  25. package/dist/types/src/node-matcher.test.d.ts +2 -0
  26. package/dist/types/src/node-matcher.test.d.ts.map +1 -0
  27. package/dist/types/src/node.d.ts +50 -5
  28. package/dist/types/src/node.d.ts.map +1 -1
  29. package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
  30. package/dist/types/src/testing/index.d.ts +2 -0
  31. package/dist/types/src/testing/index.d.ts.map +1 -0
  32. package/dist/types/src/testing/setup-graph-builder.d.ts +31 -0
  33. package/dist/types/src/testing/setup-graph-builder.d.ts.map +1 -0
  34. package/dist/types/src/util.d.ts +39 -0
  35. package/dist/types/src/util.d.ts.map +1 -0
  36. package/dist/types/tsconfig.tsbuildinfo +1 -1
  37. package/package.json +47 -35
  38. package/src/atoms.ts +25 -0
  39. package/src/graph-builder.test.ts +995 -112
  40. package/src/graph-builder.ts +705 -284
  41. package/src/graph.test.ts +448 -120
  42. package/src/graph.ts +1019 -413
  43. package/src/index.ts +10 -3
  44. package/src/node-matcher.test.ts +301 -0
  45. package/src/node-matcher.ts +314 -0
  46. package/src/node.ts +82 -8
  47. package/src/stories/EchoGraph.stories.tsx +151 -121
  48. package/src/stories/Tree.tsx +2 -2
  49. package/src/testing/index.ts +5 -0
  50. package/src/testing/setup-graph-builder.ts +41 -0
  51. package/src/util.ts +95 -0
  52. package/dist/types/src/experimental/graph-projections.test.d.ts +0 -25
  53. package/dist/types/src/experimental/graph-projections.test.d.ts.map +0 -1
  54. package/dist/types/src/signals-integration.test.d.ts +0 -2
  55. package/dist/types/src/signals-integration.test.d.ts.map +0 -1
  56. package/dist/types/src/testing.d.ts +0 -5
  57. package/dist/types/src/testing.d.ts.map +0 -1
  58. package/src/experimental/graph-projections.test.ts +0 -56
  59. package/src/signals-integration.test.ts +0 -219
  60. package/src/testing.ts +0 -20
package/src/node.ts CHANGED
@@ -2,9 +2,30 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { type MakeOptional, type MaybePromise } from '@dxos/util';
5
+ import type * as Context from 'effect/Context';
6
+ import type * as Effect from 'effect/Effect';
6
7
 
7
- import { ACTION_GROUP_TYPE, ACTION_TYPE } from './graph';
8
+ import { type MakeOptional } from '@dxos/util';
9
+
10
+ /**
11
+ * Root node ID.
12
+ */
13
+ export const RootId = 'root';
14
+
15
+ /**
16
+ * Root node type.
17
+ */
18
+ export const RootType = 'org.dxos.type.graphRoot';
19
+
20
+ /**
21
+ * Action node type.
22
+ */
23
+ export const ActionType = 'org.dxos.type.graphAction';
24
+
25
+ /**
26
+ * Action group node type.
27
+ */
28
+ export const ActionGroupType = 'org.dxos.type.graphActionGroup';
8
29
 
9
30
  /**
10
31
  * Represents a node in the graph.
@@ -48,7 +69,19 @@ export type NodeFilter<TData = any, TProperties extends Record<string, any> = Re
48
69
  connectedNode: Node,
49
70
  ) => node is Node<TData, TProperties>;
50
71
 
51
- export type Relation = 'outbound' | 'inbound';
72
+ export type RelationDirection = 'outbound' | 'inbound';
73
+
74
+ export type Relation = Readonly<{
75
+ kind: string;
76
+ direction: RelationDirection;
77
+ }>;
78
+
79
+ export type RelationInput = Relation | string;
80
+
81
+ export const relation = (kind: string, direction: RelationDirection = 'outbound'): Relation => ({ kind, direction });
82
+ // TODO(wittjosiah): Consider moving these helpers out of the core API.
83
+ export const childRelation = (direction: RelationDirection = 'outbound'): Relation => relation('child', direction);
84
+ export const actionRelation = (direction: RelationDirection = 'outbound'): Relation => relation('action', direction);
52
85
 
53
86
  export const isGraphNode = (data: unknown): data is Node =>
54
87
  data && typeof data === 'object' && 'id' in data && 'properties' in data && data.properties
@@ -63,30 +96,45 @@ export type NodeArg<TData, TProperties extends Record<string, any> = Record<stri
63
96
  nodes?: NodeArg<unknown>[];
64
97
 
65
98
  /** Will automatically add specified edges. */
66
- edges?: [string, Relation][];
99
+ edges?: [string, RelationInput][];
67
100
  };
68
101
 
69
102
  //
70
103
  // Actions
71
104
  //
72
105
 
73
- export type InvokeParams = {
106
+ export type InvokeProps = {
74
107
  /** Node the invoked action is connected to. */
75
108
  parent?: Node;
76
109
 
110
+ /** Path from root to the node in the current tree context. */
111
+ path?: string[];
112
+
77
113
  caller?: string;
78
114
  };
79
115
 
80
- export type ActionData = (params?: InvokeParams) => MaybePromise<void>;
116
+ /**
117
+ * Action data is an Effect-returning function.
118
+ * The Effect is provided with captured context at execution time.
119
+ */
120
+ export type ActionData<R = never> = (params?: InvokeProps) => Effect.Effect<any, Error, R>;
121
+
122
+ /**
123
+ * Context captured at extension creation time.
124
+ * Automatically provided to action Effects at execution.
125
+ */
126
+ export type ActionContext = Context.Context<any>;
81
127
 
82
128
  export type Action<TProperties extends Record<string, any> = Record<string, any>> = Readonly<
83
129
  Omit<Node<ActionData, TProperties>, 'properties'> & {
84
130
  properties: Readonly<TProperties>;
131
+ /** Captured context from extension creation. Provided automatically at action execution. */
132
+ _actionContext?: ActionContext;
85
133
  }
86
134
  >;
87
135
 
88
136
  export const isAction = (data: unknown): data is Action =>
89
- isGraphNode(data) ? typeof data.data === 'function' && data.type === ACTION_TYPE : false;
137
+ isGraphNode(data) ? typeof data.data === 'function' && data.type === ActionType : false;
90
138
 
91
139
  export const actionGroupSymbol = Symbol('ActionGroup');
92
140
 
@@ -97,8 +145,34 @@ export type ActionGroup<TProperties extends Record<string, any> = Record<string,
97
145
  >;
98
146
 
99
147
  export const isActionGroup = (data: unknown): data is ActionGroup =>
100
- isGraphNode(data) ? data.data === actionGroupSymbol && data.type === ACTION_GROUP_TYPE : false;
148
+ isGraphNode(data) ? data.data === actionGroupSymbol && data.type === ActionGroupType : false;
101
149
 
102
150
  export type ActionLike = Action | ActionGroup;
103
151
 
104
152
  export const isActionLike = (data: unknown): data is Action | ActionGroup => isAction(data) || isActionGroup(data);
153
+
154
+ //
155
+ // Node Factories
156
+ //
157
+
158
+ /** Typed factory for constructing a NodeArg. Provides auto-complete and type validation. */
159
+ export const make = <TData = any, TProperties extends Record<string, any> = Record<string, any>>(
160
+ arg: NodeArg<TData, TProperties>,
161
+ ): NodeArg<TData, TProperties> => arg;
162
+
163
+ /** Create an action node. Automatically sets `type: ActionType`. */
164
+ export const makeAction = <R = never>(
165
+ arg: Omit<NodeArg<ActionData<R>>, 'type' | 'nodes' | 'edges'>,
166
+ ): NodeArg<ActionData<R>> => ({
167
+ ...arg,
168
+ type: ActionType,
169
+ });
170
+
171
+ /** Create an action group node. Automatically sets `type` and `data`. */
172
+ export const makeActionGroup = (
173
+ arg: Omit<NodeArg<typeof actionGroupSymbol>, 'type' | 'data' | 'nodes' | 'edges'>,
174
+ ): NodeArg<typeof actionGroupSymbol> => ({
175
+ ...arg,
176
+ type: ActionGroupType,
177
+ data: actionGroupSymbol,
178
+ });
@@ -6,34 +6,25 @@ 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, useState } from 'react';
10
-
11
- import {
12
- Expando,
13
- Filter,
14
- type Live,
15
- Query,
16
- type QueryResult,
17
- type Space,
18
- SpaceState,
19
- isSpace,
20
- live,
21
- } from '@dxos/client/echo';
22
- import { Obj, Type } from '@dxos/echo';
23
- import { faker } from '@dxos/random';
9
+ import React, { type PropsWithChildren, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
10
+
11
+ import { Filter, type Space, SpaceState, isSpace } from '@dxos/client/echo';
12
+ import { Obj, Query } from '@dxos/echo';
13
+ import { AtomObj, AtomQuery } from '@dxos/echo-atom';
14
+ import { TestSchema } from '@dxos/echo/testing';
15
+ import { random } from '@dxos/random';
24
16
  import { type Client, useClient } from '@dxos/react-client';
25
17
  import { withClientProvider } from '@dxos/react-client/testing';
26
18
  import { Icon, IconButton, Input, Select } from '@dxos/react-ui';
19
+ import { Path, Tree, type TreeModel } from '@dxos/react-ui-list';
27
20
  import { withTheme } from '@dxos/react-ui/testing';
28
- import { Path, Tree } from '@dxos/react-ui-list';
29
- import { getSize, mx } from '@dxos/react-ui-theme';
30
- import { byPosition, isNonNullable, safeParseInt } from '@dxos/util';
31
-
32
- import { type ExpandableGraph, ROOT_ID } from '../graph';
33
- import { GraphBuilder, atomFromObservable, createExtension } from '../graph-builder';
34
- import { type Node } from '../node';
35
- import { atomFromQuery } from '../testing';
21
+ import { getSize, mx } from '@dxos/ui-theme';
22
+ import { safeParseInt } from '@dxos/util';
36
23
 
24
+ import * as CreateAtom from '../atoms';
25
+ import * as Graph from '../graph';
26
+ import * as GraphBuilder from '../graph-builder';
27
+ import * as Node from '../node';
37
28
  import { JsonTree } from './Tree';
38
29
 
39
30
  const DEFAULT_PERIOD = 500;
@@ -56,48 +47,47 @@ const actionWeights = {
56
47
  [Action.RENAME_OBJECT]: 4,
57
48
  };
58
49
 
59
- const createGraph = (client: Client, registry: Registry.Registry): ExpandableGraph => {
60
- const spaceBuilderExtension = createExtension({
50
+ const createGraph = (client: Client, registry: Registry.Registry): Graph.ExpandableGraph => {
51
+ const spaceBuilderExtension = GraphBuilder.createExtensionRaw({
61
52
  id: 'space',
62
53
  connector: (node) =>
63
54
  Atom.make((get) =>
64
55
  Function.pipe(
65
56
  get(node),
66
- Option.flatMap((node) => (node.id === ROOT_ID ? Option.some(node) : Option.none())),
57
+ Option.flatMap((node) => (node.id === Node.RootId ? Option.some(node) : Option.none())),
67
58
  Option.map(() => {
68
- const spaces = get(atomFromObservable(client.spaces)) ?? [];
59
+ const spaces = get(CreateAtom.fromObservable(client.spaces)) ?? [];
69
60
  return spaces
70
- .filter((space) => get(atomFromObservable(space.state)) === SpaceState.SPACE_READY)
71
- .map((space) => ({
72
- id: space.id,
73
- type: 'dxos.org/type/Space',
74
- properties: {
75
- label: get(atomFromObservable(space.properties.name)),
76
- },
77
- data: space,
78
- }));
61
+ .filter((space: any) => get(CreateAtom.fromObservable(space.state)) === SpaceState.SPACE_READY)
62
+ .map((space) => {
63
+ const propertiesSnapshot = get(AtomObj.make(space.properties));
64
+ return {
65
+ id: space.id,
66
+ type: 'org.dxos.type.space',
67
+ properties: {
68
+ label: propertiesSnapshot.name,
69
+ },
70
+ data: space,
71
+ };
72
+ });
79
73
  }),
80
74
  Option.getOrElse(() => []),
81
75
  ),
82
76
  ),
83
77
  });
84
78
 
85
- const objectBuilderExtension = createExtension({
79
+ const objectBuilderExtension = GraphBuilder.createExtensionRaw({
86
80
  id: 'object',
87
81
  connector: (node) => {
88
- let query: QueryResult<Live<Expando>> | undefined;
89
82
  return Atom.make((get) =>
90
83
  Function.pipe(
91
84
  get(node),
92
85
  Option.flatMap((node) => (isSpace(node.data) ? Option.some(node.data) : Option.none())),
93
86
  Option.map((space) => {
94
- if (!query) {
95
- query = space.db.query(Query.type(Expando, { type: 'test' }));
96
- }
97
- const objects = get(atomFromQuery(query));
87
+ const objects = get(AtomQuery.make(space.db, Query.type(TestSchema.Expando, { type: 'test' })));
98
88
  return objects.map((object) => ({
99
89
  id: object.id,
100
- type: 'dxos.org/type/test',
90
+ type: 'org.dxos.type.test',
101
91
  properties: { label: object.name },
102
92
  data: object,
103
93
  }));
@@ -108,15 +98,15 @@ const createGraph = (client: Client, registry: Registry.Registry): ExpandableGra
108
98
  },
109
99
  });
110
100
 
111
- const graph = new GraphBuilder({ registry })
112
- .addExtension(spaceBuilderExtension)
113
- .addExtension(objectBuilderExtension).graph;
101
+ const builder = GraphBuilder.make({ registry });
102
+ GraphBuilder.addExtension(builder, spaceBuilderExtension);
103
+ GraphBuilder.addExtension(builder, objectBuilderExtension);
104
+ const graph = builder.graph;
114
105
  graph.onNodeChanged.on(({ id }) => {
115
- graph.expand(id);
106
+ Graph.expand(graph, id, 'child');
116
107
  });
117
- graph.expand(ROOT_ID);
108
+ Graph.expand(graph, Node.RootId, 'child');
118
109
  (window as any).graph = graph;
119
-
120
110
  return graph;
121
111
  };
122
112
 
@@ -136,9 +126,9 @@ const getRandomSpace = (client: Client): Space | undefined => {
136
126
  const getSpaceWithObjects = async (client: Client): Promise<Space | undefined> => {
137
127
  const readySpaces = client.spaces.get().filter((space) => space.state.get() === SpaceState.SPACE_READY);
138
128
  const spaceQueries = await Promise.all(
139
- readySpaces.map((space) => space.db.query(Filter.type(Expando, { type: 'test' })).run()),
129
+ readySpaces.map((space) => space.db.query(Filter.type(TestSchema.Expando, { type: 'test' })).run()),
140
130
  );
141
- const spaces = readySpaces.filter((space, index) => spaceQueries[index].objects.length > 0);
131
+ const spaces = readySpaces.filter((space, index) => spaceQueries[index].length > 0);
142
132
  return spaces[Math.floor(Math.random() * spaces.length)];
143
133
  };
144
134
 
@@ -155,16 +145,18 @@ const runAction = async (client: Client, action: Action) => {
155
145
  case Action.RENAME_SPACE: {
156
146
  const space = getRandomSpace(client);
157
147
  if (space) {
158
- space.properties.name = faker.commerce.productName();
148
+ Obj.change(space.properties, (obj) => {
149
+ obj.name = random.commerce.productName();
150
+ });
159
151
  }
160
152
  break;
161
153
  }
162
154
 
163
155
  case Action.ADD_OBJECT:
164
156
  getRandomSpace(client)?.db.add(
165
- Obj.make(Type.Expando, {
157
+ Obj.make(TestSchema.Expando, {
166
158
  type: 'test',
167
- name: faker.commerce.productName(),
159
+ name: random.commerce.productName(),
168
160
  }),
169
161
  );
170
162
  break;
@@ -172,7 +164,7 @@ const runAction = async (client: Client, action: Action) => {
172
164
  case Action.REMOVE_OBJECT: {
173
165
  const space = await getSpaceWithObjects(client);
174
166
  if (space) {
175
- const { objects } = await space.db.query(Filter.type(Expando, { type: 'test' })).run();
167
+ const objects = await space.db.query(Filter.type(TestSchema.Expando, { type: 'test' })).run();
176
168
  space.db.remove(objects[Math.floor(Math.random() * objects.length)]);
177
169
  }
178
170
  break;
@@ -181,8 +173,11 @@ const runAction = async (client: Client, action: Action) => {
181
173
  case Action.RENAME_OBJECT: {
182
174
  const space = await getSpaceWithObjects(client);
183
175
  if (space) {
184
- const { objects } = await space.db.query(Filter.type(Expando, { type: 'test' })).run();
185
- objects[Math.floor(Math.random() * objects.length)].name = faker.commerce.productName();
176
+ const objects = await space.db.query(Filter.type(TestSchema.Expando, { type: 'test' })).run();
177
+ const object = objects[Math.floor(Math.random() * objects.length)];
178
+ Obj.change(object, (obj) => {
179
+ obj.name = random.commerce.productName();
180
+ });
186
181
  }
187
182
  break;
188
183
  }
@@ -220,14 +215,13 @@ const Controls = ({ children }: PropsWithChildren) => {
220
215
  <Input.Root>
221
216
  <Input.TextInput
222
217
  autoComplete='off'
223
- size={5}
224
- classNames='is-[100px] text-right pie-[22px]'
218
+ classNames='w-[100px] text-right pe-[22px]'
225
219
  placeholder='Interval'
226
220
  value={actionInterval}
227
221
  onChange={({ target: { value } }) => setActionInterval(value)}
228
222
  />
229
223
  </Input.Root>
230
- <Icon icon='ph--timer--regular' classNames={mx('absolute inline-end-1 block-start-1 mt-[6px]', getSize(3))} />
224
+ <Icon icon='ph--timer--regular' classNames={mx('absolute right-1 top-1 mt-[6px]', getSize(3))} />
231
225
  </div>
232
226
  <IconButton icon='ph--plus--regular' label='Add' onClick={() => action && runAction(client, action)} />
233
227
  <Select.Root value={action?.toString()} onValueChange={(action) => setAction(action as unknown as Action)}>
@@ -256,9 +250,10 @@ const Controls = ({ children }: PropsWithChildren) => {
256
250
  const meta = {
257
251
  title: 'sdk/app-graph/EchoGraph',
258
252
  decorators: [
259
- withTheme,
253
+ withTheme(),
260
254
  withClientProvider({
261
255
  createIdentity: true,
256
+ types: [TestSchema.Expando],
262
257
  onCreateIdentity: async ({ client }) => {
263
258
  await client.spaces.create();
264
259
  await client.spaces.create();
@@ -292,94 +287,129 @@ export const TreeView: Story = {
292
287
  const client = useClient();
293
288
  const registry = useContext(RegistryContext);
294
289
  const graph = useMemo(() => createGraph(client, registry), [client, registry]);
295
- const state = useMemo(() => new Map<string, Live<{ open: boolean; current: boolean }>>(), []);
296
-
297
- const useItems = useCallback(
298
- (node?: Node, options?: { disposition?: string; sort?: boolean }) => {
299
- const connections = useAtomValue(graph.connections(node?.id ?? ROOT_ID));
300
- return options?.sort ? connections.toSorted((a, b) => byPosition(a.properties, b.properties)) : connections;
290
+ const stateRef = useRef(new Map<string, Atom.Writable<{ open: boolean; current: boolean }>>());
291
+
292
+ const getOrCreateState = useMemo(
293
+ () => (pathKey: string) => {
294
+ let atom = stateRef.current.get(pathKey);
295
+ if (!atom) {
296
+ atom = Atom.make({ open: true, current: false }).pipe(Atom.keepAlive);
297
+ stateRef.current.set(pathKey, atom);
298
+ }
299
+ return atom;
301
300
  },
301
+ [],
302
+ );
303
+
304
+ const childIdsFamily = useMemo(
305
+ () =>
306
+ Atom.family((id: string) =>
307
+ Atom.make((get) => {
308
+ const connections = get(graph.connections(id, 'child'));
309
+ return connections.map((connection) => connection.id);
310
+ }),
311
+ ),
302
312
  [graph],
303
313
  );
304
314
 
305
- const getProps = useCallback(
306
- (node: Node, path: string[]) => {
307
- const children = graph
308
- .getConnections(node.id, 'outbound')
309
- .map((n) => {
310
- // Break cycles.
311
- const nextPath = [...path, node.id];
312
- return nextPath.includes(n.id) ? undefined : (n as Node);
313
- })
314
- .filter(isNonNullable) as Node[];
315
- const parentOf =
316
- children.length > 0 ? children.map(({ id }) => id) : node.properties.role === 'branch' ? [] : undefined;
317
- return {
318
- id: node.id,
319
- label: node.id,
320
- icon: node.type === 'dxos.org/type/Space' ? 'ph--planet--regular' : 'ph--placeholder--regular',
321
- parentOf,
322
- };
323
- },
315
+ const itemFamily = useMemo(
316
+ () =>
317
+ Atom.family((id: string) =>
318
+ Atom.make((get) => {
319
+ const node = get(graph.node(id));
320
+ return Option.isSome(node) ? node.value : undefined;
321
+ }),
322
+ ),
324
323
  [graph],
325
324
  );
326
325
 
327
- const isOpen = useCallback(
328
- (_path: string[]) => {
329
- const path = Path.create(..._path);
330
- const object = state.get(path) ?? live({ open: true, current: false });
331
- if (!state.has(path)) {
332
- state.set(path, object);
333
- }
326
+ const itemPropsFamily = useMemo(
327
+ () =>
328
+ Atom.family((pathKey: string) => {
329
+ const path = pathKey.split('~');
330
+ const id = path[path.length - 1];
331
+ return Atom.make((get) => {
332
+ const nodeOpt = get(graph.node(id));
333
+ const node = Option.isSome(nodeOpt) ? nodeOpt.value : undefined;
334
+ if (!node) {
335
+ return { id, label: id };
336
+ }
337
+ const connections = get(graph.connections(node.id, 'child'));
338
+ const safeChildren = connections.filter((n) => !path.includes(n.id));
339
+ const parentOf =
340
+ safeChildren.length > 0
341
+ ? safeChildren.map(({ id }) => id)
342
+ : node.properties.role === 'branch'
343
+ ? []
344
+ : undefined;
345
+ return {
346
+ id: node.id,
347
+ label: node.id,
348
+ icon: node.type === 'org.dxos.type.space' ? 'ph--planet--regular' : 'ph--placeholder--regular',
349
+ parentOf,
350
+ };
351
+ });
352
+ }),
353
+ [graph],
354
+ );
334
355
 
335
- return object.open;
336
- },
337
- [state],
356
+ const itemOpenFamily = useMemo(
357
+ () =>
358
+ Atom.family((pathKey: string) => {
359
+ const stateAtom = getOrCreateState(pathKey);
360
+ return Atom.make((get) => get(stateAtom).open);
361
+ }),
362
+ [getOrCreateState],
338
363
  );
339
364
 
340
- const isCurrent = useCallback(
341
- (_path: string[]) => {
342
- const path = Path.create(..._path);
343
- const object = state.get(path) ?? live({ open: false, current: false });
344
- if (!state.has(path)) {
345
- state.set(path, object);
346
- }
365
+ const itemCurrentFamily = useMemo(
366
+ () =>
367
+ Atom.family((pathKey: string) => {
368
+ const stateAtom = getOrCreateState(pathKey);
369
+ return Atom.make((get) => get(stateAtom).current);
370
+ }),
371
+ [getOrCreateState],
372
+ );
347
373
 
348
- return object.current;
349
- },
350
- [state],
374
+ const model: TreeModel<Node.Node> = useMemo(
375
+ () => ({
376
+ childIds: (parentId?: string) => childIdsFamily(parentId ?? Node.RootId),
377
+ item: (id: string) => itemFamily(id),
378
+ itemProps: (path: string[]) => itemPropsFamily(path.join('~')),
379
+ itemOpen: (path: string[]) => itemOpenFamily(Path.create(...path)),
380
+ itemCurrent: (path: string[]) => itemCurrentFamily(Path.create(...path)),
381
+ }),
382
+ [childIdsFamily, itemFamily, itemPropsFamily, itemOpenFamily, itemCurrentFamily],
351
383
  );
352
384
 
353
385
  const onOpenChange = useCallback(
354
386
  ({ path: _path, open }: { path: string[]; open: boolean }) => {
355
387
  const path = Path.create(..._path);
356
- const object = state.get(path);
357
- object!.open = open;
388
+ const atom = stateRef.current.get(path);
389
+ if (atom) {
390
+ const prev = registry.get(atom);
391
+ registry.set(atom, { ...prev, open });
392
+ }
358
393
  },
359
- [state],
394
+ [registry],
360
395
  );
361
396
 
362
397
  const onSelect = useCallback(
363
398
  ({ path: _path, current }: { path: string[]; current: boolean }) => {
364
399
  const path = Path.create(..._path);
365
- const object = state.get(path);
366
- object!.current = current;
400
+ const atom = stateRef.current.get(path);
401
+ if (atom) {
402
+ const prev = registry.get(atom);
403
+ registry.set(atom, { ...prev, current });
404
+ }
367
405
  },
368
- [state],
406
+ [registry],
369
407
  );
370
408
 
371
409
  return (
372
410
  <>
373
411
  <Controls />
374
- <Tree
375
- id={ROOT_ID}
376
- useItems={useItems}
377
- getProps={getProps}
378
- isOpen={isOpen}
379
- isCurrent={isCurrent}
380
- onOpenChange={onOpenChange}
381
- onSelect={onSelect}
382
- />
412
+ <Tree model={model} id={Node.RootId} onOpenChange={onOpenChange} onSelect={onSelect} />
383
413
  </>
384
414
  );
385
415
  },
@@ -4,7 +4,7 @@
4
4
 
5
5
  import React, { type FC, type HTMLAttributes, useState } from 'react';
6
6
 
7
- import { mx } from '@dxos/react-ui-theme';
7
+ import { mx } from '@dxos/ui-theme';
8
8
 
9
9
  // TODO(burdon): Copied form devtools.
10
10
 
@@ -72,7 +72,7 @@ const Scalar: FC<{ value: any }> = ({ value }) => {
72
72
 
73
73
  const Box: FC<HTMLAttributes<HTMLDivElement>> = ({ children, className, ...props }) => {
74
74
  return (
75
- <div className={mx('flex pli-2 border border-l-0 font-mono truncate', className)} {...props}>
75
+ <div className={mx('flex px-2 border border-l-0 font-mono truncate', className)} {...props}>
76
76
  {children}
77
77
  </div>
78
78
  );
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './setup-graph-builder';
@@ -0,0 +1,41 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { Registry } from '@effect-atom/atom-react';
6
+ import * as Option from 'effect/Option';
7
+
8
+ import * as Graph from '../graph';
9
+ import * as GraphBuilder from '../graph-builder';
10
+ import * as Node from '../node';
11
+
12
+ export type SetupGraphBuilderOptions = {
13
+ registry?: Registry.Registry;
14
+ extensions?: GraphBuilder.BuilderExtensions;
15
+ };
16
+
17
+ export const setupGraphBuilder = ({ registry = Registry.make(), extensions }: SetupGraphBuilderOptions = {}) => {
18
+ const builder = GraphBuilder.make({ registry });
19
+ const graph = builder.graph;
20
+
21
+ if (extensions) {
22
+ GraphBuilder.addExtension(builder, extensions);
23
+ }
24
+
25
+ return {
26
+ registry,
27
+ builder,
28
+ graph,
29
+ addExtensions: (nextExtensions: GraphBuilder.BuilderExtensions) => {
30
+ GraphBuilder.addExtension(builder, nextExtensions);
31
+ },
32
+ expand: async (id: string, relation: Node.RelationInput = 'child') => {
33
+ Graph.expand(graph, id, relation);
34
+ await GraphBuilder.flush(builder);
35
+ },
36
+ flush: () => GraphBuilder.flush(builder),
37
+ getConnections: (id: string, relation: Node.RelationInput = 'child') =>
38
+ registry.get(graph.connections(id, relation)),
39
+ getNode: (id: string) => Graph.getNode(graph, id).pipe(Option.getOrNull),
40
+ };
41
+ };