@dxos/app-graph 0.8.3 → 0.8.4-main.1c7ec43d41

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 (73) hide show
  1. package/dist/lib/neutral/chunk-J5LGTIGS.mjs +10 -0
  2. package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +7 -0
  3. package/dist/lib/neutral/chunk-WJJ5KEOH.mjs +1477 -0
  4. package/dist/lib/neutral/chunk-WJJ5KEOH.mjs.map +7 -0
  5. package/dist/lib/neutral/index.mjs +40 -0
  6. package/dist/lib/neutral/index.mjs.map +7 -0
  7. package/dist/lib/neutral/meta.json +1 -0
  8. package/dist/lib/neutral/scheduler.mjs +15 -0
  9. package/dist/lib/neutral/scheduler.mjs.map +7 -0
  10. package/dist/lib/neutral/testing/index.mjs +40 -0
  11. package/dist/lib/neutral/testing/index.mjs.map +7 -0
  12. package/dist/types/src/atoms.d.ts +8 -0
  13. package/dist/types/src/atoms.d.ts.map +1 -0
  14. package/dist/types/src/graph-builder.d.ts +117 -60
  15. package/dist/types/src/graph-builder.d.ts.map +1 -1
  16. package/dist/types/src/graph.d.ts +188 -218
  17. package/dist/types/src/graph.d.ts.map +1 -1
  18. package/dist/types/src/index.d.ts +7 -3
  19. package/dist/types/src/index.d.ts.map +1 -1
  20. package/dist/types/src/node-matcher.d.ts +244 -0
  21. package/dist/types/src/node-matcher.d.ts.map +1 -0
  22. package/dist/types/src/node-matcher.test.d.ts +2 -0
  23. package/dist/types/src/node-matcher.test.d.ts.map +1 -0
  24. package/dist/types/src/node.d.ts +50 -5
  25. package/dist/types/src/node.d.ts.map +1 -1
  26. package/dist/types/src/scheduler.browser.d.ts +2 -0
  27. package/dist/types/src/scheduler.browser.d.ts.map +1 -0
  28. package/dist/types/src/scheduler.d.ts +8 -0
  29. package/dist/types/src/scheduler.d.ts.map +1 -0
  30. package/dist/types/src/stories/EchoGraph.stories.d.ts +6 -13
  31. package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
  32. package/dist/types/src/testing/index.d.ts +2 -0
  33. package/dist/types/src/testing/index.d.ts.map +1 -0
  34. package/dist/types/src/testing/setup-graph-builder.d.ts +31 -0
  35. package/dist/types/src/testing/setup-graph-builder.d.ts.map +1 -0
  36. package/dist/types/src/util.d.ts +40 -0
  37. package/dist/types/src/util.d.ts.map +1 -0
  38. package/dist/types/tsconfig.tsbuildinfo +1 -1
  39. package/package.json +53 -42
  40. package/src/atoms.ts +25 -0
  41. package/src/graph-builder.test.ts +1193 -126
  42. package/src/graph-builder.ts +753 -264
  43. package/src/graph.test.ts +451 -123
  44. package/src/graph.ts +1057 -407
  45. package/src/index.ts +10 -3
  46. package/src/node-matcher.test.ts +301 -0
  47. package/src/node-matcher.ts +314 -0
  48. package/src/node.ts +83 -7
  49. package/src/scheduler.browser.ts +5 -0
  50. package/src/scheduler.ts +17 -0
  51. package/src/stories/EchoGraph.stories.tsx +178 -255
  52. package/src/stories/Tree.tsx +1 -1
  53. package/src/testing/index.ts +5 -0
  54. package/src/testing/setup-graph-builder.ts +41 -0
  55. package/src/util.ts +101 -0
  56. package/dist/lib/browser/index.mjs +0 -778
  57. package/dist/lib/browser/index.mjs.map +0 -7
  58. package/dist/lib/browser/meta.json +0 -1
  59. package/dist/lib/node/index.cjs +0 -816
  60. package/dist/lib/node/index.cjs.map +0 -7
  61. package/dist/lib/node/meta.json +0 -1
  62. package/dist/lib/node-esm/index.mjs +0 -780
  63. package/dist/lib/node-esm/index.mjs.map +0 -7
  64. package/dist/lib/node-esm/meta.json +0 -1
  65. package/dist/types/src/experimental/graph-projections.test.d.ts +0 -25
  66. package/dist/types/src/experimental/graph-projections.test.d.ts.map +0 -1
  67. package/dist/types/src/signals-integration.test.d.ts +0 -2
  68. package/dist/types/src/signals-integration.test.d.ts.map +0 -1
  69. package/dist/types/src/testing.d.ts +0 -5
  70. package/dist/types/src/testing.d.ts.map +0 -1
  71. package/src/experimental/graph-projections.test.ts +0 -56
  72. package/src/signals-integration.test.ts +0 -218
  73. package/src/testing.ts +0 -20
@@ -2,40 +2,30 @@
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 { Pause, Play, Plus, Timer } from '@phosphor-icons/react';
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';
24
- import { faker } from '@dxos/random';
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 { AtomObj, AtomQuery } from '@dxos/echo-atom';
14
+ import { TestSchema } from '@dxos/echo/testing';
15
+ import { random } from '@dxos/random';
25
16
  import { type Client, useClient } from '@dxos/react-client';
26
17
  import { withClientProvider } from '@dxos/react-client/testing';
27
- import { Button, Input, Select } from '@dxos/react-ui';
28
- import { Path, Tree } from '@dxos/react-ui-list';
29
- import { Tabs } from '@dxos/react-ui-tabs';
30
- import { getSize, mx } from '@dxos/react-ui-theme';
31
- import { withTheme } from '@dxos/storybook-utils';
32
- import { byPosition, isNonNullable, safeParseInt } from '@dxos/util';
33
-
18
+ import { Icon, IconButton, Input, Select } from '@dxos/react-ui';
19
+ import { Path, Tree, type TreeModel } from '@dxos/react-ui-list';
20
+ import { withTheme } from '@dxos/react-ui/testing';
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';
34
28
  import { JsonTree } from './Tree';
35
- import { type ExpandableGraph, ROOT_ID } from '../graph';
36
- import { GraphBuilder, createExtension, rxFromObservable, rxFromSignal } from '../graph-builder';
37
- import { type Node } from '../node';
38
- import { rxFromQuery } from '../testing';
39
29
 
40
30
  const DEFAULT_PERIOD = 500;
41
31
 
@@ -57,46 +47,47 @@ const actionWeights = {
57
47
  [Action.RENAME_OBJECT]: 4,
58
48
  };
59
49
 
60
- const createGraph = (client: Client, registry: Registry.Registry): ExpandableGraph => {
61
- const spaceBuilderExtension = createExtension({
50
+ const createGraph = (client: Client, registry: Registry.Registry): Graph.ExpandableGraph => {
51
+ const spaceBuilderExtension = GraphBuilder.createExtensionRaw({
62
52
  id: 'space',
63
53
  connector: (node) =>
64
- Rx.make((get) =>
65
- pipe(
54
+ Atom.make((get) =>
55
+ Function.pipe(
66
56
  get(node),
67
- 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())),
68
58
  Option.map(() => {
69
- const spaces = get(rxFromObservable(client.spaces)) ?? [];
59
+ const spaces = get(CreateAtom.fromObservable(client.spaces)) ?? [];
70
60
  return spaces
71
- .filter((space) => get(rxFromObservable(space.state)) === SpaceState.SPACE_READY)
72
- .map((space) => ({
73
- id: space.id,
74
- type: 'dxos.org/type/Space',
75
- properties: { label: get(rxFromSignal(() => space.properties.name)) },
76
- data: space,
77
- }));
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
+ });
78
73
  }),
79
74
  Option.getOrElse(() => []),
80
75
  ),
81
76
  ),
82
77
  });
83
78
 
84
- const objectBuilderExtension = createExtension({
79
+ const objectBuilderExtension = GraphBuilder.createExtensionRaw({
85
80
  id: 'object',
86
81
  connector: (node) => {
87
- let query: QueryResult<Live<Expando>> | undefined;
88
- return Rx.make((get) =>
89
- pipe(
82
+ return Atom.make((get) =>
83
+ Function.pipe(
90
84
  get(node),
91
85
  Option.flatMap((node) => (isSpace(node.data) ? Option.some(node.data) : Option.none())),
92
86
  Option.map((space) => {
93
- if (!query) {
94
- query = space.db.query(Query.type(Expando, { type: 'test' }));
95
- }
96
- const objects = get(rxFromQuery(query));
87
+ const objects = get(AtomQuery.make(space.db, Query.type(TestSchema.Expando, { type: 'test' })));
97
88
  return objects.map((object) => ({
98
89
  id: object.id,
99
- type: 'dxos.org/type/test',
90
+ type: 'org.dxos.type.test',
100
91
  properties: { label: object.name },
101
92
  data: object,
102
93
  }));
@@ -107,15 +98,15 @@ const createGraph = (client: Client, registry: Registry.Registry): ExpandableGra
107
98
  },
108
99
  });
109
100
 
110
- const graph = new GraphBuilder({ registry })
111
- .addExtension(spaceBuilderExtension)
112
- .addExtension(objectBuilderExtension).graph;
101
+ const builder = GraphBuilder.make({ registry });
102
+ GraphBuilder.addExtension(builder, spaceBuilderExtension);
103
+ GraphBuilder.addExtension(builder, objectBuilderExtension);
104
+ const graph = builder.graph;
113
105
  graph.onNodeChanged.on(({ id }) => {
114
- graph.expand(id);
106
+ Graph.expand(graph, id, 'child');
115
107
  });
116
- graph.expand(ROOT_ID);
108
+ Graph.expand(graph, Node.RootId, 'child');
117
109
  (window as any).graph = graph;
118
-
119
110
  return graph;
120
111
  };
121
112
 
@@ -135,9 +126,9 @@ const getRandomSpace = (client: Client): Space | undefined => {
135
126
  const getSpaceWithObjects = async (client: Client): Promise<Space | undefined> => {
136
127
  const readySpaces = client.spaces.get().filter((space) => space.state.get() === SpaceState.SPACE_READY);
137
128
  const spaceQueries = await Promise.all(
138
- 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()),
139
130
  );
140
- const spaces = readySpaces.filter((space, index) => spaceQueries[index].objects.length > 0);
131
+ const spaces = readySpaces.filter((space, index) => spaceQueries[index].length > 0);
141
132
  return spaces[Math.floor(Math.random() * spaces.length)];
142
133
  };
143
134
 
@@ -154,19 +145,26 @@ const runAction = async (client: Client, action: Action) => {
154
145
  case Action.RENAME_SPACE: {
155
146
  const space = getRandomSpace(client);
156
147
  if (space) {
157
- space.properties.name = faker.commerce.productName();
148
+ Obj.update(space.properties, (obj) => {
149
+ obj.name = random.commerce.productName();
150
+ });
158
151
  }
159
152
  break;
160
153
  }
161
154
 
162
155
  case Action.ADD_OBJECT:
163
- 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
+ );
164
162
  break;
165
163
 
166
164
  case Action.REMOVE_OBJECT: {
167
165
  const space = await getSpaceWithObjects(client);
168
166
  if (space) {
169
- 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();
170
168
  space.db.remove(objects[Math.floor(Math.random() * objects.length)]);
171
169
  }
172
170
  break;
@@ -175,8 +173,11 @@ const runAction = async (client: Client, action: Action) => {
175
173
  case Action.RENAME_OBJECT: {
176
174
  const space = await getSpaceWithObjects(client);
177
175
  if (space) {
178
- const { objects } = await space.db.query(Filter.type(Expando, { type: 'test' })).run();
179
- 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
+ });
180
181
  }
181
182
  break;
182
183
  }
@@ -205,23 +206,24 @@ const Controls = ({ children }: PropsWithChildren) => {
205
206
  return (
206
207
  <>
207
208
  <div className='flex shrink-0 p-2 space-x-2'>
208
- <Button onClick={() => setGenerating((generating) => !generating)}>{generating ? <Pause /> : <Play />}</Button>
209
+ <IconButton
210
+ icon={generating ? 'ph--pause--regular' : 'ph--play--regular'}
211
+ label={generating ? 'Pause' : 'Play'}
212
+ onClick={() => setGenerating((generating) => !generating)}
213
+ />
209
214
  <div className='relative' title='mutation period'>
210
215
  <Input.Root>
211
216
  <Input.TextInput
212
217
  autoComplete='off'
213
- size={5}
214
- classNames='w-[100px] text-right pie-[22px]'
218
+ classNames='w-[100px] text-right pe-[22px]'
215
219
  placeholder='Interval'
216
220
  value={actionInterval}
217
221
  onChange={({ target: { value } }) => setActionInterval(value)}
218
222
  />
219
223
  </Input.Root>
220
- <Timer className={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))} />
221
225
  </div>
222
- <Button onClick={() => action && runAction(client, action)}>
223
- <Plus />
224
- </Button>
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)}>
226
228
  <Select.TriggerButton placeholder='Select value' />
227
229
  <Select.Portal>
@@ -245,26 +247,31 @@ const Controls = ({ children }: PropsWithChildren) => {
245
247
  );
246
248
  };
247
249
 
248
- export default {
250
+ const meta = {
249
251
  title: 'sdk/app-graph/EchoGraph',
250
252
  decorators: [
251
- withTheme,
253
+ withTheme(),
252
254
  withClientProvider({
253
255
  createIdentity: true,
254
- onIdentityCreated: async ({ client }) => {
256
+ types: [TestSchema.Expando],
257
+ onCreateIdentity: async ({ client }) => {
255
258
  await client.spaces.create();
256
259
  await client.spaces.create();
257
260
  },
258
261
  }),
259
262
  ],
260
- };
263
+ } satisfies Meta;
264
+
265
+ export default meta;
261
266
 
262
- export const JsonView = {
267
+ type Story = StoryObj<typeof meta>;
268
+
269
+ export const JsonView: Story = {
263
270
  render: () => {
264
271
  const client = useClient();
265
272
  const registry = useContext(RegistryContext);
266
273
  const graph = useMemo(() => createGraph(client, registry), [client, registry]);
267
- const data = useRxValue(graph.json());
274
+ const data = useAtomValue(graph.json());
268
275
 
269
276
  return (
270
277
  <>
@@ -275,218 +282,134 @@ export const JsonView = {
275
282
  },
276
283
  };
277
284
 
278
- export const TreeView = {
285
+ export const TreeView: Story = {
279
286
  render: () => {
280
287
  const client = useClient();
281
288
  const registry = useContext(RegistryContext);
282
289
  const graph = useMemo(() => createGraph(client, registry), [client, registry]);
283
- const state = useMemo(() => new Map<string, Live<{ open: boolean; current: boolean }>>(), []);
284
-
285
- const useItems = useCallback(
286
- (node?: Node, options?: { disposition?: string; sort?: boolean }) => {
287
- const connections = useRxValue(graph.connections(node?.id ?? ROOT_ID));
288
- return options?.sort ? connections.toSorted((a, b) => byPosition(a.properties, b.properties)) : connections;
289
- },
290
- [graph],
291
- );
292
-
293
- const getProps = useCallback(
294
- (node: Node, path: string[]) => {
295
- const children = graph
296
- .getConnections(node.id, 'outbound')
297
- .map((n) => {
298
- // Break cycles.
299
- const nextPath = [...path, node.id];
300
- return nextPath.includes(n.id) ? undefined : (n as Node);
301
- })
302
- .filter(isNonNullable) as Node[];
303
- const parentOf =
304
- children.length > 0 ? children.map(({ id }) => id) : node.properties.role === 'branch' ? [] : undefined;
305
- return {
306
- id: node.id,
307
- label: node.id,
308
- icon: node.type === 'dxos.org/type/Space' ? 'ph--planet--regular' : 'ph--placeholder--regular',
309
- parentOf,
310
- };
311
- },
312
- [graph],
313
- );
314
-
315
- const isOpen = useCallback(
316
- (_path: string[]) => {
317
- const path = Path.create(..._path);
318
- const object = state.get(path) ?? live({ open: true, current: false });
319
- if (!state.has(path)) {
320
- state.set(path, object);
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);
321
298
  }
322
-
323
- return object.open;
324
- },
325
- [state],
326
- );
327
-
328
- const isCurrent = useCallback(
329
- (_path: string[]) => {
330
- const path = Path.create(..._path);
331
- const object = state.get(path) ?? live({ open: false, current: false });
332
- if (!state.has(path)) {
333
- state.set(path, object);
334
- }
335
-
336
- return object.current;
337
- },
338
- [state],
339
- );
340
-
341
- const onOpenChange = useCallback(
342
- ({ path: _path, open }: { path: string[]; open: boolean }) => {
343
- const path = Path.create(..._path);
344
- const object = state.get(path);
345
- object!.open = open;
346
- },
347
- [state],
348
- );
349
-
350
- const onSelect = useCallback(
351
- ({ path: _path, current }: { path: string[]; current: boolean }) => {
352
- const path = Path.create(..._path);
353
- const object = state.get(path);
354
- object!.current = current;
299
+ return atom;
355
300
  },
356
- [state],
301
+ [],
357
302
  );
358
303
 
359
- return (
360
- <>
361
- <Controls />
362
- <Tree
363
- id={ROOT_ID}
364
- useItems={useItems}
365
- getProps={getProps}
366
- isOpen={isOpen}
367
- isCurrent={isCurrent}
368
- onOpenChange={onOpenChange}
369
- onSelect={onSelect}
370
- />
371
- </>
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
+ ),
312
+ [graph],
372
313
  );
373
- },
374
- };
375
-
376
- // TODO(wittjosiah): Remove.
377
- export const TabTreeView = {
378
- render: () => {
379
- const client = useClient();
380
- const registry = useContext(RegistryContext);
381
- const graph = useMemo(() => createGraph(client, registry), [client, registry]);
382
- const state = useMemo(() => new Map<string, Live<{ open: boolean; current: boolean }>>(), []);
383
314
 
384
- const useItems = useCallback(
385
- (node?: Node, options?: { disposition?: string; sort?: boolean }) => {
386
- const connections = useRxValue(graph.connections(node?.id ?? ROOT_ID));
387
- return options?.sort ? connections.toSorted((a, b) => byPosition(a.properties, b.properties)) : connections;
388
- },
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
+ ),
389
323
  [graph],
390
324
  );
391
325
 
392
- const getProps = useCallback(
393
- (node: Node, path: string[]) => {
394
- const children = graph
395
- .getConnections(node.id, 'outbound')
396
- .map((n) => {
397
- // Break cycles.
398
- const nextPath = [...path, node.id];
399
- return nextPath.includes(n.id) ? undefined : (n as Node);
400
- })
401
- .filter(isNonNullable) as Node[];
402
- const parentOf =
403
- children.length > 0 ? children.map(({ id }) => id) : node.properties.role === 'branch' ? [] : undefined;
404
- return {
405
- id: node.id,
406
- label: node.id,
407
- icon: node.type === 'dxos.org/type/Space' ? 'ph--planet--regular' : 'ph--placeholder--regular',
408
- parentOf,
409
- };
410
- },
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
+ }),
411
353
  [graph],
412
354
  );
413
355
 
414
- const isOpen = useCallback(
415
- (_path: string[]) => {
416
- const path = Path.create(..._path);
417
- const object = state.get(path) ?? live({ open: true, current: false });
418
- if (!state.has(path)) {
419
- state.set(path, object);
420
- }
421
-
422
- return object.open;
423
- },
424
- [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],
425
363
  );
426
364
 
427
- const isCurrent = useCallback(
428
- (_path: string[]) => {
429
- const path = Path.create(..._path);
430
- const object = state.get(path) ?? live({ open: false, current: false });
431
- if (!state.has(path)) {
432
- state.set(path, object);
433
- }
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
+ );
434
373
 
435
- return object.current;
436
- },
437
- [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],
438
383
  );
439
384
 
440
385
  const onOpenChange = useCallback(
441
386
  ({ path: _path, open }: { path: string[]; open: boolean }) => {
442
387
  const path = Path.create(..._path);
443
- const object = state.get(path);
444
- 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
+ }
445
393
  },
446
- [state],
394
+ [registry],
447
395
  );
448
396
 
449
397
  const onSelect = useCallback(
450
398
  ({ path: _path, current }: { path: string[]; current: boolean }) => {
451
399
  const path = Path.create(..._path);
452
- const object = state.get(path);
453
- 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
+ }
454
405
  },
455
- [state],
406
+ [registry],
456
407
  );
457
408
 
458
- const spaces = useItems(graph.root);
459
-
460
409
  return (
461
410
  <>
462
411
  <Controls />
463
- <Tabs.Root defaultValue={spaces[0].id}>
464
- <Tabs.Tablist>
465
- {spaces.map((space) => {
466
- return (
467
- <Tabs.Tab key={space.id} value={space.id}>
468
- {space.id}
469
- </Tabs.Tab>
470
- );
471
- })}
472
- </Tabs.Tablist>
473
- {spaces.map((space) => {
474
- return (
475
- <Tabs.Tabpanel key={space.id} value={space.id}>
476
- <Tree
477
- id={space.id}
478
- root={space}
479
- useItems={useItems}
480
- getProps={getProps}
481
- isOpen={isOpen}
482
- isCurrent={isCurrent}
483
- onOpenChange={onOpenChange}
484
- onSelect={onSelect}
485
- />
486
- </Tabs.Tabpanel>
487
- );
488
- })}
489
- </Tabs.Root>
412
+ <Tree model={model} id={Node.RootId} onOpenChange={onOpenChange} onSelect={onSelect} />
490
413
  </>
491
414
  );
492
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
+ };