@dxos/app-graph 0.8.4-main.b97322e → 0.8.4-main.bcb3aa67d6

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