@dxos/app-graph 0.8.4-main.84f28bd → 0.8.4-main.8baae0fced

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 (72) hide show
  1. package/LICENSE +102 -5
  2. package/README.md +1 -1
  3. package/dist/lib/neutral/chunk-32XXJE6M.mjs +1477 -0
  4. package/dist/lib/neutral/chunk-32XXJE6M.mjs.map +7 -0
  5. package/dist/lib/neutral/chunk-J5LGTIGS.mjs +10 -0
  6. package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +7 -0
  7. package/dist/lib/neutral/index.mjs +40 -0
  8. package/dist/lib/neutral/index.mjs.map +7 -0
  9. package/dist/lib/neutral/meta.json +1 -0
  10. package/dist/lib/neutral/scheduler.mjs +15 -0
  11. package/dist/lib/neutral/scheduler.mjs.map +7 -0
  12. package/dist/lib/neutral/testing/index.mjs +40 -0
  13. package/dist/lib/neutral/testing/index.mjs.map +7 -0
  14. package/dist/types/src/atoms.d.ts +8 -0
  15. package/dist/types/src/atoms.d.ts.map +1 -0
  16. package/dist/types/src/graph-builder.d.ts +117 -60
  17. package/dist/types/src/graph-builder.d.ts.map +1 -1
  18. package/dist/types/src/graph.d.ts +188 -218
  19. package/dist/types/src/graph.d.ts.map +1 -1
  20. package/dist/types/src/index.d.ts +7 -3
  21. package/dist/types/src/index.d.ts.map +1 -1
  22. package/dist/types/src/node-matcher.d.ts +244 -0
  23. package/dist/types/src/node-matcher.d.ts.map +1 -0
  24. package/dist/types/src/node-matcher.test.d.ts +2 -0
  25. package/dist/types/src/node-matcher.test.d.ts.map +1 -0
  26. package/dist/types/src/node.d.ts +50 -5
  27. package/dist/types/src/node.d.ts.map +1 -1
  28. package/dist/types/src/scheduler.browser.d.ts +2 -0
  29. package/dist/types/src/scheduler.browser.d.ts.map +1 -0
  30. package/dist/types/src/scheduler.d.ts +8 -0
  31. package/dist/types/src/scheduler.d.ts.map +1 -0
  32. package/dist/types/src/stories/EchoGraph.stories.d.ts +8 -10
  33. package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
  34. package/dist/types/src/testing/index.d.ts +2 -0
  35. package/dist/types/src/testing/index.d.ts.map +1 -0
  36. package/dist/types/src/testing/setup-graph-builder.d.ts +31 -0
  37. package/dist/types/src/testing/setup-graph-builder.d.ts.map +1 -0
  38. package/dist/types/src/util.d.ts +40 -0
  39. package/dist/types/src/util.d.ts.map +1 -0
  40. package/dist/types/tsconfig.tsbuildinfo +1 -1
  41. package/package.json +54 -43
  42. package/src/atoms.ts +25 -0
  43. package/src/graph-builder.test.ts +1193 -126
  44. package/src/graph-builder.ts +754 -265
  45. package/src/graph.test.ts +451 -123
  46. package/src/graph.ts +1057 -407
  47. package/src/index.ts +10 -3
  48. package/src/node-matcher.test.ts +301 -0
  49. package/src/node-matcher.ts +314 -0
  50. package/src/node.ts +83 -7
  51. package/src/scheduler.browser.ts +5 -0
  52. package/src/scheduler.ts +17 -0
  53. package/src/stories/EchoGraph.stories.tsx +180 -140
  54. package/src/stories/Tree.tsx +1 -1
  55. package/src/testing/index.ts +5 -0
  56. package/src/testing/setup-graph-builder.ts +41 -0
  57. package/src/util.ts +101 -0
  58. package/dist/lib/browser/index.mjs +0 -778
  59. package/dist/lib/browser/index.mjs.map +0 -7
  60. package/dist/lib/browser/meta.json +0 -1
  61. package/dist/lib/node-esm/index.mjs +0 -780
  62. package/dist/lib/node-esm/index.mjs.map +0 -7
  63. package/dist/lib/node-esm/meta.json +0 -1
  64. package/dist/types/src/experimental/graph-projections.test.d.ts +0 -25
  65. package/dist/types/src/experimental/graph-projections.test.d.ts.map +0 -1
  66. package/dist/types/src/signals-integration.test.d.ts +0 -2
  67. package/dist/types/src/signals-integration.test.d.ts.map +0 -1
  68. package/dist/types/src/testing.d.ts +0 -5
  69. package/dist/types/src/testing.d.ts.map +0 -1
  70. package/src/experimental/graph-projections.test.ts +0 -56
  71. package/src/signals-integration.test.ts +0 -218
  72. package/src/testing.ts +0 -20
package/src/node.ts CHANGED
@@ -2,7 +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';
7
+
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';
6
29
 
7
30
  /**
8
31
  * Represents a node in the graph.
@@ -46,7 +69,19 @@ export type NodeFilter<TData = any, TProperties extends Record<string, any> = Re
46
69
  connectedNode: Node,
47
70
  ) => node is Node<TData, TProperties>;
48
71
 
49
- 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);
50
85
 
51
86
  export const isGraphNode = (data: unknown): data is Node =>
52
87
  data && typeof data === 'object' && 'id' in data && 'properties' in data && data.properties
@@ -61,30 +96,45 @@ export type NodeArg<TData, TProperties extends Record<string, any> = Record<stri
61
96
  nodes?: NodeArg<unknown>[];
62
97
 
63
98
  /** Will automatically add specified edges. */
64
- edges?: [string, Relation][];
99
+ edges?: [string, RelationInput][];
65
100
  };
66
101
 
67
102
  //
68
103
  // Actions
69
104
  //
70
105
 
71
- export type InvokeParams = {
106
+ export type InvokeProps = {
72
107
  /** Node the invoked action is connected to. */
73
108
  parent?: Node;
74
109
 
110
+ /** Path from root to the node in the current tree context. */
111
+ path?: string[];
112
+
75
113
  caller?: string;
76
114
  };
77
115
 
78
- 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>;
79
127
 
80
128
  export type Action<TProperties extends Record<string, any> = Record<string, any>> = Readonly<
81
129
  Omit<Node<ActionData, TProperties>, 'properties'> & {
82
130
  properties: Readonly<TProperties>;
131
+ /** Captured context from extension creation. Provided automatically at action execution. */
132
+ _actionContext?: ActionContext;
83
133
  }
84
134
  >;
85
135
 
86
136
  export const isAction = (data: unknown): data is Action =>
87
- isGraphNode(data) ? typeof data.data === 'function' : false;
137
+ isGraphNode(data) ? typeof data.data === 'function' && data.type === ActionType : false;
88
138
 
89
139
  export const actionGroupSymbol = Symbol('ActionGroup');
90
140
 
@@ -95,8 +145,34 @@ export type ActionGroup<TProperties extends Record<string, any> = Record<string,
95
145
  >;
96
146
 
97
147
  export const isActionGroup = (data: unknown): data is ActionGroup =>
98
- isGraphNode(data) ? data.data === actionGroupSymbol : false;
148
+ isGraphNode(data) ? data.data === actionGroupSymbol && data.type === ActionGroupType : false;
99
149
 
100
150
  export type ActionLike = Action | ActionGroup;
101
151
 
102
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
+ });
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export { scheduleTask, yieldOrContinue } from 'main-thread-scheduling';
@@ -0,0 +1,17 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // CF Workers / Node fallback: there is no main thread to yield to.
6
+ // scheduleTask runs the callback on the next microtask; yieldOrContinue is a microtask hop.
7
+
8
+ type ScheduleOptions = { strategy?: 'smooth' | 'interactive' | 'idle'; signal?: AbortSignal };
9
+
10
+ export const scheduleTask = async <T>(callback: () => T | Promise<T>, _options?: ScheduleOptions): Promise<T> => {
11
+ await Promise.resolve();
12
+ return callback();
13
+ };
14
+
15
+ export const yieldOrContinue = async (_priority: 'smooth' | 'interactive' | 'idle'): Promise<void> => {
16
+ await Promise.resolve();
17
+ };
@@ -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 { type Meta } from '@storybook/react-vite';
10
- import { Option, pipe } from 'effect';
11
- import React, { type PropsWithChildren, useCallback, useContext, useEffect, useMemo, useState } from 'react';
12
-
13
- import {
14
- live,
15
- isSpace,
16
- Query,
17
- type QueryResult,
18
- type Space,
19
- SpaceState,
20
- Expando,
21
- type Live,
22
- Filter,
23
- } from '@dxos/client/echo';
24
- import { Obj, Type } from '@dxos/echo';
25
- 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 { type Space, SpaceState, isSpace } from '@dxos/client/echo';
12
+ import { Filter, 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';
26
16
  import { type Client, useClient } from '@dxos/react-client';
27
17
  import { withClientProvider } from '@dxos/react-client/testing';
28
- import { Button, Input, Select } from '@dxos/react-ui';
29
- import { Path, Tree } from '@dxos/react-ui-list';
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,28 +247,31 @@ const Controls = ({ children }: PropsWithChildren) => {
245
247
  );
246
248
  };
247
249
 
248
- const meta: Meta = {
250
+ const meta = {
249
251
  title: 'sdk/app-graph/EchoGraph',
250
252
  decorators: [
253
+ withTheme(),
251
254
  withClientProvider({
252
255
  createIdentity: true,
253
- onIdentityCreated: async ({ client }) => {
256
+ types: [TestSchema.Expando],
257
+ onCreateIdentity: async ({ client }) => {
254
258
  await client.spaces.create();
255
259
  await client.spaces.create();
256
260
  },
257
261
  }),
258
- withTheme,
259
262
  ],
260
- };
263
+ } satisfies Meta;
261
264
 
262
265
  export default meta;
263
266
 
264
- export const JsonView = {
267
+ type Story = StoryObj<typeof meta>;
268
+
269
+ export const JsonView: Story = {
265
270
  render: () => {
266
271
  const client = useClient();
267
272
  const registry = useContext(RegistryContext);
268
273
  const graph = useMemo(() => createGraph(client, registry), [client, registry]);
269
- const data = useRxValue(graph.json());
274
+ const data = useAtomValue(graph.json());
270
275
 
271
276
  return (
272
277
  <>
@@ -277,99 +282,134 @@ export const JsonView = {
277
282
  },
278
283
  };
279
284
 
280
- export const TreeView = {
285
+ export const TreeView: Story = {
281
286
  render: () => {
282
287
  const client = useClient();
283
288
  const registry = useContext(RegistryContext);
284
289
  const graph = useMemo(() => createGraph(client, registry), [client, registry]);
285
- const state = useMemo(() => new Map<string, Live<{ open: boolean; current: boolean }>>(), []);
286
-
287
- const useItems = useCallback(
288
- (node?: Node, options?: { disposition?: string; sort?: boolean }) => {
289
- const connections = useRxValue(graph.connections(node?.id ?? ROOT_ID));
290
- 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;
291
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
+ ),
292
312
  [graph],
293
313
  );
294
314
 
295
- const getProps = useCallback(
296
- (node: Node, path: string[]) => {
297
- const children = graph
298
- .getConnections(node.id, 'outbound')
299
- .map((n) => {
300
- // Break cycles.
301
- const nextPath = [...path, node.id];
302
- return nextPath.includes(n.id) ? undefined : (n as Node);
303
- })
304
- .filter(isNonNullable) as Node[];
305
- const parentOf =
306
- children.length > 0 ? children.map(({ id }) => id) : node.properties.role === 'branch' ? [] : undefined;
307
- return {
308
- id: node.id,
309
- label: node.id,
310
- icon: node.type === 'dxos.org/type/Space' ? 'ph--planet--regular' : 'ph--placeholder--regular',
311
- parentOf,
312
- };
313
- },
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
+ ),
314
323
  [graph],
315
324
  );
316
325
 
317
- const isOpen = useCallback(
318
- (_path: string[]) => {
319
- const path = Path.create(..._path);
320
- const object = state.get(path) ?? live({ open: true, current: false });
321
- if (!state.has(path)) {
322
- state.set(path, object);
323
- }
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
+ );
324
355
 
325
- return object.open;
326
- },
327
- [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],
328
363
  );
329
364
 
330
- const isCurrent = useCallback(
331
- (_path: string[]) => {
332
- const path = Path.create(..._path);
333
- const object = state.get(path) ?? live({ open: false, current: false });
334
- if (!state.has(path)) {
335
- state.set(path, object);
336
- }
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
+ );
337
373
 
338
- return object.current;
339
- },
340
- [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],
341
383
  );
342
384
 
343
385
  const onOpenChange = useCallback(
344
386
  ({ path: _path, open }: { path: string[]; open: boolean }) => {
345
387
  const path = Path.create(..._path);
346
- const object = state.get(path);
347
- 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
+ }
348
393
  },
349
- [state],
394
+ [registry],
350
395
  );
351
396
 
352
397
  const onSelect = useCallback(
353
398
  ({ path: _path, current }: { path: string[]; current: boolean }) => {
354
399
  const path = Path.create(..._path);
355
- const object = state.get(path);
356
- 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
+ }
357
405
  },
358
- [state],
406
+ [registry],
359
407
  );
360
408
 
361
409
  return (
362
410
  <>
363
411
  <Controls />
364
- <Tree
365
- id={ROOT_ID}
366
- useItems={useItems}
367
- getProps={getProps}
368
- isOpen={isOpen}
369
- isCurrent={isCurrent}
370
- onOpenChange={onOpenChange}
371
- onSelect={onSelect}
372
- />
412
+ <Tree model={model} id={Node.RootId} onOpenChange={onOpenChange} onSelect={onSelect} />
373
413
  </>
374
414
  );
375
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';