@hydranium/glsp-server 1.0.0-next.16 → 1.0.0-next.160

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 (108) hide show
  1. package/README.md +20 -5
  2. package/lib/browser/start-glsp-server-in-worker.d.ts +30 -39
  3. package/lib/browser/start-glsp-server-in-worker.d.ts.map +1 -1
  4. package/lib/browser/start-glsp-server-in-worker.js +45 -11
  5. package/lib/browser/start-glsp-server-in-worker.js.map +1 -1
  6. package/lib/command/hydranium-glsp-command-stack.d.ts +27 -0
  7. package/lib/command/hydranium-glsp-command-stack.d.ts.map +1 -0
  8. package/lib/command/hydranium-glsp-command-stack.js +53 -0
  9. package/lib/command/hydranium-glsp-command-stack.js.map +1 -0
  10. package/lib/command/hydranium-glsp-recording-command.d.ts +22 -22
  11. package/lib/command/hydranium-glsp-recording-command.d.ts.map +1 -1
  12. package/lib/command/hydranium-glsp-recording-command.js +25 -26
  13. package/lib/command/hydranium-glsp-recording-command.js.map +1 -1
  14. package/lib/command/index.d.ts +1 -0
  15. package/lib/command/index.d.ts.map +1 -1
  16. package/lib/command/index.js +1 -0
  17. package/lib/command/index.js.map +1 -1
  18. package/lib/launcher/abstract-hydranium-glsp-diagram-module.d.ts +12 -3
  19. package/lib/launcher/abstract-hydranium-glsp-diagram-module.d.ts.map +1 -1
  20. package/lib/launcher/abstract-hydranium-glsp-diagram-module.js +18 -2
  21. package/lib/launcher/abstract-hydranium-glsp-diagram-module.js.map +1 -1
  22. package/lib/launcher/glsp-server-overrides.d.ts +10 -8
  23. package/lib/launcher/glsp-server-overrides.d.ts.map +1 -1
  24. package/lib/launcher/glsp-server-overrides.js +25 -11
  25. package/lib/launcher/glsp-server-overrides.js.map +1 -1
  26. package/lib/launcher/hydranium-glsp-server.d.ts +14 -10
  27. package/lib/launcher/hydranium-glsp-server.d.ts.map +1 -1
  28. package/lib/launcher/hydranium-glsp-server.js +20 -10
  29. package/lib/launcher/hydranium-glsp-server.js.map +1 -1
  30. package/lib/messages/index.d.ts +24 -0
  31. package/lib/messages/index.d.ts.map +1 -0
  32. package/lib/messages/index.js +24 -0
  33. package/lib/messages/index.js.map +1 -0
  34. package/lib/node/start-glsp-server.d.ts +1 -1
  35. package/lib/node/start-glsp-server.d.ts.map +1 -1
  36. package/lib/node/start-glsp-server.js +3 -4
  37. package/lib/node/start-glsp-server.js.map +1 -1
  38. package/lib/state/abstract-hydranium-glsp-state.d.ts +110 -43
  39. package/lib/state/abstract-hydranium-glsp-state.d.ts.map +1 -1
  40. package/lib/state/abstract-hydranium-glsp-state.js +139 -60
  41. package/lib/state/abstract-hydranium-glsp-state.js.map +1 -1
  42. package/lib/state/full-text-hydranium-glsp-state.d.ts +6 -6
  43. package/lib/state/full-text-hydranium-glsp-state.d.ts.map +1 -1
  44. package/lib/state/full-text-hydranium-glsp-state.js +6 -10
  45. package/lib/state/full-text-hydranium-glsp-state.js.map +1 -1
  46. package/lib/state/reconcile-source-model-write.d.ts +9 -8
  47. package/lib/state/reconcile-source-model-write.d.ts.map +1 -1
  48. package/lib/state/reconcile-source-model-write.js +8 -8
  49. package/lib/state/reconcile-source-model-write.js.map +1 -1
  50. package/lib/state/reconciling-multi-document-glsp-state.d.ts +59 -59
  51. package/lib/state/reconciling-multi-document-glsp-state.d.ts.map +1 -1
  52. package/lib/state/reconciling-multi-document-glsp-state.js +84 -82
  53. package/lib/state/reconciling-multi-document-glsp-state.js.map +1 -1
  54. package/lib/state/reconciling-transfer-hydranium-glsp-state.d.ts +19 -18
  55. package/lib/state/reconciling-transfer-hydranium-glsp-state.d.ts.map +1 -1
  56. package/lib/state/reconciling-transfer-hydranium-glsp-state.js +26 -30
  57. package/lib/state/reconciling-transfer-hydranium-glsp-state.js.map +1 -1
  58. package/lib/storage/hydranium-glsp-request-save-model-action-handler.d.ts +30 -0
  59. package/lib/storage/hydranium-glsp-request-save-model-action-handler.d.ts.map +1 -0
  60. package/lib/storage/hydranium-glsp-request-save-model-action-handler.js +47 -0
  61. package/lib/storage/hydranium-glsp-request-save-model-action-handler.js.map +1 -0
  62. package/lib/storage/hydranium-glsp-storage.d.ts +313 -86
  63. package/lib/storage/hydranium-glsp-storage.d.ts.map +1 -1
  64. package/lib/storage/hydranium-glsp-storage.js +456 -104
  65. package/lib/storage/hydranium-glsp-storage.js.map +1 -1
  66. package/lib/storage/index.d.ts +2 -1
  67. package/lib/storage/index.d.ts.map +1 -1
  68. package/lib/storage/index.js +2 -1
  69. package/lib/storage/index.js.map +1 -1
  70. package/lib/storage/save-delivery-policy.d.ts +46 -0
  71. package/lib/storage/save-delivery-policy.d.ts.map +1 -0
  72. package/lib/storage/{save-conflict-policy.js → save-delivery-policy.js} +7 -8
  73. package/lib/storage/save-delivery-policy.js.map +1 -0
  74. package/lib/testing/glsp-harness.js +4 -4
  75. package/lib/testing/glsp-harness.js.map +1 -1
  76. package/lib/validation/diagnostic-markers.d.ts +2 -2
  77. package/lib/validation/diagnostic-markers.d.ts.map +1 -1
  78. package/lib/validation/diagnostic-markers.js +7 -0
  79. package/lib/validation/diagnostic-markers.js.map +1 -1
  80. package/lib/validation/hydranium-glsp-model-validator.d.ts.map +1 -1
  81. package/lib/validation/hydranium-glsp-model-validator.js +1 -1
  82. package/lib/validation/hydranium-glsp-model-validator.js.map +1 -1
  83. package/package.json +17 -8
  84. package/src/browser/start-glsp-server-in-worker.ts +43 -47
  85. package/src/command/hydranium-glsp-command-stack.ts +36 -0
  86. package/src/command/hydranium-glsp-recording-command.ts +26 -26
  87. package/src/command/index.ts +1 -0
  88. package/src/launcher/abstract-hydranium-glsp-diagram-module.ts +27 -3
  89. package/src/launcher/glsp-server-overrides.ts +27 -11
  90. package/src/launcher/hydranium-glsp-server.ts +20 -10
  91. package/src/messages/index.ts +30 -0
  92. package/src/node/start-glsp-server.ts +3 -5
  93. package/src/state/abstract-hydranium-glsp-state.ts +174 -73
  94. package/src/state/full-text-hydranium-glsp-state.ts +7 -11
  95. package/src/state/reconcile-source-model-write.ts +18 -15
  96. package/src/state/reconciling-multi-document-glsp-state.ts +86 -83
  97. package/src/state/reconciling-transfer-hydranium-glsp-state.ts +27 -31
  98. package/src/storage/hydranium-glsp-request-save-model-action-handler.ts +40 -0
  99. package/src/storage/hydranium-glsp-storage.ts +520 -113
  100. package/src/storage/index.ts +2 -1
  101. package/src/storage/save-delivery-policy.ts +44 -0
  102. package/src/testing/glsp-harness.ts +4 -4
  103. package/src/validation/diagnostic-markers.ts +10 -3
  104. package/src/validation/hydranium-glsp-model-validator.ts +3 -3
  105. package/lib/storage/save-conflict-policy.d.ts +0 -56
  106. package/lib/storage/save-conflict-policy.d.ts.map +0 -1
  107. package/lib/storage/save-conflict-policy.js.map +0 -1
  108. package/src/storage/save-conflict-policy.ts +0 -52
@@ -15,15 +15,65 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
15
15
  var __metadata = (this && this.__metadata) || function (k, v) {
16
16
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
17
17
  };
18
- import { MarkersReason, SetMarkersAction } from '@eclipse-glsp/protocol';
19
- import { ActionDispatcher, ClientSessionManager, CommandStack, EditMode, GLSPServerError, Logger as GlspLogger, ModelState, ModelSubmissionHandler, ModelValidator, SOURCE_URI_ARG, SetEditModeAction, TEMPORARY_CLIENT_ID } from '@eclipse-glsp/server';
20
- import { Debouncer, DisposableCollection } from '@hydranium/protocol';
18
+ import { MarkersReason, MessageAction, SetMarkersAction, StatusAction } from '@eclipse-glsp/protocol';
19
+ import { ActionDispatcher, ClientSessionManager, CommandStack, EditMode, GLSPServerError, Logger as GlspLogger, ModelState, ModelSubmissionHandler, ModelValidator, SOURCE_URI_ARG, SetDirtyStateAction, SetEditModeAction, TEMPORARY_CLIENT_ID } from '@eclipse-glsp/server';
20
+ import { Debouncer, defineMessage, DisposableCollection, isDuplicateClientIdError, isSessionClosedError, SessionClosedError } from '@hydranium/protocol';
21
21
  import { inject, injectable, optional, postConstruct } from 'inversify';
22
22
  import { URI } from '@hydranium/langium';
23
23
  import { AstDocument } from '@hydranium/core';
24
24
  import { DiagnosticSeverity } from 'vscode-languageserver-types';
25
25
  import { HydraniumTypes } from '../state/hydranium-shared-core-services.js';
26
- import { DEFAULT_SAVE_CONFLICT_POLICY, SaveConflictPolicy } from './save-conflict-policy.js';
26
+ import { DEFAULT_SAVE_DELIVERY_POLICY, SaveDeliveryPolicy } from './save-delivery-policy.js';
27
+ /**
28
+ * A save action arrived with nowhere to write to. A save action carries no
29
+ * request id, so it falls through to the error handler and `message` becomes
30
+ * the toast text.
31
+ *
32
+ * Rendered at the raise site rather than by a carrier method, because GLSP's
33
+ * action protocol has no slot for an identity anywhere — every member of its
34
+ * message, status and reject actions is prose or an enum, and
35
+ * `MessageAction.details` is not a substitute since it is prose populated from
36
+ * `cause?.toString?.()`, and a Theia host shows it to a user on demand. So the
37
+ * throw is the last place that still knows which message this is.
38
+ */
39
+ export const SAVE_TARGET_UNKNOWN = defineMessage('hydranium/glsp-server/save-target-unknown', 'Could not determine where to save this model');
40
+ /**
41
+ * A model request arrived without the source URI it is required to carry.
42
+ * Rendered at the raise site, like its save-path sibling and for the same
43
+ * reason.
44
+ */
45
+ export const SOURCE_URI_MISSING = defineMessage('hydranium/glsp-server/source-uri-missing', 'Could not open this model: the request did not say which document to load');
46
+ /**
47
+ * Why the canvas has stopped accepting edits.
48
+ *
49
+ * **A READONLY canvas is otherwise indistinguishable from a broken one.** The
50
+ * client's answer to {@link SetEditModeAction} is to withdraw the tool palette,
51
+ * so the surface a reader was working in silently loses the only control it had,
52
+ * with the cause — a syntax error in a document that may not even be open —
53
+ * nowhere on screen. The mode flip is the mechanism; this is the only part of it
54
+ * a user can see.
55
+ *
56
+ * Rendered at the raise site, like its two siblings above and for the same
57
+ * reason: every member of GLSP's status action is prose or an enum.
58
+ *
59
+ * It names the recovery rather than the fault, because the fault already has a
60
+ * surface — the squiggle and the problems list, both of which say WHICH
61
+ * character — and repeating it here would put a second, less precise account of
62
+ * the same error on screen. What no other surface says is that the diagram is
63
+ * waiting on it.
64
+ */
65
+ export const DIAGRAM_READONLY_PARSE_ERROR = defineMessage('hydranium/glsp-server/diagram-readonly-parse-error', 'Read-only: this document has a syntax error. Fix it to edit the diagram again.');
66
+ /**
67
+ * The diagram's GLSP client id is still live as another participant's client
68
+ * id once the load has waited for it to free, so the diagram does not load.
69
+ *
70
+ * Rendered at the raise site, as {@link SAVE_TARGET_UNKNOWN} is and for the
71
+ * same reason, and dispatched to the client as a message of its own: a model
72
+ * request fails as a rejection whose detail is the English cause, which no user
73
+ * sees. It names the recovery: a host that numbers its diagram clients gives a
74
+ * reopened diagram a fresh id.
75
+ */
76
+ export const DIAGRAM_SESSION_REFUSED = defineMessage('hydranium/glsp-server/diagram-session-refused', 'Could not open this diagram: its identifier is still in use by another editor. Close the diagram and open it again.');
27
77
  /** Window (ms) over which back-to-back external rebuilds collapse into one resubmit. */
28
78
  const EXTERNAL_SUBMIT_DEBOUNCE_MS = 250;
29
79
  /**
@@ -69,17 +119,24 @@ function isStructuralDiagnostic(diagnostic) {
69
119
  *
70
120
  * Adopters with richer needs override the seams ({@link isStructurallyBroken},
71
121
  * {@link onParseErrorChanged}, {@link onSourceModelSettled}) rather than the
72
- * whole flow, and select a {@link SaveConflictPolicy} via the bound option to
73
- * tune how {@link saveSourceModel} reacts to a concurrent edit.
122
+ * whole flow, and select a {@link SaveDeliveryPolicy} via the bound option to
123
+ * tune how {@link saveSourceModel} delivers its result.
74
124
  *
75
- * **Default `saveSourceModel` flow.** Delegates to `ModelService.save`, which
76
- * serialises through the language-specific `Serializer` bound at
77
- * `services.serializer.Serializer` (resolved per-URI via `ServiceRegistry`),
78
- * updates the multi-client text-document store, drives a rebuild, and writes the
79
- * result via the `WritableFileSystemProvider`. The bound
80
- * {@link SaveConflictPolicy} (default {@link DEFAULT_SAVE_CONFLICT_POLICY},
81
- * `overwrite`) decides the based-on-version guard, await-vs-fire-and-forget, and
82
- * failure handling.
125
+ * **One client session per GLSP client session.** `init` registers the GLSP
126
+ * client id as a client session ({@link registerModelSession}) and hands it to
127
+ * the state as `modelSession`. The primary is opened through it on load, and
128
+ * every document that joins the write set as it joins
129
+ * ({@link openSecondary}); a document that leaves the write set stays open
130
+ * until the next save, so leaving never reverts the diagram's unsaved edits to
131
+ * it. Disposing the storage ends the session, which closes everything it has
132
+ * open.
133
+ *
134
+ * **Default `saveSourceModel` flow.** Flushes the stored text of every
135
+ * document the diagram's session has open via `AstDocumentManager.save`, with
136
+ * no serializer in the path — the update path already put the settled text in
137
+ * the store. The bound {@link SaveDeliveryPolicy} (default
138
+ * {@link DEFAULT_SAVE_DELIVERY_POLICY}, `await`) decides
139
+ * await-vs-fire-and-forget and failure handling.
83
140
  *
84
141
  * **tempId filter.** GLSP's `DefaultGlobalActionProvider` spins up a
85
142
  * throwaway per-diagram-type container with upstream's {@link TEMPORARY_CLIENT_ID}
@@ -90,9 +147,9 @@ function isStructuralDiagnostic(diagnostic) {
90
147
  * **Lifecycle ordering.** `init` runs after DI resolution and parks the
91
148
  * secondary-write-set watch, which outlives any single load.
92
149
  * {@link sessionDisposed} calls {@link dispose}, which drains
93
- * {@link toDispose} idempotently. Every transient subscription created in
94
- * {@link doLoadSourceModel} is parked on {@link toDispose} so the drain catches
95
- * them on client-detach.
150
+ * {@link toDispose} idempotently and then ends the client session. Every
151
+ * transient subscription created in {@link doLoadSourceModel} is parked on
152
+ * {@link toDispose} so the drain catches them on client-detach.
96
153
  */
97
154
  let HydraniumGlspStorage = class HydraniumGlspStorage {
98
155
  logger;
@@ -111,14 +168,13 @@ let HydraniumGlspStorage = class HydraniumGlspStorage {
111
168
  */
112
169
  modelValidator;
113
170
  /**
114
- * Selected {@link SaveConflictPolicy} for {@link saveSourceModel}. Bind the
115
- * {@link SaveConflictPolicy} token in a `DiagramModule` to choose a policy;
116
- * left unbound it resolves to {@link DEFAULT_SAVE_CONFLICT_POLICY}
117
- * (`overwrite`, last-write-wins). Read via {@link saveConflictPolicy} so the
118
- * fallback is applied even when inversify injects `undefined` for an unbound
119
- * `@optional()` member.
171
+ * Selected {@link SaveDeliveryPolicy} for {@link saveSourceModel}. Bind the
172
+ * {@link SaveDeliveryPolicy} token in a `DiagramModule` to choose a policy;
173
+ * left unbound it resolves to {@link DEFAULT_SAVE_DELIVERY_POLICY}. Read via
174
+ * {@link saveDeliveryPolicy} so the fallback is applied even when inversify
175
+ * injects `undefined` for an unbound `@optional()` member.
120
176
  */
121
- boundSaveConflictPolicy;
177
+ boundSaveDeliveryPolicy;
122
178
  /** Disposables created during {@link doLoadSourceModel}; drained on session disposal. */
123
179
  toDispose = new DisposableCollection();
124
180
  /**
@@ -130,6 +186,24 @@ let HydraniumGlspStorage = class HydraniumGlspStorage {
130
186
  * with it rather than linger until the client detaches.
131
187
  */
132
188
  secondarySubscriptions = new Map();
189
+ /**
190
+ * Documents that left the write set since the last save. They stay open in
191
+ * the diagram's session until that save has persisted them, and are closed
192
+ * after it: closing a document as it leaves would revert the diagram's
193
+ * unsaved edits to it where the diagram was its last client.
194
+ */
195
+ departedSecondaries = new Set();
196
+ /**
197
+ * How long a load waits for a client id another participant holds to free
198
+ * before refusing. A reloaded client can reconnect under its old id before
199
+ * the server has noticed the old connection close, which ends that
200
+ * connection's sessions a moment later.
201
+ */
202
+ sessionWaitMs = 2_000;
203
+ /** Set by {@link dispose}; a disposed storage registers no session again. */
204
+ disposed = false;
205
+ /** Saves of the diagram's own that GLSP's save handler awaits; see {@link handleDirtyChanged}. */
206
+ ownSavesPending = 0;
133
207
  /**
134
208
  * Whether an external resubmit has ever been dispatched. Gates the
135
209
  * keep-the-last-valid-GModel branch, which needs to know a canvas is already
@@ -155,6 +229,7 @@ let HydraniumGlspStorage = class HydraniumGlspStorage {
155
229
  if (this.state.clientId === TEMPORARY_CLIENT_ID) {
156
230
  return;
157
231
  }
232
+ this.state.modelSession = this.registerModelSession();
158
233
  this.sessionManager.addListener(this, this.state.clientId);
159
234
  // Watched for this storage's whole lifetime rather than per load. The load
160
235
  // flow is a documented override point, so a subscription parked there would
@@ -162,6 +237,92 @@ let HydraniumGlspStorage = class HydraniumGlspStorage {
162
237
  // run long after any load.
163
238
  this.toDispose.push(this.state.onSecondaryUrisChanged(() => this.reconcileSecondarySubscriptions()));
164
239
  }
240
+ /**
241
+ * Register the diagram's client session under the GLSP client id, or
242
+ * `undefined` when another participant holds that id, which is logged.
243
+ *
244
+ * The diagram then refuses to load ({@link requireModelSession}). Taking
245
+ * the id over would end the other participant's session under it, and
246
+ * working without a session would share that participant's opens and
247
+ * echoes, so its close would take the diagram's documents with it.
248
+ *
249
+ * A client id the framework reserves throws `ReservedClientIdError`:
250
+ * whoever chose the GLSP client id has a bug, and no later registration can
251
+ * succeed.
252
+ */
253
+ registerModelSession() {
254
+ try {
255
+ return this.sharedServices.model.ModelService.createSession('diagram', this.state.clientId);
256
+ }
257
+ catch (error) {
258
+ if (!isDuplicateClientIdError(error)) {
259
+ throw error;
260
+ }
261
+ this.logger.warn(`Client id ${this.state.clientId} is held by another participant: this diagram has no session yet`);
262
+ return undefined;
263
+ }
264
+ }
265
+ /**
266
+ * The diagram's client session, registering it again when the id was held
267
+ * earlier. Throws `SessionClosedError` once the storage is disposed, and the
268
+ * refusal when the id is still held.
269
+ */
270
+ requireModelSession() {
271
+ if (this.disposed) {
272
+ throw new SessionClosedError(this.state.clientId, 'The diagram has closed; its client session has ended.');
273
+ }
274
+ if (!this.state.modelSession) {
275
+ this.state.modelSession = this.registerModelSession();
276
+ }
277
+ const session = this.state.modelSession;
278
+ if (!session) {
279
+ throw new GLSPServerError(this.sharedServices.MessageRenderer.renderMessage(DIAGRAM_SESSION_REFUSED), `clientId=${this.state.clientId} is still held by another participant, so this diagram has no client session`);
280
+ }
281
+ return session;
282
+ }
283
+ /**
284
+ * {@link requireModelSession} for the load: while the id is held, wait up to
285
+ * {@link sessionWaitMs} for its holder to end before refusing, and tell the
286
+ * user when it does not.
287
+ */
288
+ async awaitModelSession() {
289
+ if (!this.state.modelSession && !this.disposed) {
290
+ this.state.modelSession = this.registerModelSession();
291
+ }
292
+ if (!this.state.modelSession && !this.disposed) {
293
+ await this.waitForSessionEnd(this.state.clientId, this.sessionWaitMs);
294
+ }
295
+ try {
296
+ return this.requireModelSession();
297
+ }
298
+ catch (error) {
299
+ // A diagram closed while it waited has no client left to tell.
300
+ if (isSessionClosedError(error)) {
301
+ throw error;
302
+ }
303
+ this.logger.error(`Client id ${this.state.clientId} is still held by another participant: this diagram does not load`);
304
+ this.actionDispatcher
305
+ .dispatch(MessageAction.create(this.sharedServices.MessageRenderer.renderMessage(DIAGRAM_SESSION_REFUSED), { severity: 'ERROR' }))
306
+ .catch((dispatchError) => this.logger.warn(`Could not tell the client the diagram was refused: ${String(dispatchError)}`));
307
+ throw error;
308
+ }
309
+ }
310
+ /** Resolve once the client session under `clientId` ends, or after `timeoutMs`. */
311
+ waitForSessionEnd(clientId, timeoutMs) {
312
+ return new Promise(resolve => {
313
+ const subscriptions = new DisposableCollection();
314
+ const done = () => {
315
+ subscriptions.dispose();
316
+ resolve();
317
+ };
318
+ subscriptions.push(this.sharedServices.Clock.setTimer(done, timeoutMs));
319
+ subscriptions.push(this.sharedServices.workspace.TextDocuments.onDidCloseSession(event => {
320
+ if (event.clientId === clientId) {
321
+ done();
322
+ }
323
+ }));
324
+ });
325
+ }
165
326
  /**
166
327
  * Default load flow — {@link doLoadSourceModel}. Adopters whose load flow
167
328
  * needs more than the seams allow override this method.
@@ -175,8 +336,8 @@ let HydraniumGlspStorage = class HydraniumGlspStorage {
175
336
  const modelService = this.sharedServices.model.ModelService;
176
337
  // Open FIRST: a workspace-scanned-but-never-didOpened document rebuilds here,
177
338
  // so the capture below settles on a built root rather than a transient
178
- // mid-rebuild one.
179
- this.toDispose.push(await modelService.open({ uri: rootUri, clientId: this.state.clientId }));
339
+ // mid-rebuild one. Closed when the session ends.
340
+ await (await this.awaitModelSession()).open(rootUri);
180
341
  // GLSP's sessionDisposed is unreliable on Theia tab-close; dispose on client
181
342
  // detach so reopens don't accumulate stale onModelUpdated listeners.
182
343
  this.toDispose.push(modelService.onClientClosed(rootUri, this.state.clientId, () => {
@@ -185,19 +346,38 @@ let HydraniumGlspStorage = class HydraniumGlspStorage {
185
346
  }));
186
347
  // React to external rebuilds: settle-gated capture + debounced/deduped resubmit.
187
348
  this.toDispose.push(modelService.onModelUpdated(rootUri, event => this.handleModelUpdated(rootUri, event)));
188
- // Coordinate the command stack's dirty state when another client saves.
349
+ // GLSP's own command stack counts commands; tell it of another client's save.
189
350
  this.toDispose.push(modelService.onModelSaved(rootUri, event => this.handleModelSaved(event)));
351
+ // Tell the client of each dirty flip, one the diagram did not cause included.
352
+ this.toDispose.push(this.sharedServices.workspace.TextDocuments.onDidChangeDirty(event => this.handleDirtyChanged(event)));
190
353
  // Capture the initial settled root.
191
354
  const document = await modelService.settled(rootUri);
192
355
  await this.captureSettledRoot(rootUri, document);
193
356
  }
194
357
  /**
195
- * Capture the initial settled root and apply the initial edit mode. `settled()`
196
- * strips diagnostics by phase contract, so the first edit mode is `EDITABLE`;
197
- * the first {@link handleModelUpdated} (at `Validated`, with diagnostics) flips
198
- * it READONLY if the document has structural parse errors. Edit-mode +
199
- * settle-hook actions are dispatched on a macrotask so the initial
358
+ * Capture the initial settled root and apply the initial edit mode. Edit-mode
359
+ * + settle-hook actions are dispatched on a macrotask so the initial
200
360
  * `requestModel → setModel` handshake isn't perturbed.
361
+ *
362
+ * **Whether the settled document carries diagnostics depends on how far it
363
+ * had already got, so the initial edit mode has two correct outcomes rather
364
+ * than one.** `settled()` strips nothing: it resolves AT OR ABOVE the
365
+ * integrity landmark and hands back the live document's own array. Its
366
+ * `AstDocument<TAst, never>` return type asserts emptiness for a document the
367
+ * wait had to DRIVE to that landmark, which is pre-validation — and for that
368
+ * one the mode here is `EDITABLE` and the flip to READONLY arrives with the
369
+ * first {@link handleModelUpdated}. A document already past `Validated` when
370
+ * the session opened resolves immediately with its diagnostics intact, and is
371
+ * decided correctly here with no later update owed. Any host that validates
372
+ * its workspace before a diagram is opened produces the second case, so
373
+ * neither is exceptional.
374
+ *
375
+ * **A correct decision is not a delivered one.** This dispatch happens inside
376
+ * the initial `requestModel`, before the client has the model — so the UI
377
+ * extensions that answer {@link onParseErrorChanged}'s actions are not yet
378
+ * constructed, and a canvas that opens READONLY over a broken document shows
379
+ * none of that feedback. The same timer carries {@link onSourceModelSettled},
380
+ * so an adopter's own initial actions have it too.
201
381
  */
202
382
  async captureSettledRoot(rootUri, document) {
203
383
  this.state.setSourceRoot(rootUri, document.root);
@@ -231,22 +411,48 @@ let HydraniumGlspStorage = class HydraniumGlspStorage {
231
411
  if (!current.has(uri)) {
232
412
  subscription.dispose();
233
413
  this.secondarySubscriptions.delete(uri);
414
+ this.departedSecondaries.add(uri);
234
415
  }
235
416
  }
236
417
  for (const uri of current) {
237
418
  if (!this.secondarySubscriptions.has(uri)) {
419
+ this.departedSecondaries.delete(uri);
238
420
  this.secondarySubscriptions.set(uri, this.sharedServices.model.ModelService.onModelUpdated(uri, event => this.handleSecondaryUpdated(uri, event)));
421
+ this.openSecondary(uri);
239
422
  }
240
423
  }
241
424
  }
425
+ /**
426
+ * Open a document that joined the write set through the diagram's session,
427
+ * where it stays open until the diagram's next save after it leaves the set,
428
+ * or the diagram's end. A document that does not exist yet is left to the
429
+ * write that creates it, and a failed open is logged: the write that needs
430
+ * the document opens it again and fails there.
431
+ */
432
+ openSecondary(uri) {
433
+ const session = this.state.modelSession;
434
+ if (!session || !this.sharedServices.model.ModelService.getDocument(uri)) {
435
+ return;
436
+ }
437
+ session.open(uri).catch((error) => {
438
+ const detail = `Could not open ${uri} for ${this.state.clientId}: ${error instanceof Error ? error.message : String(error)}`;
439
+ // A session ending while the open reads is the diagram closing.
440
+ if (isSessionClosedError(error)) {
441
+ this.logger.debug(detail);
442
+ }
443
+ else {
444
+ this.logger.warn(detail);
445
+ }
446
+ });
447
+ }
242
448
  /**
243
449
  * React to a rebuild of a SECONDARY document by resubmitting the diagram, so
244
450
  * an external edit to (say) a layout file reaches the canvas.
245
451
  *
246
- * Reuses {@link handleModelUpdated}'s authorship guard, and that is the load-
247
- * bearing half rather than the resubmit: a diagram interaction that writes a
452
+ * Applies {@link handleModelUpdated}'s guard, and that is the load-bearing
453
+ * half rather than the resubmit: a diagram interaction that writes a
248
454
  * secondary — a drag persisting bounds to a layout file — comes back through
249
- * this listener as the client's own `changed` edit, and resubmitting on it
455
+ * this listener caused by the client's own write, and resubmitting on it
250
456
  * fights the optimistic client-side move the user is still holding.
251
457
  *
252
458
  * Schedules the PRIMARY's current document, never the secondary's.
@@ -268,14 +474,14 @@ let HydraniumGlspStorage = class HydraniumGlspStorage {
268
474
  if (this.disposeIfStale(uri)) {
269
475
  return;
270
476
  }
271
- if (this.state.clientId === event.sourceClientId && event.reason === 'changed') {
477
+ if (event.causedBy === this.state.clientId) {
272
478
  return;
273
479
  }
274
480
  const primary = this.currentPrimaryDocument();
275
481
  if (primary === undefined) {
276
482
  return;
277
483
  }
278
- this.logger.debug(`Secondary ${uri} rebuilt by ${event.sourceClientId} (${event.reason}) — scheduling resubmit`);
484
+ this.logger.debug(`Secondary ${uri} rebuilt, caused by ${event.causedBy ?? 'an unknown client'} (${event.reason}) — scheduling resubmit`);
279
485
  this.scheduleUpdateAndSubmit(primary);
280
486
  }
281
487
  /**
@@ -284,9 +490,10 @@ let HydraniumGlspStorage = class HydraniumGlspStorage {
284
490
  *
285
491
  * Synchronous by construction — the phase-agnostic lookup door plus the shared
286
492
  * projection — so reacting to a secondary neither waits nor can force a build.
287
- * `settled()` is not an alternative: it strips diagnostics by phase contract,
288
- * and diagnostics are the reason {@link doUpdateAndSubmit} takes a separate
289
- * event document at all.
493
+ * `settled()` is not an alternative: besides being asynchronous, it cannot be
494
+ * relied on to carry diagnostics — a document it has to drive to the landmark
495
+ * arrives pre-validation with an empty array — and diagnostics are the reason
496
+ * {@link doUpdateAndSubmit} takes a separate event document at all.
290
497
  */
291
498
  currentPrimaryDocument() {
292
499
  const document = this.sharedServices.model.ModelService.getDocument(this.state.sourceUri);
@@ -295,16 +502,23 @@ let HydraniumGlspStorage = class HydraniumGlspStorage {
295
502
  /**
296
503
  * React to an external rebuild reaching `Validated`. Self-cleans if the
297
504
  * session has vanished but this listener leaked (neither `sessionDisposed`
298
- * nor `onClientClosed` fired on tab close). Resubmits for any change not
299
- * authored by this client's own optimistic `changed` edit, then refreshes
300
- * diagnostic markers for every update — including own edits, which skip
301
- * the resubmit but can still change diagnostics.
505
+ * nor `onClientClosed` fired on tab close). Resubmits for any update this
506
+ * client's own write did not cause alone, then refreshes diagnostic markers
507
+ * for every update — including own writes, which skip the resubmit but can
508
+ * still change diagnostics. An event without `causedBy` is resubmitted.
509
+ *
510
+ * Own writes include a primary that the build of a write to a secondary
511
+ * swept in, which arrives `rebuilt`. Skipping it loses nothing the canvas
512
+ * shows: the operation behind the write submits once the write has
513
+ * answered, and a build steps its whole batch through each phase, so that
514
+ * submit already renders the relinked primary. What the later `Validated`
515
+ * adds are diagnostics, which the marker refresh carries.
302
516
  */
303
517
  async handleModelUpdated(rootUri, event) {
304
518
  if (this.disposeIfStale(rootUri)) {
305
519
  return;
306
520
  }
307
- if (this.state.clientId !== event.sourceClientId || event.reason !== 'changed') {
521
+ if (event.causedBy !== this.state.clientId) {
308
522
  this.scheduleUpdateAndSubmit(event.document);
309
523
  }
310
524
  await this.refreshDiagnosticMarkers();
@@ -348,20 +562,50 @@ let HydraniumGlspStorage = class HydraniumGlspStorage {
348
562
  this.doUpdateAndSubmit(rootUri, document).then(actions => this.actionDispatcher.dispatchAll(actions), error => this.logger.error(`Update-and-submit failed for ${rootUri}: ${error instanceof Error ? error.message : String(error)}`));
349
563
  }
350
564
  /**
351
- * Coordinate the command stack when another client persists the document:
352
- * mark our stack clean so the editor doesn't prompt to re-save identical
353
- * content. Our own saves already settle the stack through the save flow.
565
+ * For a diagram module on GLSP's own command stack, which counts commands:
566
+ * mark it clean when another client persists the document.
567
+ * `HydraniumGlspCommandStack` reads the text store instead, and this does
568
+ * not move it.
354
569
  */
355
570
  handleModelSaved(event) {
356
571
  if (this.state.clientId !== event.sourceClientId) {
357
572
  this.commandStack.saveIsDone();
358
573
  }
359
574
  }
575
+ /**
576
+ * Send the diagram's dirty state when a document it has open turns dirty or
577
+ * clean, the answer being the command stack's. GLSP sends it only with a
578
+ * model submission or a save of the diagram's own, so a save by another
579
+ * client, an editor included, or an edit that changes nothing the diagram
580
+ * shows would otherwise leave the client's marker wrong until the next
581
+ * gesture. A flip the diagram's own operation causes is sent twice, once
582
+ * here and once with the submission, and the client keeps the one state.
583
+ *
584
+ * Nothing is sent while a save of the diagram's own is awaited: GLSP's save
585
+ * handler sends the state once the save settles, reason `save`, and GLSP's
586
+ * client keeps a state only when it changes, so the save's own flip sent
587
+ * here first would leave that answer changing nothing. GLSP's saveable
588
+ * waits for exactly that answer, and times out without it.
589
+ */
590
+ handleDirtyChanged(event) {
591
+ if (this.ownSavesPending > 0 || !this.sharedServices.workspace.TextDocuments.isOpenInClient(event.uri, this.state.clientId)) {
592
+ return;
593
+ }
594
+ this.sendDirtyState();
595
+ }
596
+ /** Send the command stack's dirty state, reason `external`. */
597
+ sendDirtyState() {
598
+ this.actionDispatcher
599
+ .dispatch(SetDirtyStateAction.create(this.commandStack.isDirty, { reason: 'external' }))
600
+ .catch((error) => this.logger.warn(`Could not send the dirty state of ${this.state.sourceUri}: ${String(error)}`));
601
+ }
360
602
  /**
361
603
  * Re-settle to a guaranteed fully-linked + reprojected root, capture it, and
362
604
  * (unless suppressed) resubmit a deduped external GModel. Diagnostics for the
363
- * edit-mode decision come from the event document (`settled()` strips them by
364
- * phase contract).
605
+ * edit-mode decision come from the event document, because the re-settled one
606
+ * cannot be relied on to have any: a document driven to the landmark arrives
607
+ * pre-validation, and this path re-settles precisely to escape a transient
608
+ * mid-rebuild snapshot.
365
609
  */
366
610
  async doUpdateAndSubmit(rootUri, eventDocument) {
367
611
  // Settle-gate the capture: never setSourceRoot off the event's possibly-transient
@@ -419,15 +663,48 @@ let HydraniumGlspStorage = class HydraniumGlspStorage {
419
663
  return document.diagnostics.some(diagnostic => isStructuralDiagnostic(diagnostic));
420
664
  }
421
665
  /**
422
- * Seam: actions to emit when the structural-broken state transitions. Default:
423
- * a single {@link SetEditModeAction} toggling READONLY ↔ EDITABLE — editing is
424
- * disabled while the syntax is broken but re-enabled once it parses, a sound
425
- * generic for any Langium-backed diagram. Adopters override to add or replace
426
- * the transition feedback. The {@link AbstractHydraniumGlspState.editMode}
427
- * flip itself is owned by {@link refreshEditMode}.
666
+ * Seam: actions to emit when the structural-broken state transitions.
667
+ * Default: a {@link SetEditModeAction} toggling READONLY ↔ EDITABLE — editing
668
+ * is disabled while the syntax is broken but re-enabled once it parses, a
669
+ * sound generic for any Langium-backed diagram — plus the
670
+ * {@link DIAGRAM_READONLY_PARSE_ERROR} band that says why, cleared on the way
671
+ * back. Adopters override to add or replace the transition feedback. The
672
+ * {@link AbstractHydraniumGlspState.editMode} flip itself is owned by
673
+ * {@link refreshEditMode}.
674
+ *
675
+ * **Both actions or neither, which is why they are one seam rather than
676
+ * two.** The mode flip removes the tool palette and the band is the only
677
+ * account of that, so an override that keeps one and drops the other produces
678
+ * either an unexplained loss of the palette or a warning about a restriction
679
+ * that is not in force.
680
+ *
681
+ * **The status is dispatched with no `timeout`, so it persists** — a
682
+ * time-limited band would describe a condition that outlasts it.
683
+ *
684
+ * **The slot is SHARED, and the other writer wins on one path.**
685
+ * `ModelSubmissionHandler`'s live validation writes the same slot, clearing
686
+ * it with `severity: 'NONE'` when it finishes, and an adopter dispatching its
687
+ * own `StatusAction` replaces this one too. Nothing re-asserts it. That is
688
+ * harmless while a document is being edited, because a resubmit is skipped
689
+ * once the AST is broken (see {@link doUpdateAndSubmit}) — but NOT at load:
690
+ * the initial `requestModel` submit is not that skipped path, so a diagram
691
+ * opened on an already-broken document shows this band and then loses it to
692
+ * the validation clear a moment later. Measured on a browser host, polling the
693
+ * overlay: no element, empty, the sentence, empty again.
428
694
  */
429
695
  onParseErrorChanged(_document, broken) {
430
- return [SetEditModeAction.create(broken ? EditMode.READONLY : EditMode.EDITABLE)];
696
+ return [
697
+ SetEditModeAction.create(broken ? EditMode.READONLY : EditMode.EDITABLE),
698
+ broken
699
+ ? StatusAction.create(this.sharedServices.MessageRenderer.renderMessage(DIAGRAM_READONLY_PARSE_ERROR), {
700
+ severity: 'WARNING'
701
+ })
702
+ : // `NONE` is the client's own spelling for "clear", not a severity it
703
+ // renders: `StatusOverlay.handle` branches on it before touching the
704
+ // DOM. An empty message at any other severity leaves an empty band
705
+ // with a warning icon standing on the canvas.
706
+ StatusAction.create('', { severity: 'NONE' })
707
+ ];
431
708
  }
432
709
  /**
433
710
  * Seam: extra actions to dispatch right after the initial settled root is
@@ -436,31 +713,47 @@ let HydraniumGlspStorage = class HydraniumGlspStorage {
436
713
  onSourceModelSettled(_document) {
437
714
  return [];
438
715
  }
439
- /** The effective {@link SaveConflictPolicy}: the bound option, or {@link DEFAULT_SAVE_CONFLICT_POLICY} when unbound. */
440
- get saveConflictPolicy() {
441
- return this.boundSaveConflictPolicy ?? DEFAULT_SAVE_CONFLICT_POLICY;
716
+ /** The effective {@link SaveDeliveryPolicy}: the bound option, or {@link DEFAULT_SAVE_DELIVERY_POLICY} when unbound. */
717
+ get saveDeliveryPolicy() {
718
+ return this.boundSaveDeliveryPolicy ?? DEFAULT_SAVE_DELIVERY_POLICY;
442
719
  }
443
720
  /**
444
- * Default save flow: route through `ModelService.save`. The framework
445
- * serialise-and-persist machinery handles serialisation (via the
446
- * language-specific `Serializer` bound at `services.serializer.Serializer`),
447
- * the multi-client text-document update, the rebuild, and the eventual
448
- * `WritableFileSystemProvider.writeFile`.
721
+ * Default save flow: persist the store's current text for every document the
722
+ * diagram's session has open.
449
723
  *
450
- * The configured {@link SaveConflictPolicy} (see {@link saveConflictPolicy})
451
- * decides how a concurrent edit that advanced the document is handled:
452
- * - `overwrite` (default): no based-on guard, await, propagate failures —
453
- * last-write-wins, correct for a single-editor head.
454
- * - `reject`: guard on the captured `state.version`, await, surface a
455
- * `ConflictError` (and any other failure) to the GLSP save action.
456
- * - `drop-and-log`: guard, fire-and-forget, log + swallow failures — a stale
457
- * diagram save is dropped because a concurrent form/code edit already wrote
458
- * the truth, and GLSP exposes no save-failure back-channel.
724
+ * **A save persists, it does not author.** Every diagram gesture already
725
+ * reached the store through the diagram's session, so the store holds the
726
+ * settled text and disk is the only thing behind. Re-serializing from the AST
727
+ * here cannot improve on that text and can only damage it: a serializer
728
+ * normalises formatting, and the comments the write path carries over keep
729
+ * none of the hand-formatting around them, so a save would reflow a document
730
+ * nothing changed — which is what a pure bounds drag does to the semantic
731
+ * file when only its layout moved.
732
+ *
733
+ * **What is saved is the primary, every tracked secondary
734
+ * (`AbstractHydraniumGlspState.trackSecondaryDocument`) and every document
735
+ * that left the write set since the last save**, so a document the diagram
736
+ * wrote is persisted whether or not it is the one the client named. One the
737
+ * diagram's session does not have open is skipped, even when another client
738
+ * has it open: that client's unsaved edits are not the diagram's to persist.
739
+ *
740
+ * **A save therefore names documents the gesture did not aim at**, which is
741
+ * why an unchanged one is not rewritten even though the user asked for a
742
+ * save: the mtime would move on a file they never touched.
743
+ * `AstDocumentManager.save` decides that per document and announces the save
744
+ * either way.
745
+ *
746
+ * The configured {@link SaveDeliveryPolicy} (see {@link saveDeliveryPolicy})
747
+ * decides await-vs-fire-and-forget and failure handling. It carries no
748
+ * based-on guard: the guard exists to stop a stale writer overwriting a newer
749
+ * document, and a flush persists the store — which already holds every other
750
+ * client's change, including the one that advanced the version.
459
751
  *
460
752
  * Returns `MaybePromise<void>` to match upstream `SourceModelStorage`:
461
- * resolves with the persist under `overwrite`/`reject`, returns synchronously
462
- * under `drop-and-log`. Adopters that bypass `ModelService` (writing through
463
- * `WritableFileSystemProvider` directly) still override the whole method.
753
+ * resolves with the flush under `await`, returns synchronously under
754
+ * `fire-and-forget`. Adopters that bypass `AstDocumentManager` (writing
755
+ * through `WritableFileSystemProvider` directly) still override the whole
756
+ * method.
464
757
  */
465
758
  saveSourceModel(action) {
466
759
  // Normalised for the same reason the load path is: `SaveModelAction.fileUri`
@@ -470,23 +763,58 @@ let HydraniumGlspStorage = class HydraniumGlspStorage {
470
763
  // the call site rather than inside `getFileUri` so that method's contract —
471
764
  // return what the action said — is unchanged for adopters overriding it.
472
765
  const uri = this.toSourceModelUri(this.getFileUri(action));
473
- const policy = this.saveConflictPolicy;
474
- const persisted = this.sharedServices.model.ModelService.save({
475
- uri,
476
- model: this.state.sourceRoot,
477
- clientId: this.state.clientId,
478
- baseVersion: policy.kind === 'overwrite' ? undefined : this.state.version
479
- }).then(() => undefined);
480
- if (policy.kind === 'drop-and-log') {
481
- // Fire-and-forget: the diagram save lost the race to a concurrent edit
482
- // that already persisted the truth, so it must neither block the action
483
- // nor surface. Log rather than leave an unhandled rejection.
484
- persisted.catch(error => this.logger.error(`Save failed for ${uri}: ${error instanceof Error ? error.message : String(error)}`));
766
+ if (this.saveDeliveryPolicy.kind === 'fire-and-forget') {
767
+ // Log rather than leave an unhandled rejection: the promise is not
768
+ // returned, so nothing else will observe a failure.
769
+ this.flushWriteSet(uri).catch(error => this.logger.error(`Save failed for ${uri}: ${error instanceof Error ? error.message : String(error)}`));
485
770
  return undefined;
486
771
  }
487
- // overwrite / reject: await; a ConflictError (reject) or any other failure
488
- // propagates to GLSP's save-action handler.
489
- return persisted;
772
+ // Awaited: any failure propagates to GLSP's save-action handler, which
773
+ // then sends no dirty state, so the one the save held back goes here.
774
+ // An override that throws before it returns a promise throws inside the
775
+ // async function too, so the hold always ends.
776
+ this.ownSavesPending++;
777
+ return (async () => {
778
+ try {
779
+ await this.flushWriteSet(uri);
780
+ }
781
+ catch (error) {
782
+ this.sendDirtyState();
783
+ throw error;
784
+ }
785
+ finally {
786
+ this.ownSavesPending--;
787
+ }
788
+ })();
789
+ }
790
+ /**
791
+ * Save the stored text of `primaryUri`, every tracked secondary and every
792
+ * document that left the write set since the last save, each only where
793
+ * the diagram's session has it open; then close those that left.
794
+ *
795
+ * Deduplicated, because a state that tracks its own primary as a secondary
796
+ * would otherwise save it twice and fire two save notifications for one
797
+ * save. Every save is called in one synchronous step, so each takes its text
798
+ * before anything can close a document of the set: saved one after another,
799
+ * a GLSP session ending during the first write closes the rest unsaved.
800
+ */
801
+ async flushWriteSet(primaryUri) {
802
+ // Refused like the load: without a session the client id's opens are
803
+ // another participant's, and this would save them.
804
+ this.requireModelSession();
805
+ const documents = this.sharedServices.workspace.AstDocumentManager;
806
+ const textDocuments = this.sharedServices.workspace.TextDocuments;
807
+ const clientId = this.state.clientId;
808
+ // Taken with the targets: a document leaving the set during the writes
809
+ // was not saved by them, and stays open for the next save.
810
+ const departed = [...this.departedSecondaries];
811
+ const targets = [...new Set([primaryUri, ...this.state.secondaryUris, ...departed])].filter(uri => textDocuments.isOpenInClient(uri, clientId));
812
+ await Promise.all(targets.map(target => documents.save(target, clientId)));
813
+ for (const uri of departed) {
814
+ if (this.departedSecondaries.delete(uri) && uri !== this.state.sourceUri) {
815
+ await this.state.modelSession?.close(uri);
816
+ }
817
+ }
490
818
  }
491
819
  /**
492
820
  * Normalise GLSP's `sourceUri` — which may be a filesystem PATH or a URI
@@ -516,11 +844,22 @@ let HydraniumGlspStorage = class HydraniumGlspStorage {
516
844
  *
517
845
  * Returns it VERBATIM, in whatever form the client sent;
518
846
  * {@link toSourceModelUri} is what normalises it.
847
+ *
848
+ * A model request DOES carry a request id, so a throw here takes the
849
+ * client-request path rather than the toast path — and on that path `detail`
850
+ * comes from `cause?.toString?.()`. A single-argument throw therefore reaches
851
+ * neither the server log nor the client console: both print `undefined`. So
852
+ * the two readers are addressed separately, as on the save path: `message`
853
+ * names what failed in the user's terms and is rendered, `cause` names the
854
+ * action and the option key that was missing and stays English. On THIS path
855
+ * the cause reaches only logs — `RejectAction.detail` is read by nothing but
856
+ * the client action-dispatcher's `logger.warn` — which is not what makes it
857
+ * English; its content is.
519
858
  */
520
859
  getSourceUri(action) {
521
860
  const sourceUri = action.options?.[SOURCE_URI_ARG];
522
861
  if (typeof sourceUri !== 'string') {
523
- throw new GLSPServerError(`Invalid RequestModelAction! Missing argument with key '${SOURCE_URI_ARG}'`);
862
+ throw new GLSPServerError(this.sharedServices.MessageRenderer.renderMessage(SOURCE_URI_MISSING), `no '${SOURCE_URI_ARG}' option on the ${action.kind} action (received ${typeof sourceUri})`);
524
863
  }
525
864
  return sourceUri;
526
865
  }
@@ -531,17 +870,23 @@ let HydraniumGlspStorage = class HydraniumGlspStorage {
531
870
  * {@link AbstractHydraniumGlspState.setSourceRoot}.
532
871
  *
533
872
  * A save action carries no request id, so a throw here reaches the user as a
534
- * toast built from `message`, with `cause` behind its details control. The
535
- * two therefore address different readers and must not be collapsed: the
536
- * message names what failed in the user's terms, and the cause names which
537
- * lookups came back empty for whom. Neither can name the URI — not having
538
- * one IS the failure, and both sources are written together, so the typed
539
- * `sourceUri` is equally empty whenever this fires.
873
+ * toast built from `message`, while `cause` travels in
874
+ * `MessageAction.details` — **which a Theia host DOES put in front of a
875
+ * user**, as a "Show details" button on the toast opening a dialog. So the
876
+ * two are not split by reachability; they are split by AUDIENCE, which is
877
+ * the older rule and the one that holds here. The message names what failed
878
+ * in the user's terms and is rendered; the cause names which lookups came
879
+ * back empty for whom, and stays English because a wire action kind, an
880
+ * option key and a client id address whoever composes the system — a
881
+ * translated developer string is a worse outcome than an untranslated one.
882
+ * Neither can name the URI — not having one IS the failure, and both sources
883
+ * are written together, so the typed `sourceUri` is equally empty whenever
884
+ * this fires.
540
885
  */
541
886
  getFileUri(action) {
542
887
  const uri = action.fileUri ?? this.state.get(SOURCE_URI_ARG);
543
888
  if (!uri) {
544
- throw new GLSPServerError('Could not determine where to save this model', `no fileUri on the save action and no ${SOURCE_URI_ARG} in the model state (clientId=${this.state.clientId})`);
889
+ throw new GLSPServerError(this.sharedServices.MessageRenderer.renderMessage(SAVE_TARGET_UNKNOWN), `no fileUri on the save action and no ${SOURCE_URI_ARG} in the model state (clientId=${this.state.clientId})`);
545
890
  }
546
891
  return uri;
547
892
  }
@@ -569,10 +914,14 @@ let HydraniumGlspStorage = class HydraniumGlspStorage {
569
914
  this.dispose();
570
915
  }
571
916
  /**
572
- * Cancel any pending resubmit and drain every subscription — those registered
917
+ * Cancel any pending resubmit, drain every subscription — those registered
573
918
  * during {@link doLoadSourceModel} and the per-secondary ones, which live
574
- * outside {@link toDispose} because they come and go with the write set.
919
+ * outside {@link toDispose} because they come and go with the write set —
920
+ * and end the diagram's client session, closing everything it has open.
575
921
  * Idempotent.
922
+ *
923
+ * The session ends last, once the detach listener is gone: its closes would
924
+ * otherwise report this storage's own teardown as a client detaching.
576
925
  */
577
926
  dispose() {
578
927
  this.resubmitDebouncer?.dispose();
@@ -581,6 +930,9 @@ let HydraniumGlspStorage = class HydraniumGlspStorage {
581
930
  }
582
931
  this.secondarySubscriptions.clear();
583
932
  this.toDispose.dispose();
933
+ this.disposed = true;
934
+ this.state.modelSession?.dispose();
935
+ this.state.modelSession = undefined;
584
936
  }
585
937
  };
586
938
  __decorate([
@@ -617,10 +969,10 @@ __decorate([
617
969
  __metadata("design:type", Object)
618
970
  ], HydraniumGlspStorage.prototype, "modelValidator", void 0);
619
971
  __decorate([
620
- inject(SaveConflictPolicy),
972
+ inject(SaveDeliveryPolicy),
621
973
  optional(),
622
974
  __metadata("design:type", Object)
623
- ], HydraniumGlspStorage.prototype, "boundSaveConflictPolicy", void 0);
975
+ ], HydraniumGlspStorage.prototype, "boundSaveDeliveryPolicy", void 0);
624
976
  __decorate([
625
977
  postConstruct(),
626
978
  __metadata("design:type", Function),