@hydranium/glsp-client-theia 1.0.0-next.32 → 1.0.0-next.321

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 (87) hide show
  1. package/README.md +60 -98
  2. package/lib/browser/client-contribution.d.ts +87 -25
  3. package/lib/browser/client-contribution.d.ts.map +1 -1
  4. package/lib/browser/client-contribution.js +233 -53
  5. package/lib/browser/client-contribution.js.map +1 -1
  6. package/lib/browser/diagram-only-marker-manager.d.ts.map +1 -1
  7. package/lib/browser/diagram-only-marker-manager.js.map +1 -1
  8. package/lib/browser/diagram-widget.d.ts +43 -25
  9. package/lib/browser/diagram-widget.d.ts.map +1 -1
  10. package/lib/browser/diagram-widget.js +76 -26
  11. package/lib/browser/diagram-widget.js.map +1 -1
  12. package/lib/browser/glsp-client-theia-module.d.ts +12 -5
  13. package/lib/browser/glsp-client-theia-module.d.ts.map +1 -1
  14. package/lib/browser/glsp-client-theia-module.js +27 -12
  15. package/lib/browser/glsp-client-theia-module.js.map +1 -1
  16. package/lib/browser/glsp-diagram-manager.d.ts +40 -1
  17. package/lib/browser/glsp-diagram-manager.d.ts.map +1 -1
  18. package/lib/browser/glsp-diagram-manager.js +100 -0
  19. package/lib/browser/glsp-diagram-manager.js.map +1 -1
  20. package/lib/browser/glsp-message-service.d.ts.map +1 -1
  21. package/lib/browser/glsp-message-service.js.map +1 -1
  22. package/lib/browser/glsp-saveable.d.ts +67 -0
  23. package/lib/browser/glsp-saveable.d.ts.map +1 -0
  24. package/lib/browser/glsp-saveable.js +110 -0
  25. package/lib/browser/glsp-saveable.js.map +1 -0
  26. package/lib/browser/glsp-theia-frontend-module.d.ts.map +1 -1
  27. package/lib/browser/glsp-theia-frontend-module.js +4 -1
  28. package/lib/browser/glsp-theia-frontend-module.js.map +1 -1
  29. package/lib/browser/hydranium-glsp-diagram-configuration.d.ts +3 -1
  30. package/lib/browser/hydranium-glsp-diagram-configuration.d.ts.map +1 -1
  31. package/lib/browser/hydranium-glsp-diagram-configuration.js +3 -1
  32. package/lib/browser/hydranium-glsp-diagram-configuration.js.map +1 -1
  33. package/lib/browser/index.d.ts +4 -3
  34. package/lib/browser/index.d.ts.map +1 -1
  35. package/lib/browser/index.js +7 -5
  36. package/lib/browser/index.js.map +1 -1
  37. package/lib/browser/theia-diagram-loader.d.ts +25 -0
  38. package/lib/browser/theia-diagram-loader.d.ts.map +1 -0
  39. package/lib/browser/theia-diagram-loader.js +42 -0
  40. package/lib/browser/theia-diagram-loader.js.map +1 -0
  41. package/lib/browser/window-session.d.ts +57 -0
  42. package/lib/browser/window-session.d.ts.map +1 -0
  43. package/lib/browser/window-session.js +102 -0
  44. package/lib/browser/window-session.js.map +1 -0
  45. package/lib/node/connection-container-module.js.map +1 -1
  46. package/lib/node/glsp-server-connection-handler.d.ts +11 -20
  47. package/lib/node/glsp-server-connection-handler.d.ts.map +1 -1
  48. package/lib/node/glsp-server-connection-handler.js +12 -15
  49. package/lib/node/glsp-server-connection-handler.js.map +1 -1
  50. package/package.json +37 -46
  51. package/src/browser/client-contribution.ts +254 -55
  52. package/src/browser/diagram-widget.ts +80 -28
  53. package/src/browser/glsp-client-theia-module.ts +34 -13
  54. package/src/browser/glsp-diagram-manager.ts +112 -3
  55. package/src/browser/glsp-saveable.ts +111 -0
  56. package/src/browser/glsp-theia-frontend-module.ts +4 -1
  57. package/src/browser/hydranium-glsp-diagram-configuration.ts +3 -1
  58. package/src/browser/index.ts +12 -5
  59. package/src/browser/theia-diagram-loader.ts +31 -0
  60. package/src/browser/window-session.ts +114 -0
  61. package/src/node/glsp-server-connection-handler.ts +12 -24
  62. package/style/diagram-loading.css +2 -5
  63. package/lib/browser/action-dispatcher.d.ts +0 -85
  64. package/lib/browser/action-dispatcher.d.ts.map +0 -1
  65. package/lib/browser/action-dispatcher.js +0 -238
  66. package/lib/browser/action-dispatcher.js.map +0 -1
  67. package/lib/browser/diagram-loader.d.ts +0 -123
  68. package/lib/browser/diagram-loader.d.ts.map +0 -1
  69. package/lib/browser/diagram-loader.js +0 -153
  70. package/lib/browser/diagram-loader.js.map +0 -1
  71. package/lib/browser/hidden-bounds-updater.d.ts +0 -122
  72. package/lib/browser/hidden-bounds-updater.d.ts.map +0 -1
  73. package/lib/browser/hidden-bounds-updater.js +0 -207
  74. package/lib/browser/hidden-bounds-updater.js.map +0 -1
  75. package/lib/testing/bind-recorder.d.ts +0 -67
  76. package/lib/testing/bind-recorder.d.ts.map +0 -1
  77. package/lib/testing/bind-recorder.js +0 -72
  78. package/lib/testing/bind-recorder.js.map +0 -1
  79. package/lib/testing/index.d.ts +0 -10
  80. package/lib/testing/index.d.ts.map +0 -1
  81. package/lib/testing/index.js +0 -30
  82. package/lib/testing/index.js.map +0 -1
  83. package/src/browser/action-dispatcher.ts +0 -257
  84. package/src/browser/diagram-loader.ts +0 -173
  85. package/src/browser/hidden-bounds-updater.ts +0 -212
  86. package/src/testing/bind-recorder.ts +0 -126
  87. package/src/testing/index.ts +0 -15
@@ -7,13 +7,17 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
- import { DiagramLoader } from '@eclipse-glsp/client';
11
- import { GLSPDiagramWidget } from '@eclipse-glsp/theia-integration';
10
+ import { type Args, DiagramLoader, EditorContextService, GLSPActionDispatcher, SetViewportAction } from '@eclipse-glsp/client';
11
+ import { RESUME_TOKEN_ARG } from '@hydranium/protocol';
12
+ import { GLSPDiagramWidget, type GLSPDiagramWidgetOptions } from '@eclipse-glsp/theia-integration';
13
+ import { type DiagramLoadOutcome, HydraniumDiagramLoader } from '@hydranium/glsp-client';
12
14
  // Type-only: the `@theia/core/lib/browser` barrel touches DOM globals at module
13
15
  // load, which the node-environment unit tests cannot provide.
14
16
  import { type Message } from '@theia/core/lib/browser';
15
- import { injectable } from '@theia/core/shared/inversify';
16
- import { type DiagramLoadOutcome, HydraniumDiagramLoader } from './diagram-loader';
17
+ import { Emitter, type Event, nls } from '@theia/core';
18
+ import { type Container, inject, injectable } from '@theia/core/shared/inversify';
19
+ import { HydraniumGlspSaveable } from './glsp-saveable';
20
+ import { WindowSessionService } from './window-session';
17
21
  // Shipped by this package rather than left to adopters: an overlay whose
18
22
  // stylesheet was forgotten is an unstyled div in normal flow, which is a silent
19
23
  // failure. Adopters override individual rules from their own stylesheet.
@@ -35,31 +39,55 @@ export const DIAGRAM_LOADING_FAILED_CLASS = `${DIAGRAM_LOADING_CLASS}-failed`;
35
39
  * model to read as a broken editor rather than a slow one.
36
40
  *
37
41
  * **How the pending state is sourced.** From {@link HydraniumDiagramLoader}, not
38
- * from `actionDispatcher.onceModelInitialized()`. The loader settles on failure
39
- * as well as on success, and it does so even when its own error-reporting
40
- * dispatch throws; `onceModelInitialized()` simply never settles on a failed
41
- * load. That distinction is not academic here: this overlay is opaque and covers
42
- * the widget node, while GLSP's `StatusOverlay` — where the loader reports the
43
- * failure — mounts *inside* the diagram's base div. Keyed on model
44
- * initialization, a failed load would leave a spinner turning on top of the
45
- * error message explaining it. Keyed on the loader, exactly one component decides
46
- * whether the canvas is pending and the two mechanisms compose instead of
47
- * competing.
42
+ * from `actionDispatcher.onceModelInitialized()`: the loader settles on failure
43
+ * as well as on success, even when its own error report throws, while
44
+ * `onceModelInitialized()` never settles on a failed load and would leave the
45
+ * spinner turning for good.
48
46
  *
49
- * **The one case where this overlay reports the failure itself.** When
50
- * `DiagramLoadFailure.surfaced` is `false` the loader could not dispatch its
51
- * `StatusAction` — the action dispatcher was what failed — so the status overlay
52
- * shows nothing. Uncovering the canvas would then leave a blank diagram whose only
53
- * explanation is a line in the Output channel. In that case, and only that case,
54
- * the overlay stays up and swaps the spinner for the error text.
47
+ * A failed load keeps the overlay up with the error and a Retry, which asks for
48
+ * a fresh widget through {@link onDidRequestReopen}.
55
49
  *
56
50
  * Bound unconditionally by `AbstractHydraniumGlspTheiaFrontendModule`. To opt out,
57
51
  * override {@link showLoadingOverlay} to a no-op; to change what is rendered,
58
52
  * override {@link createLoadingOverlay} or {@link loadingLabel}.
53
+ *
54
+ * Its saveable is a {@link HydraniumGlspSaveable}, which keeps a pending save
55
+ * dirty, so Theia's exit check still counts it; see {@link createSaveable}.
59
56
  */
60
57
  @injectable()
61
58
  export class HydraniumGlspDiagramWidget extends GLSPDiagramWidget {
59
+ @inject(WindowSessionService) protected readonly windowSessions!: WindowSessionService;
62
60
  protected loadingOverlay?: HTMLElement;
61
+ protected readonly reopenRequestEmitter = new Emitter<void>();
62
+
63
+ /** Fires when this diagram asks to be replaced by a fresh widget, as its
64
+ * Retry does; `AbstractHydraniumGlspDiagramManager` reopens it. A load is
65
+ * not repeated in place, since GLSP's model source registers its handlers
66
+ * once per load. */
67
+ readonly onDidRequestReopen: Event<void> = this.reopenRequestEmitter.event;
68
+
69
+ /**
70
+ * Replaces the saveable GLSP's `configure` builds inline, with no factory
71
+ * seam. The widget factory calls `configure` before the widget reaches the
72
+ * shell, so Theia only ever tracks this one.
73
+ */
74
+ override configure(options: GLSPDiagramWidgetOptions, diContainer: Container): void {
75
+ super.configure(options, diContainer);
76
+ this.saveable.dispose();
77
+ this.saveable = this.createSaveable();
78
+ this.toDispose.push(this.saveable);
79
+ this.toDispose.push(this.reopenRequestEmitter);
80
+ }
81
+
82
+ /** The widget's saveable. Override to change how saves and dirty state behave. */
83
+ protected createSaveable(): GLSPDiagramWidget['saveable'] {
84
+ return new HydraniumGlspSaveable(this.actionDispatcher, this.diContainer.get(EditorContextService));
85
+ }
86
+
87
+ /** Adds the window's resume token, so a reload or reconnect takes this diagram's session over. */
88
+ protected override getRequestModelOptions(): Args {
89
+ return { ...super.getRequestModelOptions(), [RESUME_TOKEN_ARG]: this.windowSessions.current().resumeToken };
90
+ }
63
91
 
64
92
  protected override onAfterAttach(msg: Message): void {
65
93
  // Before `super`, which is what starts the load on first attach — so the
@@ -73,6 +101,19 @@ export class HydraniumGlspDiagramWidget extends GLSPDiagramWidget {
73
101
  super.dispose();
74
102
  }
75
103
 
104
+ /**
105
+ * GLSP's restore with `animate: false`; compare the two on a GLSP upgrade.
106
+ * An animated restore holds back every later command, so a Delete right
107
+ * after a select-all typed as the diagram opens finds nothing selected.
108
+ */
109
+ protected override async setViewportData(viewportData: Parameters<GLSPDiagramWidget['setViewportData']>[0]): Promise<void> {
110
+ if (this.actionDispatcher instanceof GLSPActionDispatcher) {
111
+ this.actionDispatcher.dispatchOnceModelInitialized(
112
+ SetViewportAction.create(viewportData.elementId, viewportData.viewportData, { animate: false })
113
+ );
114
+ }
115
+ }
116
+
76
117
  /**
77
118
  * Cover the canvas until the load settles.
78
119
  *
@@ -100,15 +141,9 @@ export class HydraniumGlspDiagramWidget extends GLSPDiagramWidget {
100
141
  );
101
142
  }
102
143
 
103
- /**
104
- * Take the overlay down, or keep it as the failure's only reporter.
105
- *
106
- * The overlay is retained ONLY for a failure the loader could not surface;
107
- * anything else uncovers the canvas, so a load that failed *and was reported*
108
- * reveals GLSP's status overlay rather than double-reporting on top of it.
109
- */
144
+ /** Take the overlay down, or keep it as the failure's report. */
110
145
  protected onLoadSettled(outcome: DiagramLoadOutcome): void {
111
- if (outcome.status === 'failed' && !outcome.surfaced) {
146
+ if (outcome.status === 'failed') {
112
147
  this.showLoadFailure(outcome.error);
113
148
  return;
114
149
  }
@@ -136,6 +171,23 @@ export class HydraniumGlspDiagramWidget extends GLSPDiagramWidget {
136
171
  if (label) {
137
172
  label.textContent = loader.loadFailureLabel(error);
138
173
  }
174
+ overlay.appendChild(this.createRetryButton());
175
+ }
176
+
177
+ /** Ask for a fresh widget, after a failed load. */
178
+ protected retryLoad(): void {
179
+ if (this.hydraniumDiagramLoader?.loadOutcome?.status === 'failed') {
180
+ this.reopenRequestEmitter.fire();
181
+ }
182
+ }
183
+
184
+ /** Build the failure overlay's Retry button, which calls {@link retryLoad}. */
185
+ protected createRetryButton(): HTMLElement {
186
+ const button = document.createElement('button');
187
+ button.className = `theia-button ${DIAGRAM_LOADING_CLASS}-retry`;
188
+ button.textContent = nls.localize('hydranium/glsp-client-theia/diagram-load-retry', 'Retry');
189
+ button.addEventListener('click', () => this.retryLoad());
190
+ return button;
139
191
  }
140
192
 
141
193
  protected hideLoadingOverlay(): void {
@@ -7,12 +7,19 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
- import { bindChannelLogger, type ChannelLoggerOptions } from '@hydranium/client-theia/lib/browser';
11
- import { type BindingContext, DiagramLoader, GLSPActionDispatcher, GLSPHiddenBoundsUpdater } from '@eclipse-glsp/client';
12
- import { HydraniumGlspActionDispatcher } from './action-dispatcher';
13
- import { HydraniumDiagramLoader } from './diagram-loader';
10
+ import { type BindingContext, DiagramLoader } from '@eclipse-glsp/client';
11
+ import {
12
+ bindChannelLogger,
13
+ ChannelLogger,
14
+ type ChannelLoggerOptions,
15
+ ChannelTracer,
16
+ getRequestParentName
17
+ } from '@hydranium/client-theia/browser';
18
+ import { Clock } from '@hydranium/client-theia/common';
19
+ import { createGlspClientModule, HydraniumClientTypes } from '@hydranium/glsp-client';
20
+ import { SystemClock } from '@hydranium/protocol';
14
21
  import { bindHydraniumGlspMessageService } from './glsp-message-service';
15
- import { HydraniumHiddenBoundsUpdater } from './hidden-bounds-updater';
22
+ import { HydraniumGlspTheiaDiagramLoader } from './theia-diagram-loader';
16
23
 
17
24
  /** Options for `createGlspClientTheiaModule`. The adopter supplies the channel
18
25
  * logger configuration ({@link ChannelLoggerOptions} — a `channelName`, and
@@ -25,10 +32,17 @@ export interface GlspClientTheiaModuleOptions {
25
32
  }
26
33
 
27
34
  /**
28
- * Composes the standard framework client bindings: the cross-head Output-channel
29
- * logger, the instrumented action dispatcher, the failure-reporting diagram
30
- * loader, the instrumented hidden-bounds updater, and the message service that
31
- * drops the duplicate model-loading notification.
35
+ * Composes the standard framework client bindings for a Theia frontend: the
36
+ * cross-head Output-channel logger, the host-neutral classes
37
+ * `createGlspClientModule` binds, fed from that channel, the loader that renders
38
+ * its failure through Theia's catalogue, and the message service that drops
39
+ * the duplicate model-loading notification.
40
+ *
41
+ * The neutral tokens are bound BEFORE the neutral binder runs, so it binds no
42
+ * console defaults. The logger binding tags each line with the requesting
43
+ * class, as `bindChannelLogger`'s own does; a plain `toService` alias would
44
+ * start a fresh request and lose the tag. The clock is resolved lazily, so a
45
+ * clock an adopter module binds after this one is still the one used.
32
46
  *
33
47
  * Takes the whole {@link BindingContext} rather than `(bind, rebind)`: the
34
48
  * message-service rebind has to know whether GLSP's `theiaNotificationModule`
@@ -46,9 +60,16 @@ export interface GlspClientTheiaModuleOptions {
46
60
  export function createGlspClientTheiaModule(context: BindingContext, options: GlspClientTheiaModuleOptions): void {
47
61
  const { bind, isBound, rebind } = context;
48
62
  bindChannelLogger(bind, options.channelLogger);
49
- bind(HydraniumGlspActionDispatcher).toSelf().inSingletonScope();
50
- rebind(GLSPActionDispatcher).toService(HydraniumGlspActionDispatcher);
51
- rebind(DiagramLoader).to(HydraniumDiagramLoader).inSingletonScope();
52
- rebind(GLSPHiddenBoundsUpdater).to(HydraniumHiddenBoundsUpdater).inSingletonScope();
63
+ bind(HydraniumClientTypes.Logger).toDynamicValue(ctx => {
64
+ const base = ctx.container.get(ChannelLogger);
65
+ const parentName = getRequestParentName(ctx);
66
+ return parentName ? base.for(parentName) : base;
67
+ });
68
+ bind(HydraniumClientTypes.Tracer).toService(ChannelTracer);
69
+ bind(HydraniumClientTypes.Clock)
70
+ .toDynamicValue(ctx => (ctx.container.isBound(Clock) ? ctx.container.get<Clock>(Clock) : new SystemClock()))
71
+ .inSingletonScope();
72
+ createGlspClientModule(context);
73
+ rebind(DiagramLoader).to(HydraniumGlspTheiaDiagramLoader).inSingletonScope();
53
74
  bindHydraniumGlspMessageService(bind, isBound, rebind);
54
75
  }
@@ -7,12 +7,22 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
- import { codiconCSSString } from '@eclipse-glsp/client';
10
+ import { codiconCSSString, DiagramLoader, type IDiagramOptions } from '@eclipse-glsp/client';
11
11
  import { GLSPDiagramManager } from '@eclipse-glsp/theia-integration';
12
12
  import { type GLSPDiagramLanguage } from '@eclipse-glsp/theia-integration/lib/common';
13
- import { type GLSPDiagramWidget } from '@eclipse-glsp/theia-integration/lib/browser';
13
+ import {
14
+ type GLSPDiagramWidget,
15
+ type GLSPDiagramWidgetOptions,
16
+ type GLSPWidgetOpenerOptions
17
+ } from '@eclipse-glsp/theia-integration/lib/browser';
14
18
  import { type WidgetOpenerOptions } from '@theia/core/lib/browser';
15
- import { injectable } from '@theia/core/shared/inversify';
19
+ import { type Disposable, DisposableCollection } from '@theia/core';
20
+ import { inject, injectable } from '@theia/core/shared/inversify';
21
+ import { textHash } from '@hydranium/protocol';
22
+ import { HydraniumDiagramLoader } from '@hydranium/glsp-client';
23
+ import { HydraniumGlspClientContribution } from './client-contribution';
24
+ import { HydraniumGlspDiagramWidget } from './diagram-widget';
25
+ import { WindowSessionService } from './window-session';
16
26
 
17
27
  /**
18
28
  * `GLSPDiagramManager` subclass that derives the language-correlated getters
@@ -47,6 +57,12 @@ export abstract class AbstractHydraniumGlspDiagramManager extends GLSPDiagramMan
47
57
  /** Optional icon-class override; takes precedence over the language's `iconClass`. */
48
58
  protected readonly customIconClass?: string;
49
59
 
60
+ @inject(WindowSessionService) protected readonly windowSessions!: WindowSessionService;
61
+
62
+ protected clientListeners?: Disposable;
63
+ /** The reopens asked for so far, which run one at a time. */
64
+ protected reopening: Promise<void> = Promise.resolve();
65
+
50
66
  override get fileExtensions(): string[] {
51
67
  return [...this.diagramLanguage.fileExtensions];
52
68
  }
@@ -67,6 +83,99 @@ export abstract class AbstractHydraniumGlspDiagramManager extends GLSPDiagramMan
67
83
  return this.managerLabel;
68
84
  }
69
85
 
86
+ override async createWidget(options?: unknown): Promise<GLSPDiagramWidget> {
87
+ this.listenToClient();
88
+ const widget = await super.createWidget(options);
89
+ if (widget instanceof HydraniumGlspDiagramWidget) {
90
+ widget.onDidRequestReopen(() => void this.reopen(widget));
91
+ }
92
+ return widget;
93
+ }
94
+
95
+ /**
96
+ * The same client id for the same diagram in this window, across a reload
97
+ * and a reopen after a lost client, so its load takes over the session the
98
+ * old connection left and keeps its unsaved text. A diagram closed by the
99
+ * user ends its session, and opening it again starts afresh.
100
+ *
101
+ * The widget options enter as a hash: GLSP makes the id an element id and
102
+ * finds it with an `#id` selector, which a URI's `:` and `/` break. The
103
+ * options identify the widget, so two widgets of one document, in two edit
104
+ * modes or set apart by an option an adopter adds, do not take each other's
105
+ * session over.
106
+ */
107
+ protected override createDiagramOptions(options: GLSPDiagramWidgetOptions): IDiagramOptions {
108
+ const clientId = `${this.diagramType}_${this.windowSessions.current().id}_${textHash(JSON.stringify(options))}`;
109
+ return { ...super.createDiagramOptions(options), clientId };
110
+ }
111
+
112
+ /**
113
+ * Reopen every diagram once its client is lost, since none of them has a
114
+ * server behind it any more, and a failed one once a client starts.
115
+ */
116
+ protected listenToClient(): void {
117
+ if (this.clientListeners) {
118
+ return;
119
+ }
120
+ const contribution = this.diagramServiceProvider.getGLSPClientContribution(this.contributionId);
121
+ this.clientListeners =
122
+ contribution instanceof HydraniumGlspClientContribution
123
+ ? new DisposableCollection(
124
+ contribution.onDidLoseClient(() => this.reopenAll(() => true)),
125
+ contribution.onDidStartClient(() => this.reopenAll(widget => this.loadFailed(widget)))
126
+ )
127
+ : new DisposableCollection();
128
+ }
129
+
130
+ protected reopenAll(which: (widget: GLSPDiagramWidget) => boolean): void {
131
+ for (const widget of this.all.filter(which)) {
132
+ void this.reopen(widget);
133
+ }
134
+ }
135
+
136
+ protected loadFailed(widget: GLSPDiagramWidget): boolean {
137
+ const loader = widget.diContainer.get<DiagramLoader>(DiagramLoader);
138
+ return loader instanceof HydraniumDiagramLoader && loader.loadOutcome?.status === 'failed';
139
+ }
140
+
141
+ /**
142
+ * Replace `widget` with a fresh one for the same diagram, in the same tab
143
+ * position and with its viewport: a new container and load under the same
144
+ * client id, which takes over the session the old connection left.
145
+ * Reopens run one at a time, since each places its replacement next to a
146
+ * neighbour that a reopen running alongside could take out of the layout.
147
+ */
148
+ reopen(widget: GLSPDiagramWidget): Promise<void> {
149
+ const reopened = this.reopening.then(() => this.replaceWidget(widget));
150
+ this.reopening = reopened.catch(() => undefined);
151
+ return reopened;
152
+ }
153
+
154
+ /** Taken down as a close does, but without its save prompt, since a dirty
155
+ * diagram whose server is gone has nothing to save to. */
156
+ protected async replaceWidget(widget: GLSPDiagramWidget): Promise<void> {
157
+ if (widget.isDisposed) {
158
+ return;
159
+ }
160
+ const tabBar = this.shell.getTabBarFor(widget);
161
+ const titles = tabBar?.titles ?? [];
162
+ const index = titles.indexOf(widget.title);
163
+ // ponytail: a diagram alone in its tab bar reopens where a new one opens,
164
+ // since its split closes with it; restore the dock layout if that matters.
165
+ const neighbour = index > 0 ? titles[index - 1].owner : titles[index + 1]?.owner;
166
+ const mode = this.shell.activeWidget === widget ? 'activate' : tabBar?.currentTitle === widget.title ? 'reveal' : 'open';
167
+ // Detached before the dispose, as a close does: the widget stores its
168
+ // viewport on detach, and its dispose empties the container it reads.
169
+ widget.parent = null;
170
+ widget.dispose();
171
+ const options: GLSPWidgetOpenerOptions = {
172
+ mode,
173
+ editMode: widget.options.editMode,
174
+ widgetOptions: neighbour ? { ref: neighbour, mode: index > 0 ? 'tab-after' : 'tab-before' } : undefined
175
+ };
176
+ await this.open(widget.uri, options);
177
+ }
178
+
70
179
  /**
71
180
  * Drops a `selection` key that is present but `undefined` before the base
72
181
  * class reads it.
@@ -0,0 +1,111 @@
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 { RequestSaveModelAction } from '@hydranium/protocol';
11
+ // The module path, not the package root: the root also loads GLSP's diagram
12
+ // widget, which touches DOM globals at module load, and this module's unit tests
13
+ // load it in Node.
14
+ import { GLSPSaveable } from '@eclipse-glsp/theia-integration/lib/browser/diagram/glsp-saveable';
15
+
16
+ /**
17
+ * A `GLSPSaveable` whose every save is a request the server answers, and which
18
+ * stays dirty until that answer arrives.
19
+ *
20
+ * GLSP's sends a `SaveModelAction`, which the server does not answer. It takes
21
+ * the next dirty-state change to clean as the answer, but the editor context
22
+ * reports a change only when the dirty flag changes: the second of two saves
23
+ * gets no answer, and a failed save gets none either. Here each save sends a
24
+ * `RequestSaveModelAction` and settles on the response to that request only:
25
+ * it resolves when the server has saved, rejects when the server rejects it,
26
+ * and rejects when {@link saveTimeout} runs out. A save sent while another is
27
+ * pending is a request of its own, so an edit made between the two is saved.
28
+ *
29
+ * GLSP's also reports not dirty from the moment it sends a save. Theia's exit
30
+ * check asks only whether anything is dirty, so the window closes over a save
31
+ * the server has not finished, and a page reloaded right after Ctrl+S gives no
32
+ * warning. Here the saveable is dirty while any save is pending. GLSP's also
33
+ * gives up after 2 s, and Theia's Save All then counts the save as failed and
34
+ * closes the window anyway, so a slow save of a large model is cut off.
35
+ *
36
+ * The dirty flag still comes from the server. An edit whose dirty-state change
37
+ * has not arrived when Save All runs does not make the saveable dirty, so that
38
+ * edit is not saved.
39
+ *
40
+ * The server answers only where the diagram module registers the request's
41
+ * handler; `AbstractHydraniumGlspDiagramModule` does. A server without it does
42
+ * not advertise the request, and against such a server the saveable is GLSP's:
43
+ * it sends a `SaveModelAction` and reports dirty as GLSP's does. It does not
44
+ * keep only the dirty-while-pending half there, because GLSP's tracks one
45
+ * pending save and a second save overwrites it, so the first would never
46
+ * report clean.
47
+ *
48
+ * The answer means the server has done what its save does, which is not always
49
+ * the disk write: under the `fire-and-forget` `SaveDeliveryPolicy` the server
50
+ * answers before the write finishes and answers a failed write as saved, so a
51
+ * window closed on that answer can still lose the write.
52
+ */
53
+ export class HydraniumGlspSaveable extends GLSPSaveable {
54
+ /**
55
+ * How long a save waits for the server's response before it rejects. The
56
+ * server answers up to its language-client settle bound after the write, so
57
+ * this exceeds that bound with room for the write itself; a save that
58
+ * rejects in Theia's Save All lets the window close over it.
59
+ */
60
+ protected override saveTimeout = 20_000;
61
+ /** The saves sent and not yet settled. */
62
+ protected readonly pendingSaves = new Set<Promise<void>>();
63
+
64
+ override get dirty(): boolean {
65
+ if (!this.serverAnswersSaves()) {
66
+ return super.dirty;
67
+ }
68
+ return this.editorContextService.isDirty || this.pendingSaves.size > 0;
69
+ }
70
+
71
+ override save(): Promise<void> {
72
+ if (!this.serverAnswersSaves()) {
73
+ return super.save();
74
+ }
75
+ if (this.editorContextService.isDirty) {
76
+ return this.sendSave();
77
+ }
78
+ // Nothing new to save, but a save still pending has not finished.
79
+ return Promise.all(this.pendingSaves).then(() => undefined);
80
+ }
81
+
82
+ /**
83
+ * Whether the server answers a {@link RequestSaveModelAction}. GLSP's model
84
+ * source registers a handler for each kind the server advertises before it
85
+ * requests the model, so the dispatcher knows it from the first save on.
86
+ */
87
+ protected serverAnswersSaves(): boolean {
88
+ return this.actionDispatcher.hasHandler(RequestSaveModelAction.create());
89
+ }
90
+
91
+ protected sendSave(): Promise<void> {
92
+ // `request` with a bound of our own rather than GLSP's `requestUntil`:
93
+ // that one forgets a request when it times out, so a late response is
94
+ // dispatched as an action no client handler takes, which rejects.
95
+ // Here a late response settles the forgotten request and nothing else.
96
+ const response = this.actionDispatcher.request(RequestSaveModelAction.create());
97
+ let timer: ReturnType<typeof setTimeout> | undefined;
98
+ const timedOut = new Promise<never>((_resolve, reject) => {
99
+ timer = setTimeout(() => reject(new Error('Save operation timed out')), this.saveTimeout);
100
+ });
101
+ const saving: Promise<void> = Promise.race([response, timedOut])
102
+ .then(() => undefined)
103
+ .finally(() => {
104
+ clearTimeout(timer);
105
+ this.pendingSaves.delete(saving);
106
+ this.onDirtyChangedEmitter.fire(undefined);
107
+ });
108
+ this.pendingSaves.add(saving);
109
+ return saving;
110
+ }
111
+ }
@@ -17,9 +17,10 @@ import {
17
17
  registerDiagramManager
18
18
  } from '@eclipse-glsp/theia-integration';
19
19
  import { type GLSPDiagramLanguage } from '@eclipse-glsp/theia-integration/lib/common';
20
- import { bindLogLevelPreference } from '@hydranium/client-theia/lib/browser';
20
+ import { bindConnectionReporter, bindLogLevelPreference } from '@hydranium/client-theia/browser';
21
21
  import type { interfaces } from '@theia/core/shared/inversify';
22
22
  import { HydraniumGlspDiagramWidget } from './diagram-widget';
23
+ import { bindWindowSessionService } from './window-session';
23
24
 
24
25
  /**
25
26
  * Sentinel returned from {@link AbstractHydraniumGlspTheiaFrontendModule.bindClientContribution}
@@ -106,6 +107,8 @@ export abstract class AbstractHydraniumGlspTheiaFrontendModule extends GLSPTheia
106
107
  */
107
108
  override initialize(context: ContainerContext): void {
108
109
  super.initialize(context);
110
+ bindConnectionReporter(context.bind, context.isBound);
111
+ bindWindowSessionService(context.bind, context.isBound);
109
112
  if (this.logLevelPreference) {
110
113
  bindLogLevelPreference(context.bind, this.logLevelPreference);
111
114
  }
@@ -22,7 +22,9 @@ import { NoOpExternalMarkerManager } from './diagram-only-marker-manager.js';
22
22
  * **When to suppress.** A head with a co-resident LSP that publishes its
23
23
  * diagnostics over standard LSP `publishDiagnostics` would otherwise list each
24
24
  * error twice: GLSP's `TheiaMarkerManager` pushes the *same* errors again under
25
- * a different marker owner, and Theia shows the union across owners. The GLSP
25
+ * a different marker owner, and Theia shows the union across owners. One shared
26
+ * owner would not merge them either: each write replaces that owner's markers
27
+ * for the file, so the two heads would overwrite each other's. The GLSP
26
28
  * path is also open-diagram-scoped (markers clear when the diagram closes), so
27
29
  * for such a head it cannot reproduce the LSP head's persistent, cross-file
28
30
  * Problems entries — it is only ever a flickering duplicate. A graphical-only
@@ -7,16 +7,23 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
- // Browser-side framework primitives for GLSP-on-Theia adopters.
11
- // The cross-head Output-channel logger lives in `@hydranium/client-theia/browser`.
12
- export * from './action-dispatcher';
10
+ // Browser-side framework primitives for GLSP-on-Theia adopters. The
11
+ // host-neutral classes (the dispatcher, loader, updater, overlay and client)
12
+ // are `@hydranium/glsp-client`'s; the cross-head Output-channel logger lives
13
+ // in `@hydranium/client-theia/browser`.
13
14
  export * from './client-contribution';
14
- export * from './diagram-loader';
15
15
  export * from './diagram-only-marker-manager';
16
16
  export * from './diagram-widget';
17
17
  export * from './glsp-client-theia-module';
18
18
  export * from './glsp-diagram-manager';
19
19
  export * from './glsp-message-service';
20
+ export * from './glsp-saveable';
20
21
  export * from './glsp-theia-frontend-module';
21
- export * from './hidden-bounds-updater';
22
22
  export * from './hydranium-glsp-diagram-configuration';
23
+ export * from './theia-diagram-loader';
24
+ export * from './window-session';
25
+
26
+ // Upstream's root entry is its browser tier and re-exports no `lib/common`, so the
27
+ // type our abstract `diagramLanguage` members are declared with has no bare-specifier
28
+ // path — without this line every adopter implementing one deep-imports it.
29
+ export type { GLSPDiagramLanguage } from '@eclipse-glsp/theia-integration/lib/common';
@@ -0,0 +1,31 @@
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 { HydraniumDiagramLoader } from '@hydranium/glsp-client';
11
+ import { nls } from '@theia/core';
12
+ import { injectable } from '@theia/core/shared/inversify';
13
+
14
+ /**
15
+ * The framework loader rendering its failure through Theia's catalogue, which
16
+ * knows the reading user's locale where the neutral loader does not.
17
+ *
18
+ * The key and the English are inline literals, because `theia nls-extract`
19
+ * drops a key it cannot resolve. The English must match
20
+ * `DIAGRAM_LOAD_FAILED`'s text in `@hydranium/glsp-client`; a test holds them
21
+ * equal.
22
+ *
23
+ * Bound by `createGlspClientTheiaModule` via `rebind(DiagramLoader)`, after
24
+ * `createGlspClientModule` bound the neutral loader.
25
+ */
26
+ @injectable()
27
+ export class HydraniumGlspTheiaDiagramLoader extends HydraniumDiagramLoader {
28
+ override loadFailureLabel(err: unknown): string {
29
+ return nls.localize('hydranium/glsp-client-theia/diagram-load-failed', 'Diagram failed to load: {0}', this.formatError(err));
30
+ }
31
+ }