@hydranium/glsp-client 0.0.0-stage → 1.0.0-next.298

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 (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -2
  3. package/lib/action-dispatcher.d.ts +89 -0
  4. package/lib/action-dispatcher.d.ts.map +1 -0
  5. package/lib/action-dispatcher.js +257 -0
  6. package/lib/action-dispatcher.js.map +1 -0
  7. package/lib/diagram-load-failed.d.ts +20 -0
  8. package/lib/diagram-load-failed.d.ts.map +1 -0
  9. package/lib/diagram-load-failed.js +24 -0
  10. package/lib/diagram-load-failed.js.map +1 -0
  11. package/lib/diagram-loader.d.ts +124 -0
  12. package/lib/diagram-loader.d.ts.map +1 -0
  13. package/lib/diagram-loader.js +154 -0
  14. package/lib/diagram-loader.js.map +1 -0
  15. package/lib/edge.d.ts +40 -0
  16. package/lib/edge.d.ts.map +1 -0
  17. package/lib/edge.js +56 -0
  18. package/lib/edge.js.map +1 -0
  19. package/lib/glsp-client-module.d.ts +37 -0
  20. package/lib/glsp-client-module.d.ts.map +1 -0
  21. package/lib/glsp-client-module.js +66 -0
  22. package/lib/glsp-client-module.js.map +1 -0
  23. package/lib/glsp-client.d.ts +17 -0
  24. package/lib/glsp-client.d.ts.map +1 -0
  25. package/lib/glsp-client.js +23 -0
  26. package/lib/glsp-client.js.map +1 -0
  27. package/lib/hidden-bounds-updater.d.ts +119 -0
  28. package/lib/hidden-bounds-updater.d.ts.map +1 -0
  29. package/lib/hidden-bounds-updater.js +210 -0
  30. package/lib/hidden-bounds-updater.js.map +1 -0
  31. package/lib/index.d.ts +20 -0
  32. package/lib/index.d.ts.map +1 -0
  33. package/lib/index.js +41 -0
  34. package/lib/index.js.map +1 -0
  35. package/lib/messages/index.d.ts +10 -0
  36. package/lib/messages/index.d.ts.map +1 -0
  37. package/lib/messages/index.js +20 -0
  38. package/lib/messages/index.js.map +1 -0
  39. package/lib/select-mouse-listener.d.ts +30 -0
  40. package/lib/select-mouse-listener.d.ts.map +1 -0
  41. package/lib/select-mouse-listener.js +51 -0
  42. package/lib/select-mouse-listener.js.map +1 -0
  43. package/lib/sprotty-logger-adapter.d.ts +61 -0
  44. package/lib/sprotty-logger-adapter.d.ts.map +1 -0
  45. package/lib/sprotty-logger-adapter.js +130 -0
  46. package/lib/sprotty-logger-adapter.js.map +1 -0
  47. package/lib/status-overlay.d.ts +23 -0
  48. package/lib/status-overlay.d.ts.map +1 -0
  49. package/lib/status-overlay.js +52 -0
  50. package/lib/status-overlay.js.map +1 -0
  51. package/lib/testing/bind-recorder.d.ts +67 -0
  52. package/lib/testing/bind-recorder.d.ts.map +1 -0
  53. package/lib/testing/bind-recorder.js +72 -0
  54. package/lib/testing/bind-recorder.js.map +1 -0
  55. package/lib/testing/index.d.ts +10 -0
  56. package/lib/testing/index.d.ts.map +1 -0
  57. package/lib/testing/index.js +28 -0
  58. package/lib/testing/index.js.map +1 -0
  59. package/lib/types.d.ts +27 -0
  60. package/lib/types.d.ts.map +1 -0
  61. package/lib/types.js +33 -0
  62. package/lib/types.js.map +1 -0
  63. package/package.json +79 -4
  64. package/src/action-dispatcher.ts +273 -0
  65. package/src/diagram-load-failed.ts +22 -0
  66. package/src/diagram-loader.ts +173 -0
  67. package/src/edge.ts +65 -0
  68. package/src/glsp-client-module.ts +79 -0
  69. package/src/glsp-client.ts +20 -0
  70. package/src/hidden-bounds-updater.ts +210 -0
  71. package/src/index.ts +25 -0
  72. package/src/messages/index.ts +17 -0
  73. package/src/select-mouse-listener.ts +54 -0
  74. package/src/sprotty-logger-adapter.ts +121 -0
  75. package/src/status-overlay.ts +43 -0
  76. package/src/testing/bind-recorder.ts +126 -0
  77. package/src/testing/index.ts +13 -0
  78. package/src/types.ts +34 -0
@@ -0,0 +1,273 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ import {
11
+ type Action,
12
+ CheckEdgeResultAction,
13
+ ComputedBoundsAction,
14
+ GLSPActionDispatcher,
15
+ GLSPModelSource,
16
+ LocalComputedBoundsAction,
17
+ MessageAction,
18
+ RejectAction,
19
+ RequestAction,
20
+ RequestBoundsAction,
21
+ RequestCheckEdgeAction,
22
+ RequestContextActions,
23
+ RequestModelAction,
24
+ RequestTypeHintsAction,
25
+ ResponseAction,
26
+ ServerAction,
27
+ SetDirtyStateAction,
28
+ SetEditModeAction,
29
+ SetMarkersAction,
30
+ SetModelAction,
31
+ SetTypeHintsAction,
32
+ StatusAction,
33
+ UpdateModelAction
34
+ } from '@eclipse-glsp/client';
35
+ import { type Clock, type Logger, type Stopwatch, SystemClock, type Tracer } from '@hydranium/protocol';
36
+ import { inject, injectable, optional, unmanaged } from 'inversify';
37
+ import { HydraniumClientTypes } from './types';
38
+
39
+ /** Default subset of action kinds the framework dispatcher will log: the GLSP
40
+ * traffic an observer reading the log typically wants to see. All
41
+ * are standard `@eclipse-glsp/client` kinds; adopters with custom action kinds
42
+ * extend via `extraLoggedKinds`. */
43
+ export const HYDRANIUM_DEFAULT_LOGGED_KINDS: ReadonlySet<string> = new Set<string>([
44
+ SetModelAction.KIND,
45
+ UpdateModelAction.KIND,
46
+ RequestBoundsAction.KIND,
47
+ ComputedBoundsAction.KIND,
48
+ SetDirtyStateAction.KIND,
49
+ SetEditModeAction.KIND,
50
+ SetMarkersAction.KIND,
51
+ SetTypeHintsAction.KIND,
52
+ StatusAction.KIND,
53
+ MessageAction.KIND,
54
+ RejectAction.KIND,
55
+ RequestContextActions.KIND,
56
+ RequestModelAction.KIND,
57
+ RequestTypeHintsAction.KIND,
58
+ RequestCheckEdgeAction.KIND,
59
+ CheckEdgeResultAction.KIND
60
+ ]);
61
+
62
+ /** Default request → response kind pairs for GLSP 'flow' actions that do NOT
63
+ * carry a requestId/responseId — so the dispatcher matches by kind (FIFO).
64
+ * Id-based matching still wins when a requestId is present. */
65
+ export const HYDRANIUM_DEFAULT_KIND_PAIRS: ReadonlyMap<string, string> = new Map<string, string>([
66
+ [RequestModelAction.KIND, SetModelAction.KIND],
67
+ [RequestTypeHintsAction.KIND, SetTypeHintsAction.KIND],
68
+ [RequestBoundsAction.KIND, ComputedBoundsAction.KIND]
69
+ ]);
70
+
71
+ /** Additive options. Adopters with custom action kinds pass extras here; the
72
+ * framework defaults always apply (you cannot un-log a kind the framework
73
+ * considers worth logging — subclass + override `dispatch` if you must). */
74
+ export interface HydraniumGlspActionDispatcherOptions {
75
+ readonly extraLoggedKinds?: Iterable<string>;
76
+ readonly extraKindPairs?: Iterable<readonly [string, string]>;
77
+ /** Replaces the default `summarize` body entirely. Use for adopter-domain
78
+ * action kinds where the framework default returns ''. */
79
+ readonly summarize?: (action: Action) => string;
80
+ }
81
+
82
+ /**
83
+ * Wraps every dispatch so action traffic appears in the host's log, the Theia
84
+ * Output channel or a page's console, interleaved with server log lines where
85
+ * the host shows both. Instrumenting the dispatcher
86
+ * (rather than per-kind handlers) catches ResponseActions that bypass the
87
+ * handler pipeline.
88
+ *
89
+ * The framework defaults cover the standard GLSP kinds worth logging, not every
90
+ * kind the protocol defines. Adopters with custom kinds either pass
91
+ * `extraLoggedKinds` / `extraKindPairs` to the constructor (via a
92
+ * `toDynamicValue` binding) or subclass and override.
93
+ */
94
+ @injectable()
95
+ export class HydraniumGlspActionDispatcher extends GLSPActionDispatcher {
96
+ @inject(HydraniumClientTypes.Logger) protected readonly hydraniumLogger!: Logger;
97
+ @inject(HydraniumClientTypes.Tracer) protected readonly tracer!: Tracer;
98
+ /** Times each request to its response: the diagram container's {@link Clock}, or a `SystemClock`. */
99
+ @inject(HydraniumClientTypes.Clock) @optional() protected readonly clock: Clock = new SystemClock();
100
+
101
+ protected readonly loggedKinds: ReadonlySet<string>;
102
+ protected readonly kindPairs: ReadonlyMap<string, string>;
103
+ protected readonly kindPairsReverse: ReadonlyMap<string, string>;
104
+
105
+ protected pairCounter = 0;
106
+ /** Pending requests keyed by requestId (for actions that carry one). */
107
+ protected pendingById = new Map<string, { id: number; sinceRequest: Stopwatch }>();
108
+ /** FIFO queue per response kind, used for `flow` pairs without requestId/responseId. */
109
+ protected pendingByKind = new Map<string, Array<{ id: number; sinceRequest: Stopwatch }>>();
110
+
111
+ constructor(@unmanaged() options?: HydraniumGlspActionDispatcherOptions) {
112
+ super();
113
+ const extraLogged = options?.extraLoggedKinds ? Array.from(options.extraLoggedKinds) : [];
114
+ this.loggedKinds = new Set<string>([...HYDRANIUM_DEFAULT_LOGGED_KINDS, ...extraLogged]);
115
+ const extraPairs = options?.extraKindPairs ? Array.from(options.extraKindPairs) : [];
116
+ const pairs = new Map<string, string>([...HYDRANIUM_DEFAULT_KIND_PAIRS, ...extraPairs]);
117
+ this.kindPairs = pairs;
118
+ this.kindPairsReverse = new Map<string, string>(Array.from(pairs, ([req, res]) => [res, req]));
119
+ if (options?.summarize) {
120
+ this.summarize = options.summarize;
121
+ }
122
+ }
123
+
124
+ override initialize(): Promise<void> {
125
+ // `initialize()` is called many times; super is idempotent, but our callback registration
126
+ // isn't. Register the timing log only on the first call (when super.initialized is unset).
127
+ if (!this.initialized) {
128
+ this.registerInitTiming();
129
+ }
130
+ return super.initialize();
131
+ }
132
+
133
+ /**
134
+ * Register the one-shot 'model initialized' timing line. Instrumentation
135
+ * only — guarded so a tracer failure degrades logging rather than aborting
136
+ * diagram init. `initialize()` runs inside `DiagramLoader.load()`, whose
137
+ * caller (`GLSPDiagramWidget.onAfterAttach`) neither awaits nor catches the
138
+ * returned promise; an error thrown here would therefore escape as an
139
+ * uncaught rejection that blanks the diagram with no server-side signal.
140
+ */
141
+ protected registerInitTiming(): void {
142
+ try {
143
+ this.tracer.time('Model initialized', () => this.onceModelInitialized(), 'info', { logAfterMs: 0 });
144
+ } catch (err) {
145
+ this.hydraniumLogger.error('Failed to register model-initialized timing instrumentation', err);
146
+ }
147
+ }
148
+
149
+ override dispatch(action: Action): Promise<void> {
150
+ if (this.loggedKinds.has(action.kind)) {
151
+ this.logAction(action);
152
+ }
153
+ return super.dispatch(action);
154
+ }
155
+
156
+ protected logAction(action: Action): void {
157
+ const { verb, direction } = this.classify(action);
158
+ const summary = this.summarize(action);
159
+ const timing = this.trackPair(action);
160
+ const parts = [`${verb} action '${action.kind}'`, `[${direction}]`];
161
+ if (summary) {
162
+ parts.push(`[${summary}]`);
163
+ }
164
+ if (timing) {
165
+ parts.push(timing);
166
+ }
167
+ this.hydraniumLogger.trace(parts.join(' '));
168
+ }
169
+
170
+ protected classify(action: Action): { verb: string; direction: string } {
171
+ // GLSP marks client-produced computedBounds as ServerAction to suppress forwarding; stays local.
172
+ if (LocalComputedBoundsAction.is(action)) {
173
+ return { verb: 'Process', direction: 'client → client' };
174
+ }
175
+ // Marked by GLSPModelSource.messageReceived — arrived from the server.
176
+ if (ServerAction.is(action)) {
177
+ return { verb: 'Receive', direction: 'server → client' };
178
+ }
179
+ // GLSPModelSource is registered as handler for every server-handled kind (configureServeActions),
180
+ // so its presence means the action will be forwarded to the server.
181
+ const handlers = this.actionHandlerRegistry.get(action.kind);
182
+ if (handlers.some(handler => handler instanceof GLSPModelSource)) {
183
+ return { verb: 'Send', direction: 'client → server' };
184
+ }
185
+ return { verb: 'Process', direction: 'client → client' };
186
+ }
187
+
188
+ /** Emit `[request #N]` on the request side, `[response #N, Xms]` on the matching response side.
189
+ * Match first by requestId/responseId, then by known kind pairs (FIFO). */
190
+ protected trackPair(action: Action): string {
191
+ // Response side.
192
+ if (ResponseAction.hasValidResponseId(action)) {
193
+ const entry = this.pendingById.get(action.responseId);
194
+ if (entry) {
195
+ this.pendingById.delete(action.responseId);
196
+ return this.formatResponse(entry);
197
+ }
198
+ }
199
+ if (this.kindPairsReverse.has(action.kind)) {
200
+ const queue = this.pendingByKind.get(action.kind);
201
+ const entry = queue?.shift();
202
+ if (entry) {
203
+ return this.formatResponse(entry);
204
+ }
205
+ }
206
+ // Request side.
207
+ if (RequestAction.is(action) && action.requestId) {
208
+ const entry = { id: ++this.pairCounter, sinceRequest: this.clock.stopwatch() };
209
+ this.pendingById.set(action.requestId, entry);
210
+ return `[request #${entry.id}]`;
211
+ }
212
+ if (this.kindPairs.has(action.kind)) {
213
+ const responseKind = this.kindPairs.get(action.kind)!;
214
+ const entry = { id: ++this.pairCounter, sinceRequest: this.clock.stopwatch() };
215
+ const queue = this.pendingByKind.get(responseKind) ?? [];
216
+ queue.push(entry);
217
+ this.pendingByKind.set(responseKind, queue);
218
+ return `[request #${entry.id}]`;
219
+ }
220
+ return '';
221
+ }
222
+
223
+ protected formatResponse(entry: { id: number; sinceRequest: Stopwatch }): string {
224
+ const elapsed = Math.round(entry.sinceRequest.elapsedMs);
225
+ return `[response #${entry.id}, ${elapsed}ms]`;
226
+ }
227
+
228
+ protected summarize(action: Action): string {
229
+ if (SetModelAction.is(action) || UpdateModelAction.is(action)) {
230
+ const root = action.newRoot;
231
+ return `rootType=${root?.type ?? 'none'} children=${root?.children?.length ?? 0}`;
232
+ }
233
+ if (RequestBoundsAction.is(action)) {
234
+ return `rootType=${action.newRoot.type} children=${action.newRoot.children?.length ?? 0}`;
235
+ }
236
+ if (ComputedBoundsAction.is(action)) {
237
+ return `bounds=${action.bounds.length}`;
238
+ }
239
+ if (SetDirtyStateAction.is(action)) {
240
+ return `isDirty=${action.isDirty} reason=${action.reason ?? 'n/a'}`;
241
+ }
242
+ if (SetEditModeAction.is(action)) {
243
+ return `editMode=${action.editMode}`;
244
+ }
245
+ if (SetMarkersAction.is(action)) {
246
+ return `markers=${action.markers.length}`;
247
+ }
248
+ if (StatusAction.is(action) || MessageAction.is(action)) {
249
+ // severity=NONE with empty message is the conventional 'clear' signal.
250
+ const clear = action.severity === 'NONE' && !action.message ? ' (clear)' : '';
251
+ return `severity=${action.severity} message="${action.message.slice(0, 160)}"${clear}`;
252
+ }
253
+ if (RejectAction.is(action)) {
254
+ return `reason="${action.message.slice(0, 160)}"`;
255
+ }
256
+ if (SetTypeHintsAction.is(action)) {
257
+ return `shapes=${action.shapeHints.length} edges=${action.edgeHints.length}`;
258
+ }
259
+ if (RequestContextActions.is(action)) {
260
+ return `contextId=${action.contextId}`;
261
+ }
262
+ if (RequestCheckEdgeAction.is(action) || CheckEdgeResultAction.is(action)) {
263
+ return this.formatEdgeCheck(action);
264
+ }
265
+ return '';
266
+ }
267
+
268
+ /** An edge check and, on the result, its verdict, so the log shows why an edge tool refused a target. */
269
+ protected formatEdgeCheck(action: RequestCheckEdgeAction | CheckEdgeResultAction): string {
270
+ const verdict = CheckEdgeResultAction.is(action) ? ` isValid=${action.isValid}` : '';
271
+ return `edgeType=${action.edgeType} source=${action.sourceElementId} target=${action.targetElementId ?? 'none'}${verdict}`;
272
+ }
273
+ }
@@ -0,0 +1,22 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ import { defineMessage } from '@hydranium/protocol';
11
+
12
+ /**
13
+ * What a user reads when a diagram load fails: `reason` is the described
14
+ * error. Formatted in English by `HydraniumDiagramLoader`; a host that knows
15
+ * the reading user's locale renders the same sentence under a key of its own
16
+ * by overriding its `loadFailureLabel`, as the Theia loader does.
17
+ *
18
+ * In a module of its own, apart from the loader that raises it, so the
19
+ * `./messages` barrel loads no `@eclipse-glsp/client`: a bundle that only
20
+ * translates the code must not need GLSP's stylesheets.
21
+ */
22
+ export const DIAGRAM_LOAD_FAILED = defineMessage('hydranium/glsp-client/diagram-load-failed', 'Diagram failed to load: {reason}');
@@ -0,0 +1,173 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ import { DiagramLoader, type DiagramLoadingOptions, StatusAction } from '@eclipse-glsp/client';
11
+ import { Deferred, describeError, type Logger } from '@hydranium/protocol';
12
+ import { inject, injectable } from 'inversify';
13
+ import { DIAGRAM_LOAD_FAILED } from './diagram-load-failed';
14
+ import { HydraniumClientTypes } from './types';
15
+
16
+ /**
17
+ * How a diagram load ended. A discriminated union rather than a boolean or a
18
+ * string: a consumer showing a pending indicator has to distinguish "not settled
19
+ * yet" (`undefined`) from either terminal state, and the failed arm has to carry
20
+ * enough for that consumer to decide whether *it* is now the only thing that can
21
+ * report the failure — see {@link DiagramLoadFailure.surfaced}.
22
+ */
23
+ export type DiagramLoadOutcome = DiagramLoadSuccess | DiagramLoadFailure;
24
+
25
+ export interface DiagramLoadSuccess {
26
+ readonly status: 'loaded';
27
+ }
28
+
29
+ export interface DiagramLoadFailure {
30
+ readonly status: 'failed';
31
+ /** The thrown value, verbatim. `unknown` because a rejection can be anything. */
32
+ readonly error: unknown;
33
+ /**
34
+ * Whether the `severity: 'ERROR'` {@link StatusAction} reached the action
35
+ * dispatcher. It says nothing about whether a status overlay shows it.
36
+ *
37
+ * `false` means the report itself failed, which happens when the action
38
+ * dispatcher is the thing that could not initialize. A consumer that covers
39
+ * the canvas must then render the failure itself: it is the only surface
40
+ * left, and removing its overlay would leave a blank diagram whose only
41
+ * explanation is a line in the log — the exact failure mode this loader
42
+ * exists to prevent.
43
+ */
44
+ readonly surfaced: boolean;
45
+ }
46
+
47
+ /**
48
+ * Diagram loader that never lets a load failure vanish.
49
+ *
50
+ * `DiagramLoader.load()` orchestrates the full diagram-init path — action
51
+ * dispatcher init, GLSP-client connect + server `initialize`, the first
52
+ * `RequestModelAction`, and the model-initialization constraint. Of these only
53
+ * the `IDiagramStartup` hooks are wrapped by the base loader (it logs them to
54
+ * `console.error`); the dispatcher-init / connection / request-model steps are
55
+ * not. The Theia widget that drives loading
56
+ * (`@eclipse-glsp/theia-integration`'s `GLSPDiagramWidget.onAfterAttach`)
57
+ * calls `load()` *without awaiting or catching it*, so any rejection becomes an
58
+ * uncaught promise rejection visible only in the browser devtools console —
59
+ * nothing reaches the e2e output, the server log, or a Theia log sink, and the
60
+ * diagram just stays blank.
61
+ *
62
+ * This subclass closes that gap: it catches every load failure, routes it to
63
+ * the host's logger (in Theia the Output channel the server log and action
64
+ * traffic share, so it is discoverable where a developer already looks), and
65
+ * surfaces a persistent error to the user via a `severity: 'ERROR'`
66
+ * {@link StatusAction} on the diagram's status overlay. It deliberately does
67
+ * **not** rethrow — the caller drops the promise, so rethrowing would only
68
+ * reproduce the uncaught rejection this exists to prevent.
69
+ *
70
+ * It also publishes the load's terminal state ({@link loadOutcome} /
71
+ * {@link onceLoadSettled}) so a pending indicator has one authority to key on.
72
+ * The Theia diagram widget uses it to cover the canvas while loading; see
73
+ * {@link settle} for why the signal cannot be derived from the error
74
+ * `StatusAction` instead.
75
+ *
76
+ * Bound by `createGlspClientModule` via `rebind(DiagramLoader)`.
77
+ */
78
+ @injectable()
79
+ export class HydraniumDiagramLoader extends DiagramLoader {
80
+ @inject(HydraniumClientTypes.Logger) protected readonly hydraniumLogger!: Logger;
81
+
82
+ protected readonly settled = new Deferred<DiagramLoadOutcome>();
83
+ protected outcome?: DiagramLoadOutcome;
84
+
85
+ /** Terminal state of the load, or `undefined` while it is still in flight.
86
+ * The synchronous companion of {@link onceLoadSettled}, for a consumer that
87
+ * must decide something in the same tick (e.g. whether to show an indicator
88
+ * at all when re-attaching an already-loaded diagram). */
89
+ get loadOutcome(): DiagramLoadOutcome | undefined {
90
+ return this.outcome;
91
+ }
92
+
93
+ /** Resolves once the load reaches a terminal state — on success **and** on
94
+ * failure, so a caller awaiting it cannot hang. Never rejects. */
95
+ onceLoadSettled(): Promise<DiagramLoadOutcome> {
96
+ return this.settled.promise;
97
+ }
98
+
99
+ override async load(options?: DiagramLoadingOptions): Promise<void> {
100
+ try {
101
+ await super.load(options);
102
+ this.settle({ status: 'loaded' });
103
+ } catch (err) {
104
+ // Report first, settle second, and carry whether the report landed.
105
+ const surfaced = await this.reportLoadFailure(err);
106
+ this.settle({ status: 'failed', error: err, surfaced });
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Record the terminal state and release {@link onceLoadSettled}. Idempotent —
112
+ * the first outcome wins, so a reload cannot flip a settled indicator.
113
+ *
114
+ * This runs unconditionally after {@link reportLoadFailure}, *including* when
115
+ * the status dispatch inside it threw. That is the load-bearing property: when
116
+ * the action dispatcher is itself the thing that failed to initialize — the
117
+ * exact failure this class exists for — no `StatusAction` ever lands, so a
118
+ * consumer observing actions would wait forever. Settling here means a pending
119
+ * indicator always comes down, and `surfaced: false` tells that consumer it is
120
+ * now the only thing that can report the failure.
121
+ */
122
+ protected settle(outcome: DiagramLoadOutcome): void {
123
+ if (this.outcome !== undefined) {
124
+ return;
125
+ }
126
+ this.outcome = outcome;
127
+ this.settled.resolve(outcome);
128
+ }
129
+
130
+ /**
131
+ * Route a diagram-load failure to the host's logger and the status overlay.
132
+ * The status dispatch is itself guarded: when the action dispatcher is the
133
+ * thing that failed to initialize, dispatching may throw too, and a failure
134
+ * to *report* the failure must not re-escape as an uncaught rejection.
135
+ *
136
+ * @returns whether the `StatusAction` reached the dispatcher — i.e. whether the
137
+ * user can see this failure on the diagram. The log line always happens, so
138
+ * the return value is specifically about the *user-visible* report; a `false`
139
+ * makes {@link DiagramLoadFailure.surfaced} false and hands the job to whoever
140
+ * is covering the canvas.
141
+ */
142
+ protected async reportLoadFailure(err: unknown): Promise<boolean> {
143
+ const label = this.loadFailureLabel(err);
144
+ this.hydraniumLogger.error(label, err);
145
+ try {
146
+ await this.actionDispatcher.dispatch(StatusAction.create(label, { severity: 'ERROR' }));
147
+ return true;
148
+ } catch (statusErr) {
149
+ this.hydraniumLogger.error('Failed to surface the diagram-load failure on the status overlay', statusErr);
150
+ return false;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * The sentence a failed load is reported with, on every surface that reports
156
+ * it. Public because the canvas overlay is one of those surfaces and lives in
157
+ * another class, where a second copy of the sentence would drift.
158
+ *
159
+ * English, formatted from {@link DIAGRAM_LOAD_FAILED}: this tier knows no
160
+ * locale. A host that does renders the code through its own mechanism by
161
+ * overriding this method, so every surface, the log line included, carries
162
+ * the one sentence the user reads.
163
+ */
164
+ loadFailureLabel(err: unknown): string {
165
+ return DIAGRAM_LOAD_FAILED.format({ reason: this.formatError(err) });
166
+ }
167
+
168
+ /** Render a thrown value as a message. Shared so the log line, the status
169
+ * overlay and any consumer rendering {@link DiagramLoadFailure.error} agree. */
170
+ protected formatError(err: unknown): string {
171
+ return describeError(err);
172
+ }
173
+ }
package/src/edge.ts ADDED
@@ -0,0 +1,65 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ import {
11
+ type BindingContext,
12
+ type Bounds,
13
+ configureDefaultModelElements,
14
+ DefaultTypes,
15
+ GEdge,
16
+ GEdgeView,
17
+ isBounds,
18
+ overrideModelElement,
19
+ type Point
20
+ } from '@eclipse-glsp/client';
21
+
22
+ /**
23
+ * GLSP's edge, with an empty route transforming its children through the
24
+ * origin.
25
+ *
26
+ * `GEdge` offsets a child by the bounds of its routing points, and sprotty's
27
+ * `getRouteBounds` answers `NaN` for an empty route, which an edge without
28
+ * bendpoints always has. An edge label's bounds then come out `NaN`, and a
29
+ * fit-to-screen over every element lands the viewport at `translate(NaN,NaN)`,
30
+ * where every shape fails its visibility check and the canvas goes blank.
31
+ *
32
+ * Register your own edge types with this class in place of `GEdge`; sprotty
33
+ * fixes a type's class when it is registered, so an edge type registered with
34
+ * `GEdge` keeps the defect. Patching `GEdge`'s prototype would reach those
35
+ * types too, and is not done: it is no extension point of GLSP's.
36
+ */
37
+ export class HydraniumGEdge extends GEdge {
38
+ override localToParent(point: Point | Bounds): Bounds {
39
+ return this.routingPoints.length === 0 ? asBounds(point) : super.localToParent(point);
40
+ }
41
+
42
+ override parentToLocal(point: Point | Bounds): Bounds {
43
+ return this.routingPoints.length === 0 ? asBounds(point) : super.parentToLocal(point);
44
+ }
45
+ }
46
+
47
+ /** The shape `GEdge` returns: a point gains the size `-1`, bounds keep theirs. */
48
+ function asBounds(point: Point | Bounds): Bounds {
49
+ return { x: point.x, y: point.y, width: isBounds(point) ? point.width : -1, height: isBounds(point) ? point.height : -1 };
50
+ }
51
+
52
+ /**
53
+ * GLSP's default model elements, with `DefaultTypes.EDGE` built as a
54
+ * {@link HydraniumGEdge}.
55
+ *
56
+ * Call it from your diagram module in place of GLSP's
57
+ * `configureDefaultModelElements`. The replacement cannot come from the
58
+ * binder: a diagram module is loaded after the host's, and GLSP's model
59
+ * registry gives a type to whichever registration came last, so a replacement
60
+ * bound before the defaults is the one overwritten.
61
+ */
62
+ export function configureHydraniumModelElements(context: Pick<BindingContext, 'bind' | 'isBound'>): void {
63
+ configureDefaultModelElements(context);
64
+ overrideModelElement(context, DefaultTypes.EDGE, HydraniumGEdge, GEdgeView);
65
+ }
@@ -0,0 +1,79 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ import {
11
+ type BindingContext,
12
+ DiagramLoader,
13
+ GLSPActionDispatcher,
14
+ GLSPHiddenBoundsUpdater,
15
+ RankedSelectMouseListener,
16
+ StatusOverlay,
17
+ TYPES
18
+ } from '@eclipse-glsp/client';
19
+ import { type Clock, ConsoleLogger, DefaultTracer, type Logger, SystemClock } from '@hydranium/protocol';
20
+ import { HydraniumGlspActionDispatcher } from './action-dispatcher';
21
+ import { HydraniumDiagramLoader } from './diagram-loader';
22
+ import { HydraniumHiddenBoundsUpdater } from './hidden-bounds-updater';
23
+ import { HydraniumSelectMouseListener } from './select-mouse-listener';
24
+ import { SprottyLoggerAdapter } from './sprotty-logger-adapter';
25
+ import { HydraniumStatusOverlay } from './status-overlay';
26
+ import { HydraniumClientTypes } from './types';
27
+
28
+ /** Options for {@link createGlspClientModule}. */
29
+ export interface GlspClientModuleOptions {
30
+ /** The host's logger, bound on `HydraniumClientTypes.Logger` in place of any
31
+ * binding the host made. Absent, the token keeps the host's own binding, or
32
+ * gets a `ConsoleLogger` when it has none. */
33
+ readonly logger?: Logger;
34
+ }
35
+
36
+ /**
37
+ * Binds the framework's client classes into a GLSP diagram container, whatever
38
+ * hosts it: the instrumented action dispatcher, the failure-reporting diagram
39
+ * loader, the route-repairing hidden-bounds updater, the status overlay that
40
+ * stays on the page, the select listener whose plain click replaces a pending
41
+ * selection, and a sprotty logger that reports through the host's.
42
+ *
43
+ * Call it from the module a host adds AFTER GLSP's defaults, since every class
44
+ * here replaces a token those defaults bind; `rebind` on an unbound token
45
+ * throws. A host that wants one GLSP default back rebinds that token after
46
+ * this call, the same one-line move this function makes.
47
+ *
48
+ * The logger and tracer tokens are bound only when the host has not bound
49
+ * them, so a host with a richer logger binds `HydraniumClientTypes.Logger`
50
+ * first and passes no option. The tracer composes the bound logger and clock.
51
+ * Bind the tokens in the container this runs in: `isBound` sees a parent
52
+ * container's binding, which `rebind` cannot replace.
53
+ */
54
+ export function createGlspClientModule(context: BindingContext, options: GlspClientModuleOptions = {}): void {
55
+ const { bind, isBound, rebind } = context;
56
+ if (options.logger) {
57
+ (isBound(HydraniumClientTypes.Logger) ? rebind : bind)(HydraniumClientTypes.Logger).toConstantValue(options.logger);
58
+ } else if (!isBound(HydraniumClientTypes.Logger)) {
59
+ bind(HydraniumClientTypes.Logger).toConstantValue(new ConsoleLogger());
60
+ }
61
+ if (!isBound(HydraniumClientTypes.Tracer)) {
62
+ bind(HydraniumClientTypes.Tracer)
63
+ .toDynamicValue(ctx => {
64
+ const logger = ctx.container.get<Logger>(HydraniumClientTypes.Logger);
65
+ const clock = ctx.container.isBound(HydraniumClientTypes.Clock)
66
+ ? ctx.container.get<Clock>(HydraniumClientTypes.Clock)
67
+ : new SystemClock();
68
+ return new DefaultTracer(logger, clock);
69
+ })
70
+ .inSingletonScope();
71
+ }
72
+ bind(HydraniumGlspActionDispatcher).toSelf().inSingletonScope();
73
+ rebind(GLSPActionDispatcher).toService(HydraniumGlspActionDispatcher);
74
+ rebind(DiagramLoader).to(HydraniumDiagramLoader).inSingletonScope();
75
+ rebind(GLSPHiddenBoundsUpdater).to(HydraniumHiddenBoundsUpdater).inSingletonScope();
76
+ rebind(StatusOverlay).to(HydraniumStatusOverlay).inSingletonScope();
77
+ rebind(RankedSelectMouseListener).to(HydraniumSelectMouseListener).inSingletonScope();
78
+ rebind(TYPES.ILogger).to(SprottyLoggerAdapter).inSingletonScope();
79
+ }
@@ -0,0 +1,20 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ import { BaseJsonrpcGLSPClient, type DisposeClientSessionParameters } from '@eclipse-glsp/client';
11
+
12
+ /** The GLSP client a host builds over its connection to the GLSP head. */
13
+ export class HydraniumGlspClient extends BaseJsonrpcGLSPClient {
14
+ /** Resolves at once when the connection is gone: the session went with its
15
+ * server, and upstream would throw, which a diagram disposed after its
16
+ * client was lost logs as an error. */
17
+ override disposeClientSession(params: DisposeClientSessionParameters): Promise<void> {
18
+ return this.isConnectionActive() ? super.disposeClientSession(params) : Promise.resolve();
19
+ }
20
+ }