@dxos/app-graph 0.8.4-main.a4bbb77 → 0.8.4-main.abd8ff62ef

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-3T75MQOS.mjs +1480 -0
  2. package/dist/lib/browser/chunk-3T75MQOS.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +27 -842
  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-UEXRLXMS.mjs +1481 -0
  9. package/dist/lib/node-esm/chunk-UEXRLXMS.mjs.map +7 -0
  10. package/dist/lib/node-esm/index.mjs +27 -843
  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 +113 -67
  18. package/dist/types/src/graph-builder.d.ts.map +1 -1
  19. package/dist/types/src/graph.d.ts +188 -222
  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 +40 -0
  35. package/dist/types/src/util.d.ts.map +1 -0
  36. package/dist/types/tsconfig.tsbuildinfo +1 -1
  37. package/package.json +42 -38
  38. package/src/atoms.ts +25 -0
  39. package/src/graph-builder.test.ts +1154 -144
  40. package/src/graph-builder.ts +737 -293
  41. package/src/graph.test.ts +451 -123
  42. package/src/graph.ts +1054 -403
  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 +164 -126
  48. package/src/stories/Tree.tsx +1 -1
  49. package/src/testing/index.ts +5 -0
  50. package/src/testing/setup-graph-builder.ts +41 -0
  51. package/src/util.ts +101 -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 -218
  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
+ });
@@ -2,37 +2,29 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { type Registry, RegistryContext, Rx, useRxValue } from '@effect-rx/rx-react';
5
+ import { Atom, type Registry, RegistryContext, useAtomValue } from '@effect-atom/atom-react';
6
6
  import { type Meta, type StoryObj } from '@storybook/react-vite';
7
- import { Option, pipe } from 'effect';
8
- import React, { type PropsWithChildren, useCallback, useContext, useEffect, useMemo, useState } from 'react';
9
-
10
- import {
11
- Expando,
12
- Filter,
13
- type Live,
14
- Query,
15
- type QueryResult,
16
- type Space,
17
- SpaceState,
18
- isSpace,
19
- live,
20
- } from '@dxos/client/echo';
21
- import { Obj, Type } from '@dxos/echo';
22
- import { faker } from '@dxos/random';
7
+ import * as Function from 'effect/Function';
8
+ import * as Option from 'effect/Option';
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';
23
16
  import { type Client, useClient } from '@dxos/react-client';
24
17
  import { withClientProvider } from '@dxos/react-client/testing';
25
18
  import { Icon, IconButton, Input, Select } from '@dxos/react-ui';
19
+ import { Path, Tree, type TreeModel } from '@dxos/react-ui-list';
26
20
  import { withTheme } from '@dxos/react-ui/testing';
27
- import { Path, Tree } from '@dxos/react-ui-list';
28
- import { getSize, mx } from '@dxos/react-ui-theme';
29
- import { byPosition, isNonNullable, safeParseInt } from '@dxos/util';
30
-
31
- import { type ExpandableGraph, ROOT_ID } from '../graph';
32
- import { GraphBuilder, createExtension, rxFromObservable, rxFromSignal } from '../graph-builder';
33
- import { type Node } from '../node';
34
- import { rxFromQuery } from '../testing';
21
+ import { getSize, mx } from '@dxos/ui-theme';
22
+ import { safeParseInt } from '@dxos/util';
35
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';
36
28
  import { JsonTree } from './Tree';
37
29
 
38
30
  const DEFAULT_PERIOD = 500;
@@ -55,46 +47,47 @@ const actionWeights = {
55
47
  [Action.RENAME_OBJECT]: 4,
56
48
  };
57
49
 
58
- const createGraph = (client: Client, registry: Registry.Registry): ExpandableGraph => {
59
- const spaceBuilderExtension = createExtension({
50
+ const createGraph = (client: Client, registry: Registry.Registry): Graph.ExpandableGraph => {
51
+ const spaceBuilderExtension = GraphBuilder.createExtensionRaw({
60
52
  id: 'space',
61
53
  connector: (node) =>
62
- Rx.make((get) =>
63
- pipe(
54
+ Atom.make((get) =>
55
+ Function.pipe(
64
56
  get(node),
65
- 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())),
66
58
  Option.map(() => {
67
- const spaces = get(rxFromObservable(client.spaces)) ?? [];
59
+ const spaces = get(CreateAtom.fromObservable(client.spaces)) ?? [];
68
60
  return spaces
69
- .filter((space) => get(rxFromObservable(space.state)) === SpaceState.SPACE_READY)
70
- .map((space) => ({
71
- id: space.id,
72
- type: 'dxos.org/type/Space',
73
- properties: { label: get(rxFromSignal(() => space.properties.name)) },
74
- data: space,
75
- }));
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
+ });
76
73
  }),
77
74
  Option.getOrElse(() => []),
78
75
  ),
79
76
  ),
80
77
  });
81
78
 
82
- const objectBuilderExtension = createExtension({
79
+ const objectBuilderExtension = GraphBuilder.createExtensionRaw({
83
80
  id: 'object',
84
81
  connector: (node) => {
85
- let query: QueryResult<Live<Expando>> | undefined;
86
- return Rx.make((get) =>
87
- pipe(
82
+ return Atom.make((get) =>
83
+ Function.pipe(
88
84
  get(node),
89
85
  Option.flatMap((node) => (isSpace(node.data) ? Option.some(node.data) : Option.none())),
90
86
  Option.map((space) => {
91
- if (!query) {
92
- query = space.db.query(Query.type(Expando, { type: 'test' }));
93
- }
94
- const objects = get(rxFromQuery(query));
87
+ const objects = get(AtomQuery.make(space.db, Query.type(TestSchema.Expando, { type: 'test' })));
95
88
  return objects.map((object) => ({
96
89
  id: object.id,
97
- type: 'dxos.org/type/test',
90
+ type: 'org.dxos.type.test',
98
91
  properties: { label: object.name },
99
92
  data: object,
100
93
  }));
@@ -105,15 +98,15 @@ const createGraph = (client: Client, registry: Registry.Registry): ExpandableGra
105
98
  },
106
99
  });
107
100
 
108
- const graph = new GraphBuilder({ registry })
109
- .addExtension(spaceBuilderExtension)
110
- .addExtension(objectBuilderExtension).graph;
101
+ const builder = GraphBuilder.make({ registry });
102
+ GraphBuilder.addExtension(builder, spaceBuilderExtension);
103
+ GraphBuilder.addExtension(builder, objectBuilderExtension);
104
+ const graph = builder.graph;
111
105
  graph.onNodeChanged.on(({ id }) => {
112
- graph.expand(id);
106
+ Graph.expand(graph, id, 'child');
113
107
  });
114
- graph.expand(ROOT_ID);
108
+ Graph.expand(graph, Node.RootId, 'child');
115
109
  (window as any).graph = graph;
116
-
117
110
  return graph;
118
111
  };
119
112
 
@@ -133,9 +126,9 @@ const getRandomSpace = (client: Client): Space | undefined => {
133
126
  const getSpaceWithObjects = async (client: Client): Promise<Space | undefined> => {
134
127
  const readySpaces = client.spaces.get().filter((space) => space.state.get() === SpaceState.SPACE_READY);
135
128
  const spaceQueries = await Promise.all(
136
- 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()),
137
130
  );
138
- const spaces = readySpaces.filter((space, index) => spaceQueries[index].objects.length > 0);
131
+ const spaces = readySpaces.filter((space, index) => spaceQueries[index].length > 0);
139
132
  return spaces[Math.floor(Math.random() * spaces.length)];
140
133
  };
141
134
 
@@ -152,19 +145,26 @@ const runAction = async (client: Client, action: Action) => {
152
145
  case Action.RENAME_SPACE: {
153
146
  const space = getRandomSpace(client);
154
147
  if (space) {
155
- space.properties.name = faker.commerce.productName();
148
+ Obj.update(space.properties, (obj) => {
149
+ obj.name = random.commerce.productName();
150
+ });
156
151
  }
157
152
  break;
158
153
  }
159
154
 
160
155
  case Action.ADD_OBJECT:
161
- getRandomSpace(client)?.db.add(Obj.make(Type.Expando, { type: 'test', name: faker.commerce.productName() }));
156
+ getRandomSpace(client)?.db.add(
157
+ Obj.make(TestSchema.Expando, {
158
+ type: 'test',
159
+ name: random.commerce.productName(),
160
+ }),
161
+ );
162
162
  break;
163
163
 
164
164
  case Action.REMOVE_OBJECT: {
165
165
  const space = await getSpaceWithObjects(client);
166
166
  if (space) {
167
- 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();
168
168
  space.db.remove(objects[Math.floor(Math.random() * objects.length)]);
169
169
  }
170
170
  break;
@@ -173,8 +173,11 @@ const runAction = async (client: Client, action: Action) => {
173
173
  case Action.RENAME_OBJECT: {
174
174
  const space = await getSpaceWithObjects(client);
175
175
  if (space) {
176
- const { objects } = await space.db.query(Filter.type(Expando, { type: 'test' })).run();
177
- 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.update(object, (object) => {
179
+ object.name = random.commerce.productName();
180
+ });
178
181
  }
179
182
  break;
180
183
  }
@@ -212,14 +215,13 @@ const Controls = ({ children }: PropsWithChildren) => {
212
215
  <Input.Root>
213
216
  <Input.TextInput
214
217
  autoComplete='off'
215
- size={5}
216
- classNames='w-[100px] text-right pie-[22px]'
218
+ classNames='w-[100px] text-right pe-[22px]'
217
219
  placeholder='Interval'
218
220
  value={actionInterval}
219
221
  onChange={({ target: { value } }) => setActionInterval(value)}
220
222
  />
221
223
  </Input.Root>
222
- <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))} />
223
225
  </div>
224
226
  <IconButton icon='ph--plus--regular' label='Add' onClick={() => action && runAction(client, action)} />
225
227
  <Select.Root value={action?.toString()} onValueChange={(action) => setAction(action as unknown as Action)}>
@@ -248,16 +250,17 @@ const Controls = ({ children }: PropsWithChildren) => {
248
250
  const meta = {
249
251
  title: 'sdk/app-graph/EchoGraph',
250
252
  decorators: [
251
- withTheme,
253
+ withTheme(),
252
254
  withClientProvider({
253
255
  createIdentity: true,
256
+ types: [TestSchema.Expando],
254
257
  onCreateIdentity: async ({ client }) => {
255
258
  await client.spaces.create();
256
259
  await client.spaces.create();
257
260
  },
258
261
  }),
259
262
  ],
260
- } satisfies Meta<typeof Registry>;
263
+ } satisfies Meta;
261
264
 
262
265
  export default meta;
263
266
 
@@ -268,7 +271,7 @@ export const JsonView: Story = {
268
271
  const client = useClient();
269
272
  const registry = useContext(RegistryContext);
270
273
  const graph = useMemo(() => createGraph(client, registry), [client, registry]);
271
- const data = useRxValue(graph.json());
274
+ const data = useAtomValue(graph.json());
272
275
 
273
276
  return (
274
277
  <>
@@ -284,94 +287,129 @@ export const TreeView: Story = {
284
287
  const client = useClient();
285
288
  const registry = useContext(RegistryContext);
286
289
  const graph = useMemo(() => createGraph(client, registry), [client, registry]);
287
- const state = useMemo(() => new Map<string, Live<{ open: boolean; current: boolean }>>(), []);
288
-
289
- const useItems = useCallback(
290
- (node?: Node, options?: { disposition?: string; sort?: boolean }) => {
291
- const connections = useRxValue(graph.connections(node?.id ?? ROOT_ID));
292
- 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;
293
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
+ ),
294
312
  [graph],
295
313
  );
296
314
 
297
- const getProps = useCallback(
298
- (node: Node, path: string[]) => {
299
- const children = graph
300
- .getConnections(node.id, 'outbound')
301
- .map((n) => {
302
- // Break cycles.
303
- const nextPath = [...path, node.id];
304
- return nextPath.includes(n.id) ? undefined : (n as Node);
305
- })
306
- .filter(isNonNullable) as Node[];
307
- const parentOf =
308
- children.length > 0 ? children.map(({ id }) => id) : node.properties.role === 'branch' ? [] : undefined;
309
- return {
310
- id: node.id,
311
- label: node.id,
312
- icon: node.type === 'dxos.org/type/Space' ? 'ph--planet--regular' : 'ph--placeholder--regular',
313
- parentOf,
314
- };
315
- },
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
+ ),
316
323
  [graph],
317
324
  );
318
325
 
319
- const isOpen = useCallback(
320
- (_path: string[]) => {
321
- const path = Path.create(..._path);
322
- const object = state.get(path) ?? live({ open: true, current: false });
323
- if (!state.has(path)) {
324
- state.set(path, object);
325
- }
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
+ );
326
355
 
327
- return object.open;
328
- },
329
- [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],
330
363
  );
331
364
 
332
- const isCurrent = useCallback(
333
- (_path: string[]) => {
334
- const path = Path.create(..._path);
335
- const object = state.get(path) ?? live({ open: false, current: false });
336
- if (!state.has(path)) {
337
- state.set(path, object);
338
- }
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
+ );
339
373
 
340
- return object.current;
341
- },
342
- [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],
343
383
  );
344
384
 
345
385
  const onOpenChange = useCallback(
346
386
  ({ path: _path, open }: { path: string[]; open: boolean }) => {
347
387
  const path = Path.create(..._path);
348
- const object = state.get(path);
349
- 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
+ }
350
393
  },
351
- [state],
394
+ [registry],
352
395
  );
353
396
 
354
397
  const onSelect = useCallback(
355
398
  ({ path: _path, current }: { path: string[]; current: boolean }) => {
356
399
  const path = Path.create(..._path);
357
- const object = state.get(path);
358
- 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
+ }
359
405
  },
360
- [state],
406
+ [registry],
361
407
  );
362
408
 
363
409
  return (
364
410
  <>
365
411
  <Controls />
366
- <Tree
367
- id={ROOT_ID}
368
- useItems={useItems}
369
- getProps={getProps}
370
- isOpen={isOpen}
371
- isCurrent={isCurrent}
372
- onOpenChange={onOpenChange}
373
- onSelect={onSelect}
374
- />
412
+ <Tree model={model} id={Node.RootId} onOpenChange={onOpenChange} onSelect={onSelect} />
375
413
  </>
376
414
  );
377
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
 
@@ -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
+ };