@dxos/app-graph 0.10.0 → 0.11.1
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.
- package/dist/lib/chunk-graph-builder.mjs +1901 -0
- package/dist/lib/chunk-graph-builder.mjs.map +1 -0
- package/dist/lib/index.mjs +281 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/lib/scheduler.browser.mjs +2 -0
- package/dist/lib/scheduler.mjs +12 -0
- package/dist/lib/scheduler.mjs.map +1 -0
- package/dist/lib/testing.mjs +28 -0
- package/dist/lib/testing.mjs.map +1 -0
- package/dist/types/src/atoms.d.ts +1 -1
- package/dist/types/src/atoms.d.ts.map +1 -1
- package/dist/types/src/graph-builder.d.ts +128 -9
- package/dist/types/src/graph-builder.d.ts.map +1 -1
- package/dist/types/src/graph.d.ts +4 -1
- package/dist/types/src/graph.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +1 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/node-matcher.d.ts +1 -1
- package/dist/types/src/node-matcher.d.ts.map +1 -1
- package/dist/types/src/node.d.ts +14 -2
- package/dist/types/src/node.d.ts.map +1 -1
- package/dist/types/src/path-resolution.d.ts +69 -0
- package/dist/types/src/path-resolution.d.ts.map +1 -0
- package/dist/types/src/path-resolution.test.d.ts +2 -0
- package/dist/types/src/path-resolution.test.d.ts.map +1 -0
- package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
- package/dist/types/src/testing/setup-graph-builder.d.ts +1 -1
- package/dist/types/src/testing/setup-graph-builder.d.ts.map +1 -1
- package/dist/types/src/util.test.d.ts +2 -0
- package/dist/types/src/util.test.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +22 -21
- package/src/atoms.ts +1 -1
- package/src/graph-builder.test.ts +21 -1
- package/src/graph-builder.ts +327 -58
- package/src/graph.test.ts +1 -1
- package/src/graph.ts +22 -16
- package/src/index.ts +1 -0
- package/src/node-matcher.test.ts +1 -1
- package/src/node-matcher.ts +1 -1
- package/src/node.ts +19 -2
- package/src/path-resolution.test.ts +520 -0
- package/src/path-resolution.ts +355 -0
- package/src/stories/EchoGraph.stories.tsx +97 -121
- package/src/testing/setup-graph-builder.ts +1 -1
- package/src/util.test.ts +85 -0
- package/dist/lib/neutral/chunk-J5LGTIGS.mjs +0 -10
- package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +0 -7
- package/dist/lib/neutral/chunk-YMTZ7MPW.mjs +0 -1509
- package/dist/lib/neutral/chunk-YMTZ7MPW.mjs.map +0 -7
- package/dist/lib/neutral/index.mjs +0 -40
- package/dist/lib/neutral/index.mjs.map +0 -7
- package/dist/lib/neutral/meta.json +0 -1
- package/dist/lib/neutral/scheduler.mjs +0 -15
- package/dist/lib/neutral/scheduler.mjs.map +0 -7
- package/dist/lib/neutral/testing/index.mjs +0 -40
- package/dist/lib/neutral/testing/index.mjs.map +0 -7
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Array from 'effect/Array';
|
|
6
|
+
import * as Effect from 'effect/Effect';
|
|
7
|
+
import * as Function from 'effect/Function';
|
|
8
|
+
import * as Option from 'effect/Option';
|
|
9
|
+
import * as Record from 'effect/Record';
|
|
10
|
+
|
|
11
|
+
import { EffectEx } from '@dxos/effect';
|
|
12
|
+
import { EntityId, SpaceId } from '@dxos/keys';
|
|
13
|
+
import { log } from '@dxos/log';
|
|
14
|
+
import { Position } from '@dxos/util';
|
|
15
|
+
|
|
16
|
+
import * as Graph from './graph';
|
|
17
|
+
import * as GraphBuilder from './graph-builder';
|
|
18
|
+
import * as Node from './node';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A single `(prefix, id?)` pair as parsed by `@dxos/app-toolkit`'s `UrlPath.parse`. Kept as a
|
|
22
|
+
* plain structural type here (rather than importing `UrlPath.Pair`) because app-graph must not
|
|
23
|
+
* depend on app-toolkit.
|
|
24
|
+
*/
|
|
25
|
+
export type UrlPair = {
|
|
26
|
+
key: string;
|
|
27
|
+
id?: string;
|
|
28
|
+
workspace: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A resolved pair: the index it occupied in the parsed chain, and the qualified graph node id it
|
|
33
|
+
* resolved to. `null` in the caller's result array means the pair didn't resolve (unknown key or
|
|
34
|
+
* no matching node); how an unresolved pair is surfaced is the caller's concern.
|
|
35
|
+
*/
|
|
36
|
+
export type ResolvedPair = {
|
|
37
|
+
pairIndex: number;
|
|
38
|
+
nodeId: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** The graph-path representation of a node, the reverse of a `UrlPair` (`id` is absent for singleton keys). */
|
|
42
|
+
export type RepresentedNode = {
|
|
43
|
+
key: string;
|
|
44
|
+
id?: string;
|
|
45
|
+
workspace: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Reserved words that can never be registered as a `urlKey`, duplicated from `UrlPath.isReservedKey`
|
|
49
|
+
* (rather than imported) to keep app-graph free of an app-toolkit dependency. A key declared by a
|
|
50
|
+
* binding — including the `anchor` and `linked` tiers — is never reserved. */
|
|
51
|
+
const RESERVED_URL_KEYS = new Set(['reset', 'redirect', 'not-found']);
|
|
52
|
+
|
|
53
|
+
const isReservedUrlKey = (key: string): boolean =>
|
|
54
|
+
RESERVED_URL_KEYS.has(key) || SpaceId.isValid(key) || EntityId.isValid(key);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Ordered `urlKey`-declaring extensions: sorted by Position then insertion order (matching
|
|
58
|
+
* connector-ordering semantics elsewhere in this package), with reserved-word keys dropped (each with
|
|
59
|
+
* a `log.warn`). A single key may legitimately be shared by more than one extension (e.g. plugin-space
|
|
60
|
+
* declares `collection` on both the root-collection children connector and the nested-collection
|
|
61
|
+
* children connector, which together address any object reachable through a space's collection tree),
|
|
62
|
+
* so keys are NOT deduped here — {@link buildKeyTable} groups the sharers under one key and forward
|
|
63
|
+
* resolution matches a node produced by any of them. Shared with {@link buildUrlKeyTable} so the
|
|
64
|
+
* reservation rule is expressed exactly once.
|
|
65
|
+
*/
|
|
66
|
+
type UrlKeyedExtension = GraphBuilder.BuilderExtension & { url: GraphBuilder.UrlBinding };
|
|
67
|
+
|
|
68
|
+
/** Narrows to an extension that declared a URL binding, so callers need no non-null assertion. */
|
|
69
|
+
const isUrlKeyed = (extension: GraphBuilder.BuilderExtension): extension is UrlKeyedExtension => !!extension.url?.key;
|
|
70
|
+
|
|
71
|
+
const getKeyedExtensions = (builder: GraphBuilder.GraphBuilder): UrlKeyedExtension[] => {
|
|
72
|
+
const extensions = Function.pipe(Record.values(builder.getExtensions()), Array.sortBy(Position.compare));
|
|
73
|
+
|
|
74
|
+
const keyed: UrlKeyedExtension[] = [];
|
|
75
|
+
for (const extension of extensions) {
|
|
76
|
+
if (!isUrlKeyed(extension)) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (isReservedUrlKey(extension.url.key)) {
|
|
80
|
+
log.warn('reserved URL prefix key', { key: extension.url.key, extension: extension.id });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
keyed.push(extension);
|
|
84
|
+
}
|
|
85
|
+
return keyed;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build the global `urlKey -> extensionIds` table from the builder's current extensions. Recomputed
|
|
90
|
+
* on every call — cheap (a synchronous scan of already-registered extensions) and always current, so
|
|
91
|
+
* activating/deactivating plugins can never leave a stale table around. A key maps to the ordered list
|
|
92
|
+
* of every extension that declared it (usually one); forward resolution treats a node produced by any
|
|
93
|
+
* of them as a match for the key.
|
|
94
|
+
*/
|
|
95
|
+
const buildKeyTable = (builder: GraphBuilder.GraphBuilder): Map<string, string[]> => {
|
|
96
|
+
const table = new Map<string, string[]>();
|
|
97
|
+
for (const extension of getKeyedExtensions(builder)) {
|
|
98
|
+
const key = extension.url.key;
|
|
99
|
+
const existing = table.get(key);
|
|
100
|
+
if (existing) {
|
|
101
|
+
existing.push(extension.id);
|
|
102
|
+
} else {
|
|
103
|
+
table.set(key, [extension.id]);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return table;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A single registered URL prefix key, in the shape `UrlPath.parse` expects. Kept as a plain
|
|
111
|
+
* structural type here (rather than importing `UrlPath.KeyTableEntry`) because app-graph must not
|
|
112
|
+
* depend on app-toolkit.
|
|
113
|
+
*/
|
|
114
|
+
export type UrlKeyTableEntry = { key: string; hasId: boolean; anchor: boolean };
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build the `urlKey -> { key, hasId }` table consumed by `UrlPath.parse`, straight from the
|
|
118
|
+
* builder's current `urlKey`/`urlKeyHasId` declarations — the "registration, not parser" property
|
|
119
|
+
* the URL grammar requires. Callers (the layout url-handler) pass this to `UrlPath.parse`
|
|
120
|
+
* to tokenize a pathname into a pair chain.
|
|
121
|
+
*/
|
|
122
|
+
export const buildUrlKeyTable = (builder: GraphBuilder.GraphBuilder): Map<string, UrlKeyTableEntry> => {
|
|
123
|
+
const table = new Map<string, UrlKeyTableEntry>();
|
|
124
|
+
// The grammar's fixed tiers are configured on the builder, not declared by any extension: the anchor
|
|
125
|
+
// rebases the chain, and the linked key addresses a `~<variant>` child of the preceding item.
|
|
126
|
+
const { anchorKey, linkedKey } = builder.urlGrammar;
|
|
127
|
+
if (anchorKey) {
|
|
128
|
+
table.set(anchorKey, { key: anchorKey, hasId: true, anchor: true });
|
|
129
|
+
}
|
|
130
|
+
if (linkedKey) {
|
|
131
|
+
table.set(linkedKey, { key: linkedKey, hasId: true, anchor: false });
|
|
132
|
+
}
|
|
133
|
+
for (const extension of getKeyedExtensions(builder)) {
|
|
134
|
+
const key = extension.url.key;
|
|
135
|
+
// The tokenizer's flat lookup is derived from `kind`: a singleton has no id.
|
|
136
|
+
const hasId = extension.url.kind !== 'singleton';
|
|
137
|
+
const anchor = false;
|
|
138
|
+
const existing = table.get(key);
|
|
139
|
+
if (existing && existing.hasId !== hasId) {
|
|
140
|
+
// Extensions that share a key must agree on their kind — the parse table has one entry per key.
|
|
141
|
+
// A mismatch is a declaration bug; keep the first and warn.
|
|
142
|
+
log.warn('conflicting kind for shared URL prefix key', { key, extension: extension.id });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
table.set(key, { key, hasId, anchor });
|
|
146
|
+
}
|
|
147
|
+
return table;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Expand every ancestor prefix of a qualified node id (including the id itself), then flush once.
|
|
152
|
+
* Mirrors `@dxos/app-toolkit`'s `NotFound.expandPath` technique, reimplemented locally so app-graph
|
|
153
|
+
* doesn't depend on app-toolkit.
|
|
154
|
+
*/
|
|
155
|
+
const expandAncestors = async (builder: GraphBuilder.GraphBuilder, qualifiedId: string): Promise<void> => {
|
|
156
|
+
const segments = qualifiedId.split('/');
|
|
157
|
+
for (let index = 1; index <= segments.length; index++) {
|
|
158
|
+
Graph.expand(builder.graph, segments.slice(0, index).join('/'), 'child');
|
|
159
|
+
}
|
|
160
|
+
await GraphBuilder.flush(builder);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/** An extension registered for a URL key: its path (static segments or a dynamic resolver). */
|
|
164
|
+
type KeyedExtension = { id: string; path: string[] | GraphBuilder.PathResolver };
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Materialize a candidate qualified node id and confirm it exists: expand its ancestors, then check
|
|
168
|
+
* the node is known. Returns the id on success, `null` otherwise.
|
|
169
|
+
*/
|
|
170
|
+
const materializeCandidate = async (
|
|
171
|
+
builder: GraphBuilder.GraphBuilder,
|
|
172
|
+
candidateId: string,
|
|
173
|
+
): Promise<string | null> => {
|
|
174
|
+
await expandAncestors(builder, candidateId);
|
|
175
|
+
return Option.isSome(Graph.getNode(builder.graph, candidateId)) ? candidateId : null;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Resolve a single `(key, id)` pair to a qualified node id, anchored at the workspace base. Resolution
|
|
180
|
+
* is fully explicit — no search. Each keyed extension's `path` is one of:
|
|
181
|
+
* 1. Static segments (`string[]`, the preferred deterministic case): the id is the `+`-joined node
|
|
182
|
+
* segments *after* the path, so a fixed-depth nested shape (e.g. `db/<slug>+<id>`) resolves with
|
|
183
|
+
* no resolver — split the id back into segments and expand the exact path.
|
|
184
|
+
* 2. A dynamic {@link GraphBuilder.PathResolver} (recursive/mutable shapes, i.e. nested collections),
|
|
185
|
+
* whose candidate is materialized and verified the same way.
|
|
186
|
+
* Static paths are tried before resolvers; an unmatched pair yields `null`.
|
|
187
|
+
*/
|
|
188
|
+
const resolveKeyId = async (
|
|
189
|
+
builder: GraphBuilder.GraphBuilder,
|
|
190
|
+
workspaceBaseId: string,
|
|
191
|
+
workspace: string,
|
|
192
|
+
extensions: ReadonlyArray<KeyedExtension>,
|
|
193
|
+
id: string,
|
|
194
|
+
): Promise<string | null> => {
|
|
195
|
+
// 1. Static segments: an exact candidate, no search (type sections, database/inbox objects, etc.).
|
|
196
|
+
const idSegments = id.split(builder.urlGrammar.tailSeparator);
|
|
197
|
+
for (const extension of extensions) {
|
|
198
|
+
if (Array.isArray(extension.path)) {
|
|
199
|
+
const resolved = await materializeCandidate(
|
|
200
|
+
builder,
|
|
201
|
+
[workspaceBaseId, ...extension.path, ...idSegments].join('/'),
|
|
202
|
+
);
|
|
203
|
+
if (resolved) {
|
|
204
|
+
return resolved;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 2. Dynamic resolver: the extension computes the candidate id from runtime data (self-contained
|
|
210
|
+
// Effect; a defect degrades to no candidate rather than crashing resolution).
|
|
211
|
+
for (const extension of extensions) {
|
|
212
|
+
if (typeof extension.path === 'function') {
|
|
213
|
+
const candidateId = await EffectEx.runPromise(
|
|
214
|
+
extension.path({ id, workspace, workspaceBaseId }).pipe(Effect.catchAllDefect(() => Effect.succeed(null))),
|
|
215
|
+
);
|
|
216
|
+
if (candidateId) {
|
|
217
|
+
const resolved = await materializeCandidate(builder, candidateId);
|
|
218
|
+
if (resolved) {
|
|
219
|
+
return resolved;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return null;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Resolve a linked pair (`<key>/<variant>`) against the item it attaches to: the linked-segment child
|
|
230
|
+
* (`<precedingNodeId>/~<variant>`) of `precedingNodeId`. A single expand, no BFS — a linked node is
|
|
231
|
+
* always a direct child of the item it attaches to. Matched by the variant (the `~`-stripped last
|
|
232
|
+
* segment), so it works regardless of which extension produced the node.
|
|
233
|
+
*/
|
|
234
|
+
const resolveLinked = async (
|
|
235
|
+
builder: GraphBuilder.GraphBuilder,
|
|
236
|
+
precedingNodeId: string,
|
|
237
|
+
variant: string,
|
|
238
|
+
): Promise<string | null> => {
|
|
239
|
+
Graph.expand(builder.graph, precedingNodeId, 'child');
|
|
240
|
+
await GraphBuilder.flush(builder);
|
|
241
|
+
|
|
242
|
+
const linkedSegment = `${builder.urlGrammar.linkedPrefix}${variant}`;
|
|
243
|
+
const match = Graph.getConnections(builder.graph, precedingNodeId, 'child').find(
|
|
244
|
+
(child) => child.id.slice(child.id.lastIndexOf('/') + 1) === linkedSegment,
|
|
245
|
+
);
|
|
246
|
+
return match?.id ?? null;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const resolveUrlAsync = async (
|
|
250
|
+
builder: GraphBuilder.GraphBuilder,
|
|
251
|
+
parsed: { workspace: string; pairs: ReadonlyArray<UrlPair> },
|
|
252
|
+
): Promise<Array<ResolvedPair | null>> => {
|
|
253
|
+
const keyTable = buildKeyTable(builder);
|
|
254
|
+
const allExtensions = builder.getExtensions();
|
|
255
|
+
const results: Array<ResolvedPair | null> = [];
|
|
256
|
+
// Tracks the most recently resolved *item* node, the base for `linked` pairs — a linked pair
|
|
257
|
+
// always attaches to the preceding item, never to another linked pair.
|
|
258
|
+
let lastItemNodeId: string | undefined;
|
|
259
|
+
|
|
260
|
+
for (let pairIndex = 0; pairIndex < parsed.pairs.length; pairIndex++) {
|
|
261
|
+
const pair = parsed.pairs[pairIndex];
|
|
262
|
+
|
|
263
|
+
// Linked pair: resolves against the preceding item by variant, not the workspace base — the linked
|
|
264
|
+
// resolution tier. Produced by no extension (it is a grammar key), so it is matched before the key
|
|
265
|
+
// table, and it is not itself an item, so it does not become the base for a following linked pair.
|
|
266
|
+
if (pair.key === builder.urlGrammar.linkedKey) {
|
|
267
|
+
const nodeId = lastItemNodeId && pair.id ? await resolveLinked(builder, lastItemNodeId, pair.id) : null;
|
|
268
|
+
results.push(nodeId ? { pairIndex, nodeId } : null);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const extensionIdList = keyTable.get(pair.key);
|
|
273
|
+
if (!extensionIdList || extensionIdList.length === 0) {
|
|
274
|
+
log.warn('unknown URL prefix key', { key: pair.key });
|
|
275
|
+
results.push(null);
|
|
276
|
+
if (pair.id !== undefined) {
|
|
277
|
+
lastItemNodeId = undefined;
|
|
278
|
+
}
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const workspaceBaseId = `${Node.RootId}/${pair.workspace}`;
|
|
283
|
+
const extensions: KeyedExtension[] = [];
|
|
284
|
+
for (const extensionId of extensionIdList) {
|
|
285
|
+
const url = allExtensions[extensionId]?.url;
|
|
286
|
+
if (url) {
|
|
287
|
+
extensions.push({ id: extensionId, path: url.path });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// A normal key addresses a node by id; an id-less singleton key (e.g. `home`) addresses a fixed node
|
|
291
|
+
// whose terminal segment IS the key — resolve it the same way with the key standing in for the id
|
|
292
|
+
// (`root/<ws>/<...path>/<key>`).
|
|
293
|
+
const nodeId = await resolveKeyId(builder, workspaceBaseId, pair.workspace, extensions, pair.id ?? pair.key);
|
|
294
|
+
results.push(nodeId ? { pairIndex, nodeId } : null);
|
|
295
|
+
lastItemNodeId = nodeId ?? undefined;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return results;
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Resolve a parsed URL's pair chain to graph node ids, walking left to right. Resolution is fully
|
|
303
|
+
* explicit — each keyed extension declares either a static `urlPath` template (preferred) or a dynamic
|
|
304
|
+
* `resolve` Effect (data-dependent shapes); there is no generic search. Reverse mapping still uses the
|
|
305
|
+
* provenance the builder tracks (see `GraphBuilder.getNodeExtensionId`).
|
|
306
|
+
*
|
|
307
|
+
* An unknown key, or a key whose extension produces no matching node, yields `null` at that index;
|
|
308
|
+
* how a `null` is surfaced is the caller's concern. A linked pair resolves against the *preceding
|
|
309
|
+
* item's* node, not the raw preceding pair.
|
|
310
|
+
*/
|
|
311
|
+
export const resolveUrl = (
|
|
312
|
+
builder: GraphBuilder.GraphBuilder,
|
|
313
|
+
parsed: { workspace: string; pairs: ReadonlyArray<UrlPair> },
|
|
314
|
+
): Effect.Effect<Array<ResolvedPair | null>> => Effect.promise(() => resolveUrlAsync(builder, parsed));
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Reverse-map a graph node id back to its `(key, id?, workspace)` representation, the inverse of
|
|
318
|
+
* `resolveUrl`. A linked node (a `~<variant>` segment) maps to the declared `linked` key
|
|
319
|
+
* with the variant as its id — independent of the producing extension, so every linked node is
|
|
320
|
+
* addressable. Any other node maps via its producing extension's `urlKey` (`getNodeExtensionId`);
|
|
321
|
+
* a node with no key-declaring producer returns `Option.none()` (unmapped — serialization skips it
|
|
322
|
+
* with a dev-time warning one layer up, per the design's "unmapped nodes" rule).
|
|
323
|
+
*/
|
|
324
|
+
export const representNode = (builder: GraphBuilder.GraphBuilder, nodeId: string): Option.Option<RepresentedNode> => {
|
|
325
|
+
const segments = nodeId.split('/');
|
|
326
|
+
// Canonical node ids are `root/<workspace>/...`; the workspace is always the second segment.
|
|
327
|
+
const workspace = segments[1];
|
|
328
|
+
if (!workspace) {
|
|
329
|
+
return Option.none();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const lastSegment = segments[segments.length - 1];
|
|
333
|
+
if (lastSegment.startsWith(builder.urlGrammar.linkedPrefix)) {
|
|
334
|
+
// Linked node: keyed by the grammar's `linked` key, with the variant (the `~`-stripped segment) as
|
|
335
|
+
// its id — matched by the convention, independent of the producing extension.
|
|
336
|
+
const linkedKey = builder.urlGrammar.linkedKey;
|
|
337
|
+
if (linkedKey) {
|
|
338
|
+
return Option.some({ key: linkedKey, id: lastSegment.slice(builder.urlGrammar.linkedPrefix.length), workspace });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const extensionId = builder.getNodeExtensionId(nodeId);
|
|
343
|
+
if (!extensionId) {
|
|
344
|
+
return Option.none();
|
|
345
|
+
}
|
|
346
|
+
const url = builder.getExtensions()[extensionId]?.url;
|
|
347
|
+
if (!url) {
|
|
348
|
+
return Option.none();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// The (key, id?) representation is derived from the node id + binding (a singleton has no id; a
|
|
352
|
+
// resolver-backed key keeps just the object id; a static path `+`-joins the segments after the path) —
|
|
353
|
+
// the same derivation the builder uses to stamp `urlSegment`.
|
|
354
|
+
return Option.some({ ...GraphBuilder.urlRepresentation(nodeId, url, builder.urlGrammar.tailSeparator), workspace });
|
|
355
|
+
};
|
|
@@ -6,7 +6,7 @@ import { Atom, type Registry, RegistryContext, useAtomValue } from '@effect-atom
|
|
|
6
6
|
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
7
7
|
import * as Function from 'effect/Function';
|
|
8
8
|
import * as Option from 'effect/Option';
|
|
9
|
-
import React, { type PropsWithChildren, useCallback, useContext, useEffect, useMemo,
|
|
9
|
+
import React, { type PropsWithChildren, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
10
10
|
|
|
11
11
|
import { type Space, SpaceState, isSpace } from '@dxos/client/echo';
|
|
12
12
|
import { Filter, Obj, Query } from '@dxos/echo';
|
|
@@ -15,7 +15,6 @@ import { random } from '@dxos/random';
|
|
|
15
15
|
import { type Client, useClient } from '@dxos/react-client';
|
|
16
16
|
import { withClientProvider } from '@dxos/react-client/testing';
|
|
17
17
|
import { Icon, IconButton, Input, Select } from '@dxos/react-ui';
|
|
18
|
-
import { Path, Tree, type TreeModel } from '@dxos/react-ui-list';
|
|
19
18
|
import { withTheme } from '@dxos/react-ui/testing';
|
|
20
19
|
import { getSize, mx } from '@dxos/ui-theme';
|
|
21
20
|
import { safeParseInt } from '@dxos/util';
|
|
@@ -281,134 +280,111 @@ export const JsonView: Story = {
|
|
|
281
280
|
},
|
|
282
281
|
};
|
|
283
282
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
[graph],
|
|
312
|
-
);
|
|
283
|
+
/**
|
|
284
|
+
* One row of {@link GraphTree}. Subscribes to just its own node and child list, so a mutation anywhere in
|
|
285
|
+
* the graph re-renders only the rows it actually touches — which is the behaviour this story exists to show.
|
|
286
|
+
*/
|
|
287
|
+
const GraphTreeItem = ({
|
|
288
|
+
graph,
|
|
289
|
+
id,
|
|
290
|
+
ancestors,
|
|
291
|
+
selectedId,
|
|
292
|
+
onSelect,
|
|
293
|
+
}: {
|
|
294
|
+
graph: Graph.ExpandableGraph;
|
|
295
|
+
id: string;
|
|
296
|
+
ancestors: readonly string[];
|
|
297
|
+
selectedId?: string;
|
|
298
|
+
onSelect: (id: string) => void;
|
|
299
|
+
}) => {
|
|
300
|
+
const [open, setOpen] = useState(true);
|
|
301
|
+
const node = Option.getOrUndefined(useAtomValue(graph.node(id)));
|
|
302
|
+
const children = useAtomValue(graph.connections(id, 'child'));
|
|
303
|
+
|
|
304
|
+
// The graph may be cyclic; recursing into an id already on the path would never terminate.
|
|
305
|
+
const path = useMemo(() => [...ancestors, id], [ancestors, id]);
|
|
306
|
+
const safeChildren = useMemo(() => children.filter((child) => !path.includes(child.id)), [children, path]);
|
|
307
|
+
|
|
308
|
+
const icon = node?.type === 'org.dxos.type.space' ? 'ph--planet--regular' : 'ph--circle-dashed--regular';
|
|
309
|
+
const expandable = safeChildren.length > 0;
|
|
313
310
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
}),
|
|
361
|
-
[getOrCreateState],
|
|
362
|
-
);
|
|
311
|
+
return (
|
|
312
|
+
<div role='treeitem' aria-expanded={expandable ? open : undefined}>
|
|
313
|
+
<div
|
|
314
|
+
className={mx(
|
|
315
|
+
'flex items-center gap-1 p-1 rounded cursor-pointer',
|
|
316
|
+
selectedId === id && 'bg-primary-500 text-primary-100',
|
|
317
|
+
)}
|
|
318
|
+
style={{ paddingInlineStart: `${ancestors.length}rem` }}
|
|
319
|
+
onClick={() => onSelect(id)}
|
|
320
|
+
>
|
|
321
|
+
{expandable ? (
|
|
322
|
+
<IconButton
|
|
323
|
+
iconOnly
|
|
324
|
+
variant='ghost'
|
|
325
|
+
density='sm'
|
|
326
|
+
icon={open ? 'ph--caret-down--regular' : 'ph--caret-right--regular'}
|
|
327
|
+
label={open ? 'Collapse' : 'Expand'}
|
|
328
|
+
onClick={(event) => {
|
|
329
|
+
// Toggling disclosure must not also select the row.
|
|
330
|
+
event.stopPropagation();
|
|
331
|
+
setOpen((open) => !open);
|
|
332
|
+
}}
|
|
333
|
+
/>
|
|
334
|
+
) : (
|
|
335
|
+
<Icon icon='ph--dot--regular' classNames={getSize(4)} />
|
|
336
|
+
)}
|
|
337
|
+
<Icon icon={icon} classNames={getSize(4)} />
|
|
338
|
+
<span className='truncate'>{node?.id ?? id}</span>
|
|
339
|
+
</div>
|
|
340
|
+
{expandable && open && (
|
|
341
|
+
<div role='group'>
|
|
342
|
+
{safeChildren.map((child) => (
|
|
343
|
+
<GraphTreeItem
|
|
344
|
+
key={child.id}
|
|
345
|
+
graph={graph}
|
|
346
|
+
id={child.id}
|
|
347
|
+
ancestors={path}
|
|
348
|
+
selectedId={selectedId}
|
|
349
|
+
onSelect={onSelect}
|
|
350
|
+
/>
|
|
351
|
+
))}
|
|
352
|
+
</div>
|
|
353
|
+
)}
|
|
354
|
+
</div>
|
|
355
|
+
);
|
|
356
|
+
};
|
|
363
357
|
|
|
364
|
-
|
|
365
|
-
() =>
|
|
366
|
-
Atom.family((pathKey: string) => {
|
|
367
|
-
const stateAtom = getOrCreateState(pathKey);
|
|
368
|
-
return Atom.make((get) => get(stateAtom).current);
|
|
369
|
-
}),
|
|
370
|
-
[getOrCreateState],
|
|
371
|
-
);
|
|
358
|
+
const NO_ANCESTORS: readonly string[] = [];
|
|
372
359
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
itemProps: (path: string[]) => itemPropsFamily(path.join('~')),
|
|
378
|
-
itemOpen: (path: string[]) => itemOpenFamily(Path.create(...path)),
|
|
379
|
-
itemCurrent: (path: string[]) => itemCurrentFamily(Path.create(...path)),
|
|
380
|
-
}),
|
|
381
|
-
[childIdsFamily, itemFamily, itemPropsFamily, itemOpenFamily, itemCurrentFamily],
|
|
382
|
-
);
|
|
360
|
+
/** Minimal tree view over the graph, built from `@dxos/react-ui` primitives only. */
|
|
361
|
+
const GraphTree = ({ graph }: { graph: Graph.ExpandableGraph }) => {
|
|
362
|
+
const [selectedId, setSelectedId] = useState<string>();
|
|
363
|
+
const onSelect = useCallback((id: string) => setSelectedId((current) => (current === id ? undefined : id)), []);
|
|
383
364
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
365
|
+
return (
|
|
366
|
+
<div role='tree' className='p-2 overflow-auto'>
|
|
367
|
+
<GraphTreeItem
|
|
368
|
+
graph={graph}
|
|
369
|
+
id={Node.RootId}
|
|
370
|
+
ancestors={NO_ANCESTORS}
|
|
371
|
+
selectedId={selectedId}
|
|
372
|
+
onSelect={onSelect}
|
|
373
|
+
/>
|
|
374
|
+
</div>
|
|
375
|
+
);
|
|
376
|
+
};
|
|
395
377
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
const prev = registry.get(atom);
|
|
402
|
-
registry.set(atom, { ...prev, current });
|
|
403
|
-
}
|
|
404
|
-
},
|
|
405
|
-
[registry],
|
|
406
|
-
);
|
|
378
|
+
export const TreeView: Story = {
|
|
379
|
+
render: () => {
|
|
380
|
+
const client = useClient();
|
|
381
|
+
const registry = useContext(RegistryContext);
|
|
382
|
+
const graph = useMemo(() => createGraph(client, registry), [client, registry]);
|
|
407
383
|
|
|
408
384
|
return (
|
|
409
385
|
<>
|
|
410
386
|
<Controls />
|
|
411
|
-
<
|
|
387
|
+
<GraphTree graph={graph} />
|
|
412
388
|
</>
|
|
413
389
|
);
|
|
414
390
|
},
|