@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.
- package/LICENSE +21 -0
- package/README.md +100 -2
- package/lib/action-dispatcher.d.ts +89 -0
- package/lib/action-dispatcher.d.ts.map +1 -0
- package/lib/action-dispatcher.js +257 -0
- package/lib/action-dispatcher.js.map +1 -0
- package/lib/diagram-load-failed.d.ts +20 -0
- package/lib/diagram-load-failed.d.ts.map +1 -0
- package/lib/diagram-load-failed.js +24 -0
- package/lib/diagram-load-failed.js.map +1 -0
- package/lib/diagram-loader.d.ts +124 -0
- package/lib/diagram-loader.d.ts.map +1 -0
- package/lib/diagram-loader.js +154 -0
- package/lib/diagram-loader.js.map +1 -0
- package/lib/edge.d.ts +40 -0
- package/lib/edge.d.ts.map +1 -0
- package/lib/edge.js +56 -0
- package/lib/edge.js.map +1 -0
- package/lib/glsp-client-module.d.ts +37 -0
- package/lib/glsp-client-module.d.ts.map +1 -0
- package/lib/glsp-client-module.js +66 -0
- package/lib/glsp-client-module.js.map +1 -0
- package/lib/glsp-client.d.ts +17 -0
- package/lib/glsp-client.d.ts.map +1 -0
- package/lib/glsp-client.js +23 -0
- package/lib/glsp-client.js.map +1 -0
- package/lib/hidden-bounds-updater.d.ts +119 -0
- package/lib/hidden-bounds-updater.d.ts.map +1 -0
- package/lib/hidden-bounds-updater.js +210 -0
- package/lib/hidden-bounds-updater.js.map +1 -0
- package/lib/index.d.ts +20 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +41 -0
- package/lib/index.js.map +1 -0
- package/lib/messages/index.d.ts +10 -0
- package/lib/messages/index.d.ts.map +1 -0
- package/lib/messages/index.js +20 -0
- package/lib/messages/index.js.map +1 -0
- package/lib/select-mouse-listener.d.ts +30 -0
- package/lib/select-mouse-listener.d.ts.map +1 -0
- package/lib/select-mouse-listener.js +51 -0
- package/lib/select-mouse-listener.js.map +1 -0
- package/lib/sprotty-logger-adapter.d.ts +61 -0
- package/lib/sprotty-logger-adapter.d.ts.map +1 -0
- package/lib/sprotty-logger-adapter.js +130 -0
- package/lib/sprotty-logger-adapter.js.map +1 -0
- package/lib/status-overlay.d.ts +23 -0
- package/lib/status-overlay.d.ts.map +1 -0
- package/lib/status-overlay.js +52 -0
- package/lib/status-overlay.js.map +1 -0
- package/lib/testing/bind-recorder.d.ts +67 -0
- package/lib/testing/bind-recorder.d.ts.map +1 -0
- package/lib/testing/bind-recorder.js +72 -0
- package/lib/testing/bind-recorder.js.map +1 -0
- package/lib/testing/index.d.ts +10 -0
- package/lib/testing/index.d.ts.map +1 -0
- package/lib/testing/index.js +28 -0
- package/lib/testing/index.js.map +1 -0
- package/lib/types.d.ts +27 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +33 -0
- package/lib/types.js.map +1 -0
- package/package.json +79 -4
- package/src/action-dispatcher.ts +273 -0
- package/src/diagram-load-failed.ts +22 -0
- package/src/diagram-loader.ts +173 -0
- package/src/edge.ts +65 -0
- package/src/glsp-client-module.ts +79 -0
- package/src/glsp-client.ts +20 -0
- package/src/hidden-bounds-updater.ts +210 -0
- package/src/index.ts +25 -0
- package/src/messages/index.ts +17 -0
- package/src/select-mouse-listener.ts +54 -0
- package/src/sprotty-logger-adapter.ts +121 -0
- package/src/status-overlay.ts +43 -0
- package/src/testing/bind-recorder.ts +126 -0
- package/src/testing/index.ts +13 -0
- package/src/types.ts +34 -0
|
@@ -0,0 +1,210 @@
|
|
|
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
|
+
type ElementAndRoutingPoints,
|
|
13
|
+
type GModelElement,
|
|
14
|
+
type GRoutableElement,
|
|
15
|
+
GLSPHiddenBoundsUpdater,
|
|
16
|
+
type LocalRequestBoundsAction,
|
|
17
|
+
Point,
|
|
18
|
+
RequestBoundsAction,
|
|
19
|
+
ServerAction,
|
|
20
|
+
isRoutable
|
|
21
|
+
} from '@eclipse-glsp/client';
|
|
22
|
+
import { type Clock, type Logger, SystemClock } from '@hydranium/protocol';
|
|
23
|
+
import { inject, injectable, optional } from 'inversify';
|
|
24
|
+
import { type VNode } from 'snabbdom';
|
|
25
|
+
import { HydraniumClientTypes } from './types';
|
|
26
|
+
|
|
27
|
+
/** How many scoped element ids the log line names before summarising the rest.
|
|
28
|
+
* A scoped request names one element in practice; the cap only stops a
|
|
29
|
+
* pathological caller from writing a thousand ids into the log. */
|
|
30
|
+
const SCOPE_ID_CAP = 3;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Instruments sprotty's hidden-bounds pass and repairs degenerate routes before
|
|
34
|
+
* they go on the wire.
|
|
35
|
+
*
|
|
36
|
+
* **Why the framework owns this.** Every GLSP head runs the hidden-bounds pass on
|
|
37
|
+
* every model update, and it is the one client-side phase that scales with the
|
|
38
|
+
* *rendered element count* rather than the model size — labels, compartments and
|
|
39
|
+
* edge decorations expand a modest node count into orders of magnitude more DOM
|
|
40
|
+
* elements. Without a timing line there is no way to tell a slow measurement pass
|
|
41
|
+
* apart from slow edge routing or a slow server round trip; with one,
|
|
42
|
+
* `bounds-request` either exonerates the client in a sentence or names the
|
|
43
|
+
* dominant cost.
|
|
44
|
+
*
|
|
45
|
+
* Emits exactly one `trace` line per bounds request — `trace` because the
|
|
46
|
+
* frequency matches the per-action traffic `HydraniumGlspActionDispatcher` logs
|
|
47
|
+
* there (a bounds request is driven by those actions), so it belongs with them
|
|
48
|
+
* rather than above them. The line carries:
|
|
49
|
+
*
|
|
50
|
+
* - `server` / `local` — whether the request came from the server or from a
|
|
51
|
+
* client-local {@link LocalRequestBoundsAction}
|
|
52
|
+
* - `scope=<ids>` — the elements a scoped request named; absent when the request
|
|
53
|
+
* covers the whole diagram
|
|
54
|
+
* - `measured=N` — DOM elements whose bounds were read. Exceeds the scoped id
|
|
55
|
+
* count because the base class expands each requested element to its
|
|
56
|
+
* descendants
|
|
57
|
+
* - `getBBox=Nms` — {@link getBoundsFromDOM} **only**: the layout pass and the
|
|
58
|
+
* `ComputedBoundsAction` assembly stay outside it, so the number isolates raw
|
|
59
|
+
* measurement cost
|
|
60
|
+
*
|
|
61
|
+
* Composed from {@link Clock.measure} rather than `Tracer.time`, matching
|
|
62
|
+
* `HydraniumDocumentBuilder`'s phase instrumentation. `Tracer.time` is for "tell
|
|
63
|
+
* me when this is slow" and is deliberately silent on a fast success, which would
|
|
64
|
+
* drop the element count on every normal pass — and forcing it to emit costs a
|
|
65
|
+
* paired `[#N start]` line per request. This is a per-occurrence record, so it
|
|
66
|
+
* measures and composes its own line.
|
|
67
|
+
*
|
|
68
|
+
* Bound by `createGlspClientModule` via `rebind(GLSPHiddenBoundsUpdater)`.
|
|
69
|
+
*/
|
|
70
|
+
@injectable()
|
|
71
|
+
export class HydraniumHiddenBoundsUpdater extends GLSPHiddenBoundsUpdater {
|
|
72
|
+
@inject(HydraniumClientTypes.Logger) protected readonly hydraniumLogger!: Logger;
|
|
73
|
+
|
|
74
|
+
/** The diagram container's {@link Clock}, or a `SystemClock`; it only measures. */
|
|
75
|
+
@inject(HydraniumClientTypes.Clock) @optional() protected readonly clock: Clock = new SystemClock();
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Scope description for the in-flight request, read from the `cause` in
|
|
79
|
+
* {@link postUpdate} and consumed by {@link getBoundsFromDOM} — which the base
|
|
80
|
+
* class calls with no arguments, so the cause cannot be threaded through.
|
|
81
|
+
*/
|
|
82
|
+
protected currentScope?: string;
|
|
83
|
+
|
|
84
|
+
override decorate(vnode: VNode, element: GModelElement): VNode {
|
|
85
|
+
super.decorate(vnode, element);
|
|
86
|
+
if (isRoutable(element)) {
|
|
87
|
+
this.repairLastRoute(element);
|
|
88
|
+
}
|
|
89
|
+
return vnode;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Replace the routing entry the base class just pushed when it carries fewer
|
|
94
|
+
* than two points, which is not a valid route for any edge.
|
|
95
|
+
*
|
|
96
|
+
* `calcElementAndRoute` already guards the case where the registered router
|
|
97
|
+
* returns *nothing* — it falls back to source + own routing points + target.
|
|
98
|
+
* The hole is one step further in: `calcRoute` filters duplicate points at
|
|
99
|
+
* `Number.EPSILON`, so while obstacles still carry sprotty's unmeasured-bounds
|
|
100
|
+
* sentinel `(100, 100, -1, -1)` the source and target coincide and a valid
|
|
101
|
+
* two-point route collapses to a single point. That result is defined, so the
|
|
102
|
+
* upstream truthiness guard never fires and the single point ships.
|
|
103
|
+
*
|
|
104
|
+
* The substitute is deliberately upstream's own fallback shape rather than a
|
|
105
|
+
* new invention, so a repaired route is indistinguishable from a route the
|
|
106
|
+
* base class would have produced had it noticed. Only already-invalid payloads
|
|
107
|
+
* are touched, so a head with a working router is unaffected.
|
|
108
|
+
*/
|
|
109
|
+
protected repairLastRoute(element: GRoutableElement): void {
|
|
110
|
+
const addedRoute = this.element2route.pop();
|
|
111
|
+
if (addedRoute === undefined) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
this.element2route.push(this.hasValidRoute(addedRoute) ? addedRoute : this.toSourceTargetRoute(element));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A route needs at least a start and an end point to describe an edge. */
|
|
118
|
+
protected hasValidRoute(route: ElementAndRoutingPoints): boolean {
|
|
119
|
+
return route.newRoutingPoints !== undefined && route.newRoutingPoints.length >= 2;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Upstream's `calcElementAndRoute` no-router fallback: the element's own
|
|
123
|
+
* bendpoints framed by its source and target anchor positions. */
|
|
124
|
+
protected toSourceTargetRoute(element: GRoutableElement): ElementAndRoutingPoints {
|
|
125
|
+
return {
|
|
126
|
+
elementId: element.id,
|
|
127
|
+
newRoutingPoints: [element.source?.position ?? Point.ORIGIN, ...element.routingPoints, element.target?.position ?? Point.ORIGIN]
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
protected override getBoundsFromDOM(): void {
|
|
132
|
+
if (this.currentScope === undefined) {
|
|
133
|
+
// Not inside an instrumented `postUpdate` (a subclass calling directly);
|
|
134
|
+
// with no cause there is nothing to attribute a measurement to.
|
|
135
|
+
super.getBoundsFromDOM();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
// Read before the pass: the base class clears the map on its way out.
|
|
139
|
+
const measured = this.getElement2BoundsData().size;
|
|
140
|
+
const { elapsedMs } = this.clock.measure(() => super.getBoundsFromDOM());
|
|
141
|
+
this.hydraniumLogger.trace(this.formatBoundsRequest(this.currentScope, measured, elapsedMs));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Format the per-request line. Override to change the wording. */
|
|
145
|
+
protected formatBoundsRequest(scope: string, measured: number, elapsedMs: number): string {
|
|
146
|
+
return `bounds-request[${scope}] measured=${measured} getBBox=${elapsedMs.toFixed(0)}ms`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
override postUpdate(cause?: Action): void {
|
|
150
|
+
if (cause?.kind !== RequestBoundsAction.KIND) {
|
|
151
|
+
super.postUpdate(cause);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
this.currentScope = this.formatCause(cause);
|
|
155
|
+
try {
|
|
156
|
+
super.postUpdate(cause);
|
|
157
|
+
} finally {
|
|
158
|
+
this.currentScope = undefined;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Scope facts for the log line: where the request came from, and which
|
|
164
|
+
* elements it asked for.
|
|
165
|
+
*
|
|
166
|
+
* Origin is read from {@link ServerAction}, the `__receivedFromServer` marker
|
|
167
|
+
* `GLSPModelSource` stamps on inbound actions, rather than from
|
|
168
|
+
* `LocalRequestBoundsAction.is`. The latter would answer the same today, but
|
|
169
|
+
* only incidentally: its `elementIDs` check is declared optional, so it is
|
|
170
|
+
* really just "a bounds request that did not come from the server". Keying on
|
|
171
|
+
* the marker states that directly, and keeps an *unscoped* local request from
|
|
172
|
+
* being mislabelled `server` if GLSP ever tightens that guard.
|
|
173
|
+
*/
|
|
174
|
+
protected formatCause(cause: Action): string {
|
|
175
|
+
const origin = ServerAction.is(cause) ? 'server' : 'local';
|
|
176
|
+
const scope = this.formatScope(cause as RequestBoundsAction);
|
|
177
|
+
return scope ? `${origin} scope=${scope}` : origin;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Name the elements a scoped request asked for, or `undefined` when it covers
|
|
182
|
+
* the whole diagram.
|
|
183
|
+
*
|
|
184
|
+
* Deliberately the ids and not their count. A scoped request in practice names
|
|
185
|
+
* a single element, so a count is always `1` and says nothing, while the id
|
|
186
|
+
* says *which* element was re-measured — and it is the only part of the line
|
|
187
|
+
* that distinguishes one such request from the next. It also stops the line
|
|
188
|
+
* reading as a contradiction: `scope=node0, measured=6` is coherent (the base
|
|
189
|
+
* class expands a requested element to its descendants, so one id measures six
|
|
190
|
+
* DOM elements), where `scoped=1, measured=6` invites the reader to wonder
|
|
191
|
+
* which number is wrong.
|
|
192
|
+
*
|
|
193
|
+
* A request naming only the root is treated as unscoped: it measures
|
|
194
|
+
* everything, so the id adds nothing over the absence of a `scope` tag.
|
|
195
|
+
*/
|
|
196
|
+
protected formatScope(cause: RequestBoundsAction): string | undefined {
|
|
197
|
+
const scopedIds = (cause as LocalRequestBoundsAction).elementIDs;
|
|
198
|
+
if (!scopedIds || scopedIds.length === 0) {
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
const named = scopedIds.filter(id => id !== cause.newRoot?.id);
|
|
202
|
+
if (named.length === 0) {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
if (named.length > SCOPE_ID_CAP) {
|
|
206
|
+
return `${named.slice(0, SCOPE_ID_CAP).join(',')},+${named.length - SCOPE_ID_CAP} more`;
|
|
207
|
+
}
|
|
208
|
+
return named.join(',');
|
|
209
|
+
}
|
|
210
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
// The host-neutral GLSP client classes and the binder that mounts them. This
|
|
11
|
+
// root is browser-bound, not neutral: it loads `@eclipse-glsp/client`, whose
|
|
12
|
+
// graph imports stylesheets, so it needs a bundler with a CSS loader. It is
|
|
13
|
+
// free of Node and of any host platform, which is the claim `check:neutral`
|
|
14
|
+
// gates.
|
|
15
|
+
export * from './action-dispatcher';
|
|
16
|
+
export * from './diagram-load-failed';
|
|
17
|
+
export * from './diagram-loader';
|
|
18
|
+
export * from './edge';
|
|
19
|
+
export * from './glsp-client';
|
|
20
|
+
export * from './glsp-client-module';
|
|
21
|
+
export * from './hidden-bounds-updater';
|
|
22
|
+
export * from './select-mouse-listener';
|
|
23
|
+
export * from './sprotty-logger-adapter';
|
|
24
|
+
export * from './status-overlay';
|
|
25
|
+
export * from './types';
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
// Every user-facing message `@hydranium/glsp-client` raises. The client tier
|
|
11
|
+
// renders only its own messages, which fire when no server could have worded
|
|
12
|
+
// them: the loader formats the English, and a host that knows the reading
|
|
13
|
+
// user's locale renders the code through its own mechanism instead, as the
|
|
14
|
+
// Theia loader does through `nls`. The barrel is how an adopter rendering their
|
|
15
|
+
// own diagram chrome discovers the codes exist.
|
|
16
|
+
|
|
17
|
+
export { DIAGRAM_LOAD_FAILED } from '../diagram-load-failed';
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
BringToFrontAction,
|
|
13
|
+
type GModelElement,
|
|
14
|
+
GRoutingHandle,
|
|
15
|
+
isCtrlOrCmd,
|
|
16
|
+
RankedSelectMouseListener,
|
|
17
|
+
SelectAction,
|
|
18
|
+
type SelectableElement
|
|
19
|
+
} from '@eclipse-glsp/client';
|
|
20
|
+
import { injectable } from 'inversify';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* GLSP's select mouse listener, with a plain click on an unselected element
|
|
24
|
+
* replacing whatever is selected when its action executes.
|
|
25
|
+
*
|
|
26
|
+
* The listener names the elements a click deselects from each element's
|
|
27
|
+
* `selected` flag, which the feedback the previous click's command queues
|
|
28
|
+
* writes only when that feedback runs; a click landing before then names
|
|
29
|
+
* nothing, and both elements end up selected. A plain click sends
|
|
30
|
+
* `deselectAll` instead, which `SelectCommand` resolves against the selection
|
|
31
|
+
* service as it executes. Reading the service from the listener would still
|
|
32
|
+
* race the previous click's command, since dispatch is asynchronous.
|
|
33
|
+
* Ctrl/Cmd-click keeps GLSP's add to the selection; a routing handle keeps
|
|
34
|
+
* GLSP's path, since `deselectAll` cannot spare the handle's own edge.
|
|
35
|
+
*
|
|
36
|
+
* Bound by `createGlspClientModule` via `rebind(RankedSelectMouseListener)`.
|
|
37
|
+
*/
|
|
38
|
+
@injectable()
|
|
39
|
+
export class HydraniumSelectMouseListener extends RankedSelectMouseListener {
|
|
40
|
+
protected override handleSelectTarget(
|
|
41
|
+
selectableTarget: SelectableElement,
|
|
42
|
+
deselectedElements: GModelElement[],
|
|
43
|
+
event: MouseEvent
|
|
44
|
+
): (Action | Promise<Action>)[] {
|
|
45
|
+
if (isCtrlOrCmd(event) || selectableTarget instanceof GRoutingHandle) {
|
|
46
|
+
return super.handleSelectTarget(selectableTarget, deselectedElements, event);
|
|
47
|
+
}
|
|
48
|
+
return [SelectAction.setSelection([selectableTarget.id]), BringToFrontAction.create([selectableTarget.id])];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
protected override handleDeselectAll(): (Action | Promise<Action>)[] {
|
|
52
|
+
return [SelectAction.setSelection([])];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
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 { type ILogger, LogLevel } from '@eclipse-glsp/client';
|
|
11
|
+
import { Logger } from '@hydranium/protocol';
|
|
12
|
+
import { inject, injectable } from 'inversify';
|
|
13
|
+
import { HydraniumClientTypes } from './types';
|
|
14
|
+
|
|
15
|
+
/** GLSP's wording for a request it resolved silently after its timeout. If
|
|
16
|
+
* upstream rewords it, the notice degrades to `debug` with the rest of `info`. */
|
|
17
|
+
const REQUEST_TIMED_OUT = /\btime out after \d+ms\b/;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* sprotty's `ILogger` over the host's {@link Logger}, so sprotty's own lines
|
|
21
|
+
* reach the same sink as the framework's: a view-registration failure lands in
|
|
22
|
+
* the Theia Output channel beside the action traffic, or on a page's console.
|
|
23
|
+
* sprotty binds a `NullLogger` by default, which drops those failures.
|
|
24
|
+
*
|
|
25
|
+
* sprotty's `error` and `warn` keep their levels and their parameters, where
|
|
26
|
+
* the parameter is the useful part, such as the element type that has no view.
|
|
27
|
+
*
|
|
28
|
+
* Its `info` is diagnosis, chatty on every hover's feedback, so it goes to
|
|
29
|
+
* `debug`; the one anomaly reported there, a request GLSP resolved silently
|
|
30
|
+
* after its timeout, goes to `warn`, where a default threshold shows it.
|
|
31
|
+
*
|
|
32
|
+
* Its `log` fires per action, per command and per render with the whole action
|
|
33
|
+
* or model as a parameter, so it goes to `trace`, where the framework
|
|
34
|
+
* dispatcher logs that traffic. **At `info` and `log` a parameter is reduced to
|
|
35
|
+
* its name**, an action's `kind` or an object's class, never forwarded whole:
|
|
36
|
+
* the Theia channel logger stringifies every parameter as JSON, so forwarding
|
|
37
|
+
* them would write the model into the Output channel on every action.
|
|
38
|
+
*
|
|
39
|
+
* Each line goes through a logger nested under the host's for the calling
|
|
40
|
+
* class, cached per class, so the origin is a tag the host renders inside its
|
|
41
|
+
* own, `[GLSP client :: Viewer]`, not a prefix inside the message. Where
|
|
42
|
+
* sprotty passes a sentence instead of the caller, as its layout registries
|
|
43
|
+
* do, the sentence prefixes the message. The adapter's own `logLevel` is
|
|
44
|
+
* sprotty's most verbose, so only the framework threshold filters; `info` and
|
|
45
|
+
* `log` check it first, since they run on every action and render and would
|
|
46
|
+
* otherwise format a line the threshold drops.
|
|
47
|
+
*
|
|
48
|
+
* Bound by `createGlspClientModule` via `rebind(TYPES.ILogger)`.
|
|
49
|
+
*/
|
|
50
|
+
@injectable()
|
|
51
|
+
export class SprottyLoggerAdapter implements ILogger {
|
|
52
|
+
@inject(HydraniumClientTypes.Logger) protected readonly logger!: Logger;
|
|
53
|
+
|
|
54
|
+
logLevel: LogLevel = LogLevel.log;
|
|
55
|
+
|
|
56
|
+
/** Loggers derived per calling class; sprotty logs from a few dozen classes. */
|
|
57
|
+
protected readonly loggers = new Map<string, Logger>();
|
|
58
|
+
|
|
59
|
+
error(thisArg: unknown, message: string, ...params: unknown[]): void {
|
|
60
|
+
this.loggerFor(thisArg).error(this.formatMessage(thisArg, message), ...params);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
warn(thisArg: unknown, message: string, ...params: unknown[]): void {
|
|
64
|
+
this.loggerFor(thisArg).warn(this.formatMessage(thisArg, message), ...params);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
info(thisArg: unknown, message: string, ...params: unknown[]): void {
|
|
68
|
+
if (REQUEST_TIMED_OUT.test(message)) {
|
|
69
|
+
this.loggerFor(thisArg).warn(this.formatMessage(thisArg, message, params));
|
|
70
|
+
} else if (Logger.isLevelEnabled('debug')) {
|
|
71
|
+
this.loggerFor(thisArg).debug(this.formatMessage(thisArg, message, params));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
log(thisArg: unknown, message: string, ...params: unknown[]): void {
|
|
76
|
+
if (Logger.isLevelEnabled('trace')) {
|
|
77
|
+
this.loggerFor(thisArg).trace(this.formatMessage(thisArg, message, params));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The host's logger with the calling class nested under its tag, or the
|
|
82
|
+
* host's own when the caller is not an object. */
|
|
83
|
+
protected loggerFor(thisArg: unknown): Logger {
|
|
84
|
+
const caller = typeof thisArg === 'object' && thisArg !== null ? this.nameOf(thisArg) : undefined;
|
|
85
|
+
if (caller === undefined) {
|
|
86
|
+
return this.logger;
|
|
87
|
+
}
|
|
88
|
+
let derived = this.loggers.get(caller);
|
|
89
|
+
if (derived === undefined) {
|
|
90
|
+
derived = this.logger.sub(caller);
|
|
91
|
+
this.loggers.set(caller, derived);
|
|
92
|
+
}
|
|
93
|
+
return derived;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The message, behind the sentence sprotty sometimes passes as the caller,
|
|
97
|
+
* followed by the parameters' names. */
|
|
98
|
+
protected formatMessage(thisArg: unknown, message: string, params: readonly unknown[] = []): string {
|
|
99
|
+
const text = typeof thisArg === 'string' ? `${thisArg}${message}` : message;
|
|
100
|
+
return [text, ...params.map(param => this.nameOf(param)).filter(name => name !== undefined)].join(' ');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** An action's `kind`, also inside the message GLSP sends an action in, a
|
|
104
|
+
* string as it is, any other object's class, else nothing. */
|
|
105
|
+
protected nameOf(value: unknown): string | undefined {
|
|
106
|
+
if (typeof value === 'string') {
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
if (typeof value !== 'object' || value === null) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
const { kind, action } = value as { kind?: unknown; action?: { kind?: unknown } };
|
|
113
|
+
if (typeof kind === 'string') {
|
|
114
|
+
return kind;
|
|
115
|
+
}
|
|
116
|
+
if (typeof action?.kind === 'string') {
|
|
117
|
+
return action.kind;
|
|
118
|
+
}
|
|
119
|
+
return value.constructor?.name;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
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 { type StatusAction, StatusOverlay } from '@eclipse-glsp/client';
|
|
11
|
+
import { injectable } from 'inversify';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* GLSP's status overlay, kept on the page.
|
|
15
|
+
*
|
|
16
|
+
* It inserts its element into the diagram's base div when the diagram starts,
|
|
17
|
+
* and sprotty's first render then replaces that div with its own, so the element
|
|
18
|
+
* is left detached and no status it shows is ever seen. This puts the element
|
|
19
|
+
* back into the current base div before it shows anything.
|
|
20
|
+
*/
|
|
21
|
+
@injectable()
|
|
22
|
+
export class HydraniumStatusOverlay extends StatusOverlay {
|
|
23
|
+
override handle(action: StatusAction): void {
|
|
24
|
+
this.reattach();
|
|
25
|
+
super.handle(action);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
override show(...args: Parameters<StatusOverlay['show']>): void {
|
|
29
|
+
super.show(...args);
|
|
30
|
+
this.reattach();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
protected reattach(): void {
|
|
34
|
+
const container = this.containerElement as HTMLElement | undefined;
|
|
35
|
+
if (!container || container.isConnected) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const parent = this.getParentContainer();
|
|
39
|
+
if (parent) {
|
|
40
|
+
this.insertContainerIntoParent(container, parent);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
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 type { interfaces } from 'inversify';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One captured `bind(...)` or `rebind(...)` call on a {@link BindRecorder}.
|
|
14
|
+
*
|
|
15
|
+
* `chain` holds the follow-up methods in invocation order, one string per call,
|
|
16
|
+
* rendered as `name(arg, …)`. Arguments are stringified inline so assertions
|
|
17
|
+
* can compare structure without re-resolving service identifiers.
|
|
18
|
+
*/
|
|
19
|
+
export interface BindRecorderEntry {
|
|
20
|
+
readonly kind: 'bind' | 'rebind';
|
|
21
|
+
readonly token: unknown;
|
|
22
|
+
readonly chain: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A spy set of Inversify binding hooks that records every call and its chain.
|
|
27
|
+
* Hand them to a module-factory under test — they satisfy Inversify's
|
|
28
|
+
* `interfaces.Bind` / `interfaces.Rebind` / `interfaces.Unbind` /
|
|
29
|
+
* `interfaces.IsBound` shapes at the call site but never resolve real services.
|
|
30
|
+
*
|
|
31
|
+
* Together they match GLSP's `BindingContext`, so a factory taking the whole
|
|
32
|
+
* context is called as `factory(recorder, options)` — the recorder *is*
|
|
33
|
+
* structurally a context.
|
|
34
|
+
*
|
|
35
|
+
* Use {@link captured} to assert which tokens were touched and with what
|
|
36
|
+
* fluent chain. {@link find} is a small ergonomic helper for the common
|
|
37
|
+
* lookup pattern.
|
|
38
|
+
*/
|
|
39
|
+
export interface BindRecorder {
|
|
40
|
+
readonly bind: interfaces.Bind;
|
|
41
|
+
readonly rebind: interfaces.Rebind;
|
|
42
|
+
readonly unbind: interfaces.Unbind;
|
|
43
|
+
readonly isBound: interfaces.IsBound;
|
|
44
|
+
readonly captured: readonly BindRecorderEntry[];
|
|
45
|
+
/** Convenience lookup — returns the first entry matching both `kind` and `token`, or `undefined`. */
|
|
46
|
+
find(kind: 'bind' | 'rebind', token: unknown): BindRecorderEntry | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Options for {@link makeBindRecorder}. */
|
|
50
|
+
export interface BindRecorderOptions {
|
|
51
|
+
/**
|
|
52
|
+
* What `isBound` answers. Defaults to `true` for every token, matching
|
|
53
|
+
* production: a head registers its own module *after* the GLSP default
|
|
54
|
+
* modules, so the tokens a framework module rebinds are already bound. Pass a
|
|
55
|
+
* list to model a container where only those tokens are bound — e.g. to
|
|
56
|
+
* exercise a factory's bind-instead-of-rebind fallback.
|
|
57
|
+
*/
|
|
58
|
+
readonly boundTokens?: readonly unknown[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Methods on Inversify's binding builder that the recorder traps verbatim. */
|
|
62
|
+
const RECORDED_CHAIN_METHODS = [
|
|
63
|
+
'toSelf',
|
|
64
|
+
'to',
|
|
65
|
+
'toService',
|
|
66
|
+
'toConstantValue',
|
|
67
|
+
'toDynamicValue',
|
|
68
|
+
'toFactory',
|
|
69
|
+
'toFunction',
|
|
70
|
+
'toAutoFactory',
|
|
71
|
+
'toProvider',
|
|
72
|
+
'inSingletonScope',
|
|
73
|
+
'inTransientScope',
|
|
74
|
+
'inRequestScope',
|
|
75
|
+
'whenTargetNamed',
|
|
76
|
+
'whenTargetTagged',
|
|
77
|
+
'whenInjectedInto',
|
|
78
|
+
'onActivation',
|
|
79
|
+
'onDeactivation'
|
|
80
|
+
] as const;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build a {@link BindRecorder} for testing Inversify module factories without a
|
|
84
|
+
* real container: hand it to the factory, then assert over {@link BindRecorder.find}.
|
|
85
|
+
*
|
|
86
|
+
* The `bind` / `rebind` functions return a chained proxy that ignores its
|
|
87
|
+
* arguments structurally but records every method invocation as a string.
|
|
88
|
+
* Stringification: functions → `<className>`, symbols → `Symbol(desc).toString()`,
|
|
89
|
+
* everything else → `String(value)`. This is enough to assert which tokens are
|
|
90
|
+
* involved without dragging real service resolution into the test.
|
|
91
|
+
*/
|
|
92
|
+
export function makeBindRecorder(options: BindRecorderOptions = {}): BindRecorder {
|
|
93
|
+
const captured: BindRecorderEntry[] = [];
|
|
94
|
+
|
|
95
|
+
const stringifyArg = (value: unknown): string => {
|
|
96
|
+
if (typeof value === 'function') {
|
|
97
|
+
return `<${value.name || 'fn'}>`;
|
|
98
|
+
}
|
|
99
|
+
if (typeof value === 'symbol') {
|
|
100
|
+
return value.toString();
|
|
101
|
+
}
|
|
102
|
+
return String(value);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const beginEntry = (kind: 'bind' | 'rebind', token: unknown): unknown => {
|
|
106
|
+
const entry: BindRecorderEntry = { kind, token, chain: [] };
|
|
107
|
+
captured.push(entry);
|
|
108
|
+
const proxy: Record<string, (...args: unknown[]) => unknown> = {};
|
|
109
|
+
for (const method of RECORDED_CHAIN_METHODS) {
|
|
110
|
+
proxy[method] = (...args: unknown[]): unknown => {
|
|
111
|
+
entry.chain.push(`${method}(${args.map(stringifyArg).join(', ')})`);
|
|
112
|
+
return proxy;
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return proxy;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const bind = ((token: unknown) => beginEntry('bind', token)) as interfaces.Bind;
|
|
119
|
+
const rebind = ((token: unknown) => beginEntry('rebind', token)) as interfaces.Rebind;
|
|
120
|
+
const unbind = (() => undefined) as unknown as interfaces.Unbind;
|
|
121
|
+
const isBound = ((token: unknown) => options.boundTokens?.includes(token) ?? true) as interfaces.IsBound;
|
|
122
|
+
const find = (kind: 'bind' | 'rebind', token: unknown): BindRecorderEntry | undefined =>
|
|
123
|
+
captured.find(entry => entry.kind === kind && entry.token === token);
|
|
124
|
+
|
|
125
|
+
return { bind, rebind, unbind, isBound, captured, find };
|
|
126
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
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
|
+
// Subpath barrel for `@hydranium/glsp-client/testing` — the Inversify binding
|
|
11
|
+
// recorder for testing a module factory without a container.
|
|
12
|
+
|
|
13
|
+
export * from './bind-recorder';
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
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 type { Clock, Logger, Tracer } from '@hydranium/protocol';
|
|
11
|
+
import type { interfaces } from 'inversify';
|
|
12
|
+
|
|
13
|
+
/** A fresh `Symbol` cast to inversify's typed identifier, so `bind` and
|
|
14
|
+
* `@inject` sites type-check against `T` rather than `any`. */
|
|
15
|
+
function serviceIdentifier<T>(description: string): interfaces.ServiceIdentifier<T> {
|
|
16
|
+
return Symbol(description) as interfaces.ServiceIdentifier<T>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The DI tokens the framework's client classes inject, typed by the protocol
|
|
21
|
+
* interfaces so a host binds whatever implementation it has: a Theia frontend
|
|
22
|
+
* binds its Output-channel logger, a page or webview a console logger. Kept in
|
|
23
|
+
* a registry so a token's name cannot collide with the type it injects.
|
|
24
|
+
*
|
|
25
|
+
* `Logger` and `Tracer` are required, so a host that binds neither fails at
|
|
26
|
+
* resolution rather than running silent; `createGlspClientModule` binds
|
|
27
|
+
* defaults for a host that passes none. `Clock` is optional: the classes fall
|
|
28
|
+
* back to a `SystemClock`.
|
|
29
|
+
*/
|
|
30
|
+
export const HydraniumClientTypes = {
|
|
31
|
+
Logger: serviceIdentifier<Logger>('HydraniumClientLogger'),
|
|
32
|
+
Tracer: serviceIdentifier<Tracer>('HydraniumClientTracer'),
|
|
33
|
+
Clock: serviceIdentifier<Clock>('HydraniumClientClock')
|
|
34
|
+
} as const;
|