@hydranium/glsp-server 1.0.0-next.13 → 1.0.0-next.132
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/README.md +20 -5
- package/lib/browser/start-glsp-server-in-worker.d.ts +30 -39
- package/lib/browser/start-glsp-server-in-worker.d.ts.map +1 -1
- package/lib/browser/start-glsp-server-in-worker.js +43 -8
- package/lib/browser/start-glsp-server-in-worker.js.map +1 -1
- package/lib/command/hydranium-glsp-command-stack.d.ts +27 -0
- package/lib/command/hydranium-glsp-command-stack.d.ts.map +1 -0
- package/lib/command/hydranium-glsp-command-stack.js +53 -0
- package/lib/command/hydranium-glsp-command-stack.js.map +1 -0
- package/lib/command/hydranium-glsp-recording-command.d.ts +22 -22
- package/lib/command/hydranium-glsp-recording-command.d.ts.map +1 -1
- package/lib/command/hydranium-glsp-recording-command.js +25 -26
- package/lib/command/hydranium-glsp-recording-command.js.map +1 -1
- package/lib/command/index.d.ts +1 -0
- package/lib/command/index.d.ts.map +1 -1
- package/lib/command/index.js +1 -0
- package/lib/command/index.js.map +1 -1
- package/lib/launcher/abstract-hydranium-glsp-diagram-module.d.ts +12 -3
- package/lib/launcher/abstract-hydranium-glsp-diagram-module.d.ts.map +1 -1
- package/lib/launcher/abstract-hydranium-glsp-diagram-module.js +18 -2
- package/lib/launcher/abstract-hydranium-glsp-diagram-module.js.map +1 -1
- package/lib/launcher/hydranium-glsp-server.d.ts +10 -6
- package/lib/launcher/hydranium-glsp-server.d.ts.map +1 -1
- package/lib/launcher/hydranium-glsp-server.js +16 -6
- package/lib/launcher/hydranium-glsp-server.js.map +1 -1
- package/lib/messages/index.d.ts +24 -0
- package/lib/messages/index.d.ts.map +1 -0
- package/lib/messages/index.js +24 -0
- package/lib/messages/index.js.map +1 -0
- package/lib/node/start-glsp-server.d.ts +1 -1
- package/lib/node/start-glsp-server.d.ts.map +1 -1
- package/lib/node/start-glsp-server.js +2 -2
- package/lib/node/start-glsp-server.js.map +1 -1
- package/lib/state/abstract-hydranium-glsp-state.d.ts +110 -43
- package/lib/state/abstract-hydranium-glsp-state.d.ts.map +1 -1
- package/lib/state/abstract-hydranium-glsp-state.js +139 -60
- package/lib/state/abstract-hydranium-glsp-state.js.map +1 -1
- package/lib/state/full-text-hydranium-glsp-state.d.ts +6 -6
- package/lib/state/full-text-hydranium-glsp-state.d.ts.map +1 -1
- package/lib/state/full-text-hydranium-glsp-state.js +6 -10
- package/lib/state/full-text-hydranium-glsp-state.js.map +1 -1
- package/lib/state/reconcile-source-model-write.d.ts +9 -8
- package/lib/state/reconcile-source-model-write.d.ts.map +1 -1
- package/lib/state/reconcile-source-model-write.js +8 -8
- package/lib/state/reconcile-source-model-write.js.map +1 -1
- package/lib/state/reconciling-multi-document-glsp-state.d.ts +59 -59
- package/lib/state/reconciling-multi-document-glsp-state.d.ts.map +1 -1
- package/lib/state/reconciling-multi-document-glsp-state.js +84 -82
- package/lib/state/reconciling-multi-document-glsp-state.js.map +1 -1
- package/lib/state/reconciling-transfer-hydranium-glsp-state.d.ts +19 -18
- package/lib/state/reconciling-transfer-hydranium-glsp-state.d.ts.map +1 -1
- package/lib/state/reconciling-transfer-hydranium-glsp-state.js +26 -30
- package/lib/state/reconciling-transfer-hydranium-glsp-state.js.map +1 -1
- package/lib/storage/hydranium-glsp-request-save-model-action-handler.d.ts +30 -0
- package/lib/storage/hydranium-glsp-request-save-model-action-handler.d.ts.map +1 -0
- package/lib/storage/hydranium-glsp-request-save-model-action-handler.js +47 -0
- package/lib/storage/hydranium-glsp-request-save-model-action-handler.js.map +1 -0
- package/lib/storage/hydranium-glsp-storage.d.ts +295 -79
- package/lib/storage/hydranium-glsp-storage.d.ts.map +1 -1
- package/lib/storage/hydranium-glsp-storage.js +435 -94
- package/lib/storage/hydranium-glsp-storage.js.map +1 -1
- package/lib/storage/index.d.ts +2 -1
- package/lib/storage/index.d.ts.map +1 -1
- package/lib/storage/index.js +2 -1
- package/lib/storage/index.js.map +1 -1
- package/lib/storage/save-delivery-policy.d.ts +46 -0
- package/lib/storage/save-delivery-policy.d.ts.map +1 -0
- package/lib/storage/{save-conflict-policy.js → save-delivery-policy.js} +7 -8
- package/lib/storage/save-delivery-policy.js.map +1 -0
- package/lib/validation/diagnostic-markers.d.ts +2 -2
- package/lib/validation/diagnostic-markers.d.ts.map +1 -1
- package/lib/validation/diagnostic-markers.js +7 -0
- package/lib/validation/diagnostic-markers.js.map +1 -1
- package/lib/validation/hydranium-glsp-model-validator.d.ts.map +1 -1
- package/lib/validation/hydranium-glsp-model-validator.js +1 -1
- package/lib/validation/hydranium-glsp-model-validator.js.map +1 -1
- package/package.json +17 -8
- package/src/browser/start-glsp-server-in-worker.ts +41 -44
- package/src/command/hydranium-glsp-command-stack.ts +36 -0
- package/src/command/hydranium-glsp-recording-command.ts +26 -26
- package/src/command/index.ts +1 -0
- package/src/launcher/abstract-hydranium-glsp-diagram-module.ts +27 -3
- package/src/launcher/hydranium-glsp-server.ts +16 -6
- package/src/messages/index.ts +30 -0
- package/src/node/start-glsp-server.ts +2 -3
- package/src/state/abstract-hydranium-glsp-state.ts +174 -73
- package/src/state/full-text-hydranium-glsp-state.ts +7 -11
- package/src/state/reconcile-source-model-write.ts +18 -15
- package/src/state/reconciling-multi-document-glsp-state.ts +86 -83
- package/src/state/reconciling-transfer-hydranium-glsp-state.ts +27 -31
- package/src/storage/hydranium-glsp-request-save-model-action-handler.ts +40 -0
- package/src/storage/hydranium-glsp-storage.ts +497 -103
- package/src/storage/index.ts +2 -1
- package/src/storage/save-delivery-policy.ts +44 -0
- package/src/validation/diagnostic-markers.ts +10 -3
- package/src/validation/hydranium-glsp-model-validator.ts +3 -3
- package/lib/storage/save-conflict-policy.d.ts +0 -56
- package/lib/storage/save-conflict-policy.d.ts.map +0 -1
- package/lib/storage/save-conflict-policy.js.map +0 -1
- package/src/storage/save-conflict-policy.ts +0 -52
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* SPDX-License-Identifier: MIT
|
|
8
8
|
********************************************************************************/
|
|
9
9
|
|
|
10
|
-
import { MarkersReason, SetMarkersAction } from '@eclipse-glsp/protocol';
|
|
10
|
+
import { MarkersReason, MessageAction, SetMarkersAction, StatusAction } from '@eclipse-glsp/protocol';
|
|
11
11
|
import {
|
|
12
12
|
type Action,
|
|
13
13
|
ActionDispatcher,
|
|
@@ -27,20 +27,101 @@ import {
|
|
|
27
27
|
type RequestModelAction,
|
|
28
28
|
SOURCE_URI_ARG,
|
|
29
29
|
type SaveModelAction,
|
|
30
|
+
SetDirtyStateAction,
|
|
30
31
|
SetEditModeAction,
|
|
31
32
|
type SourceModelStorage,
|
|
32
33
|
TEMPORARY_CLIENT_ID
|
|
33
34
|
} from '@eclipse-glsp/server';
|
|
34
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
Debouncer,
|
|
37
|
+
defineMessage,
|
|
38
|
+
DisposableCollection,
|
|
39
|
+
isDuplicateClientIdError,
|
|
40
|
+
isSessionClosedError,
|
|
41
|
+
SessionClosedError
|
|
42
|
+
} from '@hydranium/protocol';
|
|
35
43
|
import { inject, injectable, optional, postConstruct } from 'inversify';
|
|
36
44
|
import { type AstNode } from '@hydranium/langium';
|
|
37
45
|
import { URI } from '@hydranium/langium';
|
|
38
|
-
import {
|
|
46
|
+
import {
|
|
47
|
+
AstDocument,
|
|
48
|
+
type AstDocumentSavedEvent,
|
|
49
|
+
type AstDocumentUpdatedEvent,
|
|
50
|
+
type ClientSession as ModelClientSession,
|
|
51
|
+
type DocumentDirtyChangedEvent,
|
|
52
|
+
type ServerSharedServices
|
|
53
|
+
} from '@hydranium/core';
|
|
39
54
|
import { DiagnosticSeverity } from 'vscode-languageserver-types';
|
|
40
55
|
import { type AbstractHydraniumGlspState } from '../state/abstract-hydranium-glsp-state.js';
|
|
41
56
|
import { type HydraniumGlspSubmissionHandler } from '../submission/hydranium-glsp-submission-handler.js';
|
|
42
57
|
import { HydraniumTypes } from '../state/hydranium-shared-core-services.js';
|
|
43
|
-
import {
|
|
58
|
+
import { DEFAULT_SAVE_DELIVERY_POLICY, SaveDeliveryPolicy } from './save-delivery-policy.js';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A save action arrived with nowhere to write to. A save action carries no
|
|
62
|
+
* request id, so it falls through to the error handler and `message` becomes
|
|
63
|
+
* the toast text.
|
|
64
|
+
*
|
|
65
|
+
* Rendered at the raise site rather than by a carrier method, because GLSP's
|
|
66
|
+
* action protocol has no slot for an identity anywhere — every member of its
|
|
67
|
+
* message, status and reject actions is prose or an enum, and
|
|
68
|
+
* `MessageAction.details` is not a substitute since it is prose populated from
|
|
69
|
+
* `cause?.toString?.()`, and a Theia host shows it to a user on demand. So the
|
|
70
|
+
* throw is the last place that still knows which message this is.
|
|
71
|
+
*/
|
|
72
|
+
export const SAVE_TARGET_UNKNOWN = defineMessage(
|
|
73
|
+
'hydranium/glsp-server/save-target-unknown',
|
|
74
|
+
'Could not determine where to save this model'
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A model request arrived without the source URI it is required to carry.
|
|
79
|
+
* Rendered at the raise site, like its save-path sibling and for the same
|
|
80
|
+
* reason.
|
|
81
|
+
*/
|
|
82
|
+
export const SOURCE_URI_MISSING = defineMessage(
|
|
83
|
+
'hydranium/glsp-server/source-uri-missing',
|
|
84
|
+
'Could not open this model: the request did not say which document to load'
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Why the canvas has stopped accepting edits.
|
|
89
|
+
*
|
|
90
|
+
* **A READONLY canvas is otherwise indistinguishable from a broken one.** The
|
|
91
|
+
* client's answer to {@link SetEditModeAction} is to withdraw the tool palette,
|
|
92
|
+
* so the surface a reader was working in silently loses the only control it had,
|
|
93
|
+
* with the cause — a syntax error in a document that may not even be open —
|
|
94
|
+
* nowhere on screen. The mode flip is the mechanism; this is the only part of it
|
|
95
|
+
* a user can see.
|
|
96
|
+
*
|
|
97
|
+
* Rendered at the raise site, like its two siblings above and for the same
|
|
98
|
+
* reason: every member of GLSP's status action is prose or an enum.
|
|
99
|
+
*
|
|
100
|
+
* It names the recovery rather than the fault, because the fault already has a
|
|
101
|
+
* surface — the squiggle and the problems list, both of which say WHICH
|
|
102
|
+
* character — and repeating it here would put a second, less precise account of
|
|
103
|
+
* the same error on screen. What no other surface says is that the diagram is
|
|
104
|
+
* waiting on it.
|
|
105
|
+
*/
|
|
106
|
+
export const DIAGRAM_READONLY_PARSE_ERROR = defineMessage(
|
|
107
|
+
'hydranium/glsp-server/diagram-readonly-parse-error',
|
|
108
|
+
'Read-only: this document has a syntax error. Fix it to edit the diagram again.'
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The diagram's GLSP client id is still live as another participant's client
|
|
113
|
+
* id once the load has waited for it to free, so the diagram does not load.
|
|
114
|
+
*
|
|
115
|
+
* Rendered at the raise site, as {@link SAVE_TARGET_UNKNOWN} is and for the
|
|
116
|
+
* same reason, and dispatched to the client as a message of its own: a model
|
|
117
|
+
* request fails as a rejection whose detail is the English cause, which no user
|
|
118
|
+
* sees. It names the recovery: a host that numbers its diagram clients gives a
|
|
119
|
+
* reopened diagram a fresh id.
|
|
120
|
+
*/
|
|
121
|
+
export const DIAGRAM_SESSION_REFUSED = defineMessage(
|
|
122
|
+
'hydranium/glsp-server/diagram-session-refused',
|
|
123
|
+
'Could not open this diagram: its identifier is still in use by another editor. Close the diagram and open it again.'
|
|
124
|
+
);
|
|
44
125
|
|
|
45
126
|
/** Window (ms) over which back-to-back external rebuilds collapse into one resubmit. */
|
|
46
127
|
const EXTERNAL_SUBMIT_DEBOUNCE_MS = 250;
|
|
@@ -90,17 +171,24 @@ function isStructuralDiagnostic(diagnostic: unknown): boolean {
|
|
|
90
171
|
*
|
|
91
172
|
* Adopters with richer needs override the seams ({@link isStructurallyBroken},
|
|
92
173
|
* {@link onParseErrorChanged}, {@link onSourceModelSettled}) rather than the
|
|
93
|
-
* whole flow, and select a {@link
|
|
94
|
-
* tune how {@link saveSourceModel}
|
|
174
|
+
* whole flow, and select a {@link SaveDeliveryPolicy} via the bound option to
|
|
175
|
+
* tune how {@link saveSourceModel} delivers its result.
|
|
95
176
|
*
|
|
96
|
-
* **
|
|
97
|
-
*
|
|
98
|
-
* `
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
177
|
+
* **One client session per GLSP client session.** `init` registers the GLSP
|
|
178
|
+
* client id as a client session ({@link registerModelSession}) and hands it to
|
|
179
|
+
* the state as `modelSession`. The primary is opened through it on load, and
|
|
180
|
+
* every document that joins the write set as it joins
|
|
181
|
+
* ({@link openSecondary}); a document that leaves the write set stays open
|
|
182
|
+
* until the next save, so leaving never reverts the diagram's unsaved edits to
|
|
183
|
+
* it. Disposing the storage ends the session, which closes everything it has
|
|
184
|
+
* open.
|
|
185
|
+
*
|
|
186
|
+
* **Default `saveSourceModel` flow.** Flushes the stored text of every
|
|
187
|
+
* document the diagram's session has open via `AstDocumentManager.save`, with
|
|
188
|
+
* no serializer in the path — the update path already put the settled text in
|
|
189
|
+
* the store. The bound {@link SaveDeliveryPolicy} (default
|
|
190
|
+
* {@link DEFAULT_SAVE_DELIVERY_POLICY}, `await`) decides
|
|
191
|
+
* await-vs-fire-and-forget and failure handling.
|
|
104
192
|
*
|
|
105
193
|
* **tempId filter.** GLSP's `DefaultGlobalActionProvider` spins up a
|
|
106
194
|
* throwaway per-diagram-type container with upstream's {@link TEMPORARY_CLIENT_ID}
|
|
@@ -111,9 +199,9 @@ function isStructuralDiagnostic(diagnostic: unknown): boolean {
|
|
|
111
199
|
* **Lifecycle ordering.** `init` runs after DI resolution and parks the
|
|
112
200
|
* secondary-write-set watch, which outlives any single load.
|
|
113
201
|
* {@link sessionDisposed} calls {@link dispose}, which drains
|
|
114
|
-
* {@link toDispose} idempotently
|
|
115
|
-
* {@link doLoadSourceModel} is parked on
|
|
116
|
-
* them on client-detach.
|
|
202
|
+
* {@link toDispose} idempotently and then ends the client session. Every
|
|
203
|
+
* transient subscription created in {@link doLoadSourceModel} is parked on
|
|
204
|
+
* {@link toDispose} so the drain catches them on client-detach.
|
|
117
205
|
*/
|
|
118
206
|
@injectable()
|
|
119
207
|
export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
|
|
@@ -137,14 +225,13 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
|
|
|
137
225
|
@inject(ModelValidator) @optional() protected readonly modelValidator?: ModelValidator;
|
|
138
226
|
|
|
139
227
|
/**
|
|
140
|
-
* Selected {@link
|
|
141
|
-
* {@link
|
|
142
|
-
* left unbound it resolves to {@link
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* `@optional()` member.
|
|
228
|
+
* Selected {@link SaveDeliveryPolicy} for {@link saveSourceModel}. Bind the
|
|
229
|
+
* {@link SaveDeliveryPolicy} token in a `DiagramModule` to choose a policy;
|
|
230
|
+
* left unbound it resolves to {@link DEFAULT_SAVE_DELIVERY_POLICY}. Read via
|
|
231
|
+
* {@link saveDeliveryPolicy} so the fallback is applied even when inversify
|
|
232
|
+
* injects `undefined` for an unbound `@optional()` member.
|
|
146
233
|
*/
|
|
147
|
-
@inject(
|
|
234
|
+
@inject(SaveDeliveryPolicy) @optional() protected readonly boundSaveDeliveryPolicy?: SaveDeliveryPolicy;
|
|
148
235
|
|
|
149
236
|
/** Disposables created during {@link doLoadSourceModel}; drained on session disposal. */
|
|
150
237
|
protected toDispose = new DisposableCollection();
|
|
@@ -159,6 +246,27 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
|
|
|
159
246
|
*/
|
|
160
247
|
protected readonly secondarySubscriptions = new Map<string, Disposable>();
|
|
161
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Documents that left the write set since the last save. They stay open in
|
|
251
|
+
* the diagram's session until that save has persisted them, and are closed
|
|
252
|
+
* after it: closing a document as it leaves would revert the diagram's
|
|
253
|
+
* unsaved edits to it where the diagram was its last client.
|
|
254
|
+
*/
|
|
255
|
+
protected readonly departedSecondaries = new Set<string>();
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* How long a load waits for a client id another participant holds to free
|
|
259
|
+
* before refusing. A reloaded client can reconnect under its old id before
|
|
260
|
+
* the server has noticed the old connection close, which ends that
|
|
261
|
+
* connection's sessions a moment later.
|
|
262
|
+
*/
|
|
263
|
+
protected readonly sessionWaitMs: number = 2_000;
|
|
264
|
+
|
|
265
|
+
/** Set by {@link dispose}; a disposed storage registers no session again. */
|
|
266
|
+
protected disposed = false;
|
|
267
|
+
/** Saves of the diagram's own that GLSP's save handler awaits; see {@link handleDirtyChanged}. */
|
|
268
|
+
protected ownSavesPending = 0;
|
|
269
|
+
|
|
162
270
|
/**
|
|
163
271
|
* Whether an external resubmit has ever been dispatched. Gates the
|
|
164
272
|
* keep-the-last-valid-GModel branch, which needs to know a canvas is already
|
|
@@ -178,7 +286,7 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
|
|
|
178
286
|
protected resubmitDebouncer!: Debouncer;
|
|
179
287
|
|
|
180
288
|
/** Latest event document awaiting the debounced resubmit; read by {@link flushResubmit} (last write wins). */
|
|
181
|
-
protected pendingResubmitDocument?: AstDocument<AstNode
|
|
289
|
+
protected pendingResubmitDocument?: AstDocument<AstNode>;
|
|
182
290
|
|
|
183
291
|
@postConstruct()
|
|
184
292
|
protected init(): void {
|
|
@@ -188,6 +296,7 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
|
|
|
188
296
|
if (this.state.clientId === TEMPORARY_CLIENT_ID) {
|
|
189
297
|
return;
|
|
190
298
|
}
|
|
299
|
+
this.state.modelSession = this.registerModelSession();
|
|
191
300
|
this.sessionManager.addListener(this, this.state.clientId);
|
|
192
301
|
// Watched for this storage's whole lifetime rather than per load. The load
|
|
193
302
|
// flow is a documented override point, so a subscription parked there would
|
|
@@ -196,6 +305,99 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
|
|
|
196
305
|
this.toDispose.push(this.state.onSecondaryUrisChanged(() => this.reconcileSecondarySubscriptions()));
|
|
197
306
|
}
|
|
198
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Register the diagram's client session under the GLSP client id, or
|
|
310
|
+
* `undefined` when another participant holds that id, which is logged.
|
|
311
|
+
*
|
|
312
|
+
* The diagram then refuses to load ({@link requireModelSession}). Taking
|
|
313
|
+
* the id over would end the other participant's session under it, and
|
|
314
|
+
* working without a session would share that participant's opens and
|
|
315
|
+
* echoes, so its close would take the diagram's documents with it.
|
|
316
|
+
*/
|
|
317
|
+
protected registerModelSession(): ModelClientSession<AstNode> | undefined {
|
|
318
|
+
try {
|
|
319
|
+
return this.sharedServices.model.ModelService.createSession('diagram', this.state.clientId);
|
|
320
|
+
} catch (error: unknown) {
|
|
321
|
+
if (!isDuplicateClientIdError(error)) {
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
this.logger.warn(`Client id ${this.state.clientId} is held by another participant: this diagram has no session yet`);
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The diagram's client session, registering it again when the id was held
|
|
331
|
+
* earlier. Throws `SessionClosedError` once the storage is disposed, and the
|
|
332
|
+
* refusal when the id is still held.
|
|
333
|
+
*/
|
|
334
|
+
protected requireModelSession(): ModelClientSession<AstNode> {
|
|
335
|
+
if (this.disposed) {
|
|
336
|
+
throw new SessionClosedError(this.state.clientId, 'The diagram has closed; its client session has ended.');
|
|
337
|
+
}
|
|
338
|
+
if (!this.state.modelSession) {
|
|
339
|
+
this.state.modelSession = this.registerModelSession();
|
|
340
|
+
}
|
|
341
|
+
const session = this.state.modelSession;
|
|
342
|
+
if (!session) {
|
|
343
|
+
throw new GLSPServerError(
|
|
344
|
+
this.sharedServices.MessageRenderer.renderMessage(DIAGRAM_SESSION_REFUSED),
|
|
345
|
+
`clientId=${this.state.clientId} is still held by another participant, so this diagram has no client session`
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
return session;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* {@link requireModelSession} for the load: while the id is held, wait up to
|
|
353
|
+
* {@link sessionWaitMs} for its holder to end before refusing, and tell the
|
|
354
|
+
* user when it does not.
|
|
355
|
+
*/
|
|
356
|
+
protected async awaitModelSession(): Promise<ModelClientSession<AstNode>> {
|
|
357
|
+
if (!this.state.modelSession && !this.disposed) {
|
|
358
|
+
this.state.modelSession = this.registerModelSession();
|
|
359
|
+
}
|
|
360
|
+
if (!this.state.modelSession && !this.disposed) {
|
|
361
|
+
await this.waitForSessionEnd(this.state.clientId, this.sessionWaitMs);
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
return this.requireModelSession();
|
|
365
|
+
} catch (error: unknown) {
|
|
366
|
+
// A diagram closed while it waited has no client left to tell.
|
|
367
|
+
if (isSessionClosedError(error)) {
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
this.logger.error(`Client id ${this.state.clientId} is still held by another participant: this diagram does not load`);
|
|
371
|
+
this.actionDispatcher
|
|
372
|
+
.dispatch(
|
|
373
|
+
MessageAction.create(this.sharedServices.MessageRenderer.renderMessage(DIAGRAM_SESSION_REFUSED), { severity: 'ERROR' })
|
|
374
|
+
)
|
|
375
|
+
.catch((dispatchError: unknown) =>
|
|
376
|
+
this.logger.warn(`Could not tell the client the diagram was refused: ${String(dispatchError)}`)
|
|
377
|
+
);
|
|
378
|
+
throw error;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Resolve once the client session under `clientId` ends, or after `timeoutMs`. */
|
|
383
|
+
protected waitForSessionEnd(clientId: string, timeoutMs: number): Promise<void> {
|
|
384
|
+
return new Promise<void>(resolve => {
|
|
385
|
+
const subscriptions = new DisposableCollection();
|
|
386
|
+
const done = (): void => {
|
|
387
|
+
subscriptions.dispose();
|
|
388
|
+
resolve();
|
|
389
|
+
};
|
|
390
|
+
subscriptions.push(this.sharedServices.Clock.setTimer(done, timeoutMs));
|
|
391
|
+
subscriptions.push(
|
|
392
|
+
this.sharedServices.workspace.TextDocuments.onDidCloseSession(event => {
|
|
393
|
+
if (event.clientId === clientId) {
|
|
394
|
+
done();
|
|
395
|
+
}
|
|
396
|
+
})
|
|
397
|
+
);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
199
401
|
/**
|
|
200
402
|
* Default load flow — {@link doLoadSourceModel}. Adopters whose load flow
|
|
201
403
|
* needs more than the seams allow override this method.
|
|
@@ -211,8 +413,8 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
|
|
|
211
413
|
|
|
212
414
|
// Open FIRST: a workspace-scanned-but-never-didOpened document rebuilds here,
|
|
213
415
|
// so the capture below settles on a built root rather than a transient
|
|
214
|
-
// mid-rebuild one.
|
|
215
|
-
|
|
416
|
+
// mid-rebuild one. Closed when the session ends.
|
|
417
|
+
await (await this.awaitModelSession()).open(rootUri);
|
|
216
418
|
|
|
217
419
|
// GLSP's sessionDisposed is unreliable on Theia tab-close; dispose on client
|
|
218
420
|
// detach so reopens don't accumulate stale onModelUpdated listeners.
|
|
@@ -226,21 +428,41 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
|
|
|
226
428
|
// React to external rebuilds: settle-gated capture + debounced/deduped resubmit.
|
|
227
429
|
this.toDispose.push(modelService.onModelUpdated(rootUri, event => this.handleModelUpdated(rootUri, event)));
|
|
228
430
|
|
|
229
|
-
//
|
|
431
|
+
// GLSP's own command stack counts commands; tell it of another client's save.
|
|
230
432
|
this.toDispose.push(modelService.onModelSaved(rootUri, event => this.handleModelSaved(event)));
|
|
231
433
|
|
|
434
|
+
// Tell the client of each dirty flip, one the diagram did not cause included.
|
|
435
|
+
this.toDispose.push(this.sharedServices.workspace.TextDocuments.onDidChangeDirty(event => this.handleDirtyChanged(event)));
|
|
436
|
+
|
|
232
437
|
// Capture the initial settled root.
|
|
233
438
|
const document = await modelService.settled(rootUri);
|
|
234
439
|
await this.captureSettledRoot(rootUri, document);
|
|
235
440
|
}
|
|
236
441
|
|
|
237
442
|
/**
|
|
238
|
-
* Capture the initial settled root and apply the initial edit mode.
|
|
239
|
-
*
|
|
240
|
-
* the first {@link handleModelUpdated} (at `Validated`, with diagnostics) flips
|
|
241
|
-
* it READONLY if the document has structural parse errors. Edit-mode +
|
|
242
|
-
* settle-hook actions are dispatched on a macrotask so the initial
|
|
443
|
+
* Capture the initial settled root and apply the initial edit mode. Edit-mode
|
|
444
|
+
* + settle-hook actions are dispatched on a macrotask so the initial
|
|
243
445
|
* `requestModel → setModel` handshake isn't perturbed.
|
|
446
|
+
*
|
|
447
|
+
* **Whether the settled document carries diagnostics depends on how far it
|
|
448
|
+
* had already got, so the initial edit mode has two correct outcomes rather
|
|
449
|
+
* than one.** `settled()` strips nothing: it resolves AT OR ABOVE the
|
|
450
|
+
* integrity landmark and hands back the live document's own array. Its
|
|
451
|
+
* `AstDocument<TAst, never>` return type asserts emptiness for a document the
|
|
452
|
+
* wait had to DRIVE to that landmark, which is pre-validation — and for that
|
|
453
|
+
* one the mode here is `EDITABLE` and the flip to READONLY arrives with the
|
|
454
|
+
* first {@link handleModelUpdated}. A document already past `Validated` when
|
|
455
|
+
* the session opened resolves immediately with its diagnostics intact, and is
|
|
456
|
+
* decided correctly here with no later update owed. Any host that validates
|
|
457
|
+
* its workspace before a diagram is opened produces the second case, so
|
|
458
|
+
* neither is exceptional.
|
|
459
|
+
*
|
|
460
|
+
* **A correct decision is not a delivered one.** This dispatch happens inside
|
|
461
|
+
* the initial `requestModel`, before the client has the model — so the UI
|
|
462
|
+
* extensions that answer {@link onParseErrorChanged}'s actions are not yet
|
|
463
|
+
* constructed, and a canvas that opens READONLY over a broken document shows
|
|
464
|
+
* none of that feedback. The same timer carries {@link onSourceModelSettled},
|
|
465
|
+
* so an adopter's own initial actions have it too.
|
|
244
466
|
*/
|
|
245
467
|
protected async captureSettledRoot(rootUri: string, document: AstDocument<AstNode, never>): Promise<void> {
|
|
246
468
|
this.state.setSourceRoot(rootUri, document.root as TRoot);
|
|
@@ -275,18 +497,44 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
|
|
|
275
497
|
if (!current.has(uri)) {
|
|
276
498
|
subscription.dispose();
|
|
277
499
|
this.secondarySubscriptions.delete(uri);
|
|
500
|
+
this.departedSecondaries.add(uri);
|
|
278
501
|
}
|
|
279
502
|
}
|
|
280
503
|
for (const uri of current) {
|
|
281
504
|
if (!this.secondarySubscriptions.has(uri)) {
|
|
505
|
+
this.departedSecondaries.delete(uri);
|
|
282
506
|
this.secondarySubscriptions.set(
|
|
283
507
|
uri,
|
|
284
508
|
this.sharedServices.model.ModelService.onModelUpdated(uri, event => this.handleSecondaryUpdated(uri, event))
|
|
285
509
|
);
|
|
510
|
+
this.openSecondary(uri);
|
|
286
511
|
}
|
|
287
512
|
}
|
|
288
513
|
}
|
|
289
514
|
|
|
515
|
+
/**
|
|
516
|
+
* Open a document that joined the write set through the diagram's session,
|
|
517
|
+
* where it stays open until the diagram's next save after it leaves the set,
|
|
518
|
+
* or the diagram's end. A document that does not exist yet is left to the
|
|
519
|
+
* write that creates it, and a failed open is logged: the write that needs
|
|
520
|
+
* the document opens it again and fails there.
|
|
521
|
+
*/
|
|
522
|
+
protected openSecondary(uri: string): void {
|
|
523
|
+
const session = this.state.modelSession;
|
|
524
|
+
if (!session || !this.sharedServices.model.ModelService.getDocument(uri)) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
session.open(uri).catch((error: unknown) => {
|
|
528
|
+
const detail = `Could not open ${uri} for ${this.state.clientId}: ${error instanceof Error ? error.message : String(error)}`;
|
|
529
|
+
// A session ending while the open reads is the diagram closing.
|
|
530
|
+
if (isSessionClosedError(error)) {
|
|
531
|
+
this.logger.debug(detail);
|
|
532
|
+
} else {
|
|
533
|
+
this.logger.warn(detail);
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
290
538
|
/**
|
|
291
539
|
* React to a rebuild of a SECONDARY document by resubmitting the diagram, so
|
|
292
540
|
* an external edit to (say) a layout file reaches the canvas.
|
|
@@ -312,7 +560,7 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
|
|
|
312
560
|
* drag this client authors — the guard above gates the resubmit, not the
|
|
313
561
|
* markers.
|
|
314
562
|
*/
|
|
315
|
-
protected handleSecondaryUpdated(uri: string, event: AstDocumentUpdatedEvent<AstNode
|
|
563
|
+
protected handleSecondaryUpdated(uri: string, event: AstDocumentUpdatedEvent<AstNode>): void {
|
|
316
564
|
if (this.disposeIfStale(uri)) {
|
|
317
565
|
return;
|
|
318
566
|
}
|
|
@@ -333,11 +581,12 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
|
|
|
333
581
|
*
|
|
334
582
|
* Synchronous by construction — the phase-agnostic lookup door plus the shared
|
|
335
583
|
* projection — so reacting to a secondary neither waits nor can force a build.
|
|
336
|
-
* `settled()` is not an alternative:
|
|
337
|
-
*
|
|
338
|
-
*
|
|
584
|
+
* `settled()` is not an alternative: besides being asynchronous, it cannot be
|
|
585
|
+
* relied on to carry diagnostics — a document it has to drive to the landmark
|
|
586
|
+
* arrives pre-validation with an empty array — and diagnostics are the reason
|
|
587
|
+
* {@link doUpdateAndSubmit} takes a separate event document at all.
|
|
339
588
|
*/
|
|
340
|
-
protected currentPrimaryDocument(): AstDocument<AstNode
|
|
589
|
+
protected currentPrimaryDocument(): AstDocument<AstNode> | undefined {
|
|
341
590
|
const document = this.sharedServices.model.ModelService.getDocument(this.state.sourceUri);
|
|
342
591
|
return document ? AstDocument.from(document) : undefined;
|
|
343
592
|
}
|
|
@@ -350,7 +599,7 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
|
|
|
350
599
|
* diagnostic markers for every update — including own edits, which skip
|
|
351
600
|
* the resubmit but can still change diagnostics.
|
|
352
601
|
*/
|
|
353
|
-
protected async handleModelUpdated(rootUri: string, event: AstDocumentUpdatedEvent<AstNode
|
|
602
|
+
protected async handleModelUpdated(rootUri: string, event: AstDocumentUpdatedEvent<AstNode>): Promise<void> {
|
|
354
603
|
if (this.disposeIfStale(rootUri)) {
|
|
355
604
|
return;
|
|
356
605
|
}
|
|
@@ -382,7 +631,7 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
|
|
|
382
631
|
* {@link resubmitDebouncer}, so a burst of rebuilds runs
|
|
383
632
|
* {@link doUpdateAndSubmit} once with the most recent document.
|
|
384
633
|
*/
|
|
385
|
-
protected scheduleUpdateAndSubmit(document: AstDocument<AstNode
|
|
634
|
+
protected scheduleUpdateAndSubmit(document: AstDocument<AstNode>): void {
|
|
386
635
|
this.pendingResubmitDocument = document;
|
|
387
636
|
this.resubmitDebouncer.schedule();
|
|
388
637
|
}
|
|
@@ -405,23 +654,55 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
|
|
|
405
654
|
}
|
|
406
655
|
|
|
407
656
|
/**
|
|
408
|
-
*
|
|
409
|
-
* mark
|
|
410
|
-
*
|
|
657
|
+
* For a diagram module on GLSP's own command stack, which counts commands:
|
|
658
|
+
* mark it clean when another client persists the document.
|
|
659
|
+
* `HydraniumGlspCommandStack` reads the text store instead, and this does
|
|
660
|
+
* not move it.
|
|
411
661
|
*/
|
|
412
|
-
protected handleModelSaved(event: AstDocumentSavedEvent<AstNode
|
|
662
|
+
protected handleModelSaved(event: AstDocumentSavedEvent<AstNode>): void {
|
|
413
663
|
if (this.state.clientId !== event.sourceClientId) {
|
|
414
664
|
this.commandStack.saveIsDone();
|
|
415
665
|
}
|
|
416
666
|
}
|
|
417
667
|
|
|
668
|
+
/**
|
|
669
|
+
* Send the diagram's dirty state when a document it has open turns dirty or
|
|
670
|
+
* clean, the answer being the command stack's. GLSP sends it only with a
|
|
671
|
+
* model submission or a save of the diagram's own, so a save by another
|
|
672
|
+
* client, an editor included, or an edit that changes nothing the diagram
|
|
673
|
+
* shows would otherwise leave the client's marker wrong until the next
|
|
674
|
+
* gesture. A flip the diagram's own operation causes is sent twice, once
|
|
675
|
+
* here and once with the submission, and the client keeps the one state.
|
|
676
|
+
*
|
|
677
|
+
* Nothing is sent while a save of the diagram's own is awaited: GLSP's save
|
|
678
|
+
* handler sends the state once the save settles, reason `save`, and GLSP's
|
|
679
|
+
* client keeps a state only when it changes, so the save's own flip sent
|
|
680
|
+
* here first would leave that answer changing nothing. GLSP's saveable
|
|
681
|
+
* waits for exactly that answer, and times out without it.
|
|
682
|
+
*/
|
|
683
|
+
protected handleDirtyChanged(event: DocumentDirtyChangedEvent): void {
|
|
684
|
+
if (this.ownSavesPending > 0 || !this.sharedServices.workspace.TextDocuments.isOpenInClient(event.uri, this.state.clientId)) {
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
this.sendDirtyState();
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** Send the command stack's dirty state, reason `external`. */
|
|
691
|
+
protected sendDirtyState(): void {
|
|
692
|
+
this.actionDispatcher
|
|
693
|
+
.dispatch(SetDirtyStateAction.create(this.commandStack.isDirty, { reason: 'external' }))
|
|
694
|
+
.catch((error: unknown) => this.logger.warn(`Could not send the dirty state of ${this.state.sourceUri}: ${String(error)}`));
|
|
695
|
+
}
|
|
696
|
+
|
|
418
697
|
/**
|
|
419
698
|
* Re-settle to a guaranteed fully-linked + reprojected root, capture it, and
|
|
420
699
|
* (unless suppressed) resubmit a deduped external GModel. Diagnostics for the
|
|
421
|
-
* edit-mode decision come from the event document
|
|
422
|
-
*
|
|
700
|
+
* edit-mode decision come from the event document, because the re-settled one
|
|
701
|
+
* cannot be relied on to have any: a document driven to the landmark arrives
|
|
702
|
+
* pre-validation, and this path re-settles precisely to escape a transient
|
|
703
|
+
* mid-rebuild snapshot.
|
|
423
704
|
*/
|
|
424
|
-
protected async doUpdateAndSubmit(rootUri: string, eventDocument: AstDocument<AstNode
|
|
705
|
+
protected async doUpdateAndSubmit(rootUri: string, eventDocument: AstDocument<AstNode>): Promise<Action[]> {
|
|
425
706
|
// Settle-gate the capture: never setSourceRoot off the event's possibly-transient
|
|
426
707
|
// snapshot — the event can arrive while a re-entered document is mid-rebuild.
|
|
427
708
|
const document = await this.sharedServices.model.ModelService.settled(rootUri);
|
|
@@ -459,7 +740,7 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
|
|
|
459
740
|
* flip {@link AbstractHydraniumGlspState.editMode}, and — only on a transition —
|
|
460
741
|
* return the {@link onParseErrorChanged} actions. No transition → no actions.
|
|
461
742
|
*/
|
|
462
|
-
protected refreshEditMode(document: AstDocument<AstNode
|
|
743
|
+
protected refreshEditMode(document: AstDocument<AstNode>): Action[] {
|
|
463
744
|
const broken = this.isStructurallyBroken(document);
|
|
464
745
|
const previousEditMode = this.state.editMode;
|
|
465
746
|
this.state.editMode = broken ? EditMode.READONLY : EditMode.EDITABLE;
|
|
@@ -476,20 +757,53 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
|
|
|
476
757
|
* carrying a Langium `lexing-error` / `parsing-error` code. Adopters override
|
|
477
758
|
* to compose additional break reasons.
|
|
478
759
|
*/
|
|
479
|
-
protected isStructurallyBroken(document: AstDocument<AstNode
|
|
760
|
+
protected isStructurallyBroken(document: AstDocument<AstNode>): boolean {
|
|
480
761
|
return document.diagnostics.some(diagnostic => isStructuralDiagnostic(diagnostic));
|
|
481
762
|
}
|
|
482
763
|
|
|
483
764
|
/**
|
|
484
|
-
* Seam: actions to emit when the structural-broken state transitions.
|
|
485
|
-
* a
|
|
486
|
-
* disabled while the syntax is broken but re-enabled once it parses, a
|
|
487
|
-
* generic for any Langium-backed diagram
|
|
488
|
-
*
|
|
489
|
-
*
|
|
765
|
+
* Seam: actions to emit when the structural-broken state transitions.
|
|
766
|
+
* Default: a {@link SetEditModeAction} toggling READONLY ↔ EDITABLE — editing
|
|
767
|
+
* is disabled while the syntax is broken but re-enabled once it parses, a
|
|
768
|
+
* sound generic for any Langium-backed diagram — plus the
|
|
769
|
+
* {@link DIAGRAM_READONLY_PARSE_ERROR} band that says why, cleared on the way
|
|
770
|
+
* back. Adopters override to add or replace the transition feedback. The
|
|
771
|
+
* {@link AbstractHydraniumGlspState.editMode} flip itself is owned by
|
|
772
|
+
* {@link refreshEditMode}.
|
|
773
|
+
*
|
|
774
|
+
* **Both actions or neither, which is why they are one seam rather than
|
|
775
|
+
* two.** The mode flip removes the tool palette and the band is the only
|
|
776
|
+
* account of that, so an override that keeps one and drops the other produces
|
|
777
|
+
* either an unexplained loss of the palette or a warning about a restriction
|
|
778
|
+
* that is not in force.
|
|
779
|
+
*
|
|
780
|
+
* **The status is dispatched with no `timeout`, so it persists** — a
|
|
781
|
+
* time-limited band would describe a condition that outlasts it.
|
|
782
|
+
*
|
|
783
|
+
* **The slot is SHARED, and the other writer wins on one path.**
|
|
784
|
+
* `ModelSubmissionHandler`'s live validation writes the same slot, clearing
|
|
785
|
+
* it with `severity: 'NONE'` when it finishes, and an adopter dispatching its
|
|
786
|
+
* own `StatusAction` replaces this one too. Nothing re-asserts it. That is
|
|
787
|
+
* harmless while a document is being edited, because a resubmit is skipped
|
|
788
|
+
* once the AST is broken (see {@link doUpdateAndSubmit}) — but NOT at load:
|
|
789
|
+
* the initial `requestModel` submit is not that skipped path, so a diagram
|
|
790
|
+
* opened on an already-broken document shows this band and then loses it to
|
|
791
|
+
* the validation clear a moment later. Measured on a browser host, polling the
|
|
792
|
+
* overlay: no element, empty, the sentence, empty again.
|
|
490
793
|
*/
|
|
491
|
-
protected onParseErrorChanged(_document: AstDocument<AstNode
|
|
492
|
-
return [
|
|
794
|
+
protected onParseErrorChanged(_document: AstDocument<AstNode>, broken: boolean): Action[] {
|
|
795
|
+
return [
|
|
796
|
+
SetEditModeAction.create(broken ? EditMode.READONLY : EditMode.EDITABLE),
|
|
797
|
+
broken
|
|
798
|
+
? StatusAction.create(this.sharedServices.MessageRenderer.renderMessage(DIAGRAM_READONLY_PARSE_ERROR), {
|
|
799
|
+
severity: 'WARNING'
|
|
800
|
+
})
|
|
801
|
+
: // `NONE` is the client's own spelling for "clear", not a severity it
|
|
802
|
+
// renders: `StatusOverlay.handle` branches on it before touching the
|
|
803
|
+
// DOM. An empty message at any other severity leaves an empty band
|
|
804
|
+
// with a warning icon standing on the canvas.
|
|
805
|
+
StatusAction.create('', { severity: 'NONE' })
|
|
806
|
+
];
|
|
493
807
|
}
|
|
494
808
|
|
|
495
809
|
/**
|
|
@@ -500,32 +814,48 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
|
|
|
500
814
|
return [];
|
|
501
815
|
}
|
|
502
816
|
|
|
503
|
-
/** The effective {@link
|
|
504
|
-
protected get
|
|
505
|
-
return this.
|
|
817
|
+
/** The effective {@link SaveDeliveryPolicy}: the bound option, or {@link DEFAULT_SAVE_DELIVERY_POLICY} when unbound. */
|
|
818
|
+
protected get saveDeliveryPolicy(): SaveDeliveryPolicy {
|
|
819
|
+
return this.boundSaveDeliveryPolicy ?? DEFAULT_SAVE_DELIVERY_POLICY;
|
|
506
820
|
}
|
|
507
821
|
|
|
508
822
|
/**
|
|
509
|
-
* Default save flow:
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
823
|
+
* Default save flow: persist the store's current text for every document the
|
|
824
|
+
* diagram's session has open.
|
|
825
|
+
*
|
|
826
|
+
* **A save persists, it does not author.** Every diagram gesture already
|
|
827
|
+
* reached the store through the diagram's session, so the store holds the
|
|
828
|
+
* settled text and disk is the only thing behind. Re-serializing from the AST
|
|
829
|
+
* here cannot improve on that text and can only damage it: a serializer
|
|
830
|
+
* normalises formatting, and the comments the write path carries over keep
|
|
831
|
+
* none of the hand-formatting around them, so a save would reflow a document
|
|
832
|
+
* nothing changed — which is what a pure bounds drag does to the semantic
|
|
833
|
+
* file when only its layout moved.
|
|
834
|
+
*
|
|
835
|
+
* **What is saved is the primary, every tracked secondary
|
|
836
|
+
* (`AbstractHydraniumGlspState.trackSecondaryDocument`) and every document
|
|
837
|
+
* that left the write set since the last save**, so a document the diagram
|
|
838
|
+
* wrote is persisted whether or not it is the one the client named. One the
|
|
839
|
+
* diagram's session does not have open is skipped, even when another client
|
|
840
|
+
* has it open: that client's unsaved edits are not the diagram's to persist.
|
|
514
841
|
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
*
|
|
523
|
-
*
|
|
842
|
+
* **A save therefore names documents the gesture did not aim at**, which is
|
|
843
|
+
* why an unchanged one is not rewritten even though the user asked for a
|
|
844
|
+
* save: the mtime would move on a file they never touched.
|
|
845
|
+
* `AstDocumentManager.save` decides that per document and announces the save
|
|
846
|
+
* either way.
|
|
847
|
+
*
|
|
848
|
+
* The configured {@link SaveDeliveryPolicy} (see {@link saveDeliveryPolicy})
|
|
849
|
+
* decides await-vs-fire-and-forget and failure handling. It carries no
|
|
850
|
+
* based-on guard: the guard exists to stop a stale writer overwriting a newer
|
|
851
|
+
* document, and a flush persists the store — which already holds every other
|
|
852
|
+
* client's change, including the one that advanced the version.
|
|
524
853
|
*
|
|
525
854
|
* Returns `MaybePromise<void>` to match upstream `SourceModelStorage`:
|
|
526
|
-
* resolves with the
|
|
527
|
-
*
|
|
528
|
-
* `WritableFileSystemProvider` directly) still override the whole
|
|
855
|
+
* resolves with the flush under `await`, returns synchronously under
|
|
856
|
+
* `fire-and-forget`. Adopters that bypass `AstDocumentManager` (writing
|
|
857
|
+
* through `WritableFileSystemProvider` directly) still override the whole
|
|
858
|
+
* method.
|
|
529
859
|
*/
|
|
530
860
|
saveSourceModel(action: SaveModelAction): MaybePromise<void> {
|
|
531
861
|
// Normalised for the same reason the load path is: `SaveModelAction.fileUri`
|
|
@@ -535,24 +865,61 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
|
|
|
535
865
|
// the call site rather than inside `getFileUri` so that method's contract —
|
|
536
866
|
// return what the action said — is unchanged for adopters overriding it.
|
|
537
867
|
const uri = this.toSourceModelUri(this.getFileUri(action));
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
}).then(() => undefined);
|
|
545
|
-
|
|
546
|
-
if (policy.kind === 'drop-and-log') {
|
|
547
|
-
// Fire-and-forget: the diagram save lost the race to a concurrent edit
|
|
548
|
-
// that already persisted the truth, so it must neither block the action
|
|
549
|
-
// nor surface. Log rather than leave an unhandled rejection.
|
|
550
|
-
persisted.catch(error => this.logger.error(`Save failed for ${uri}: ${error instanceof Error ? error.message : String(error)}`));
|
|
868
|
+
if (this.saveDeliveryPolicy.kind === 'fire-and-forget') {
|
|
869
|
+
// Log rather than leave an unhandled rejection: the promise is not
|
|
870
|
+
// returned, so nothing else will observe a failure.
|
|
871
|
+
this.flushWriteSet(uri).catch(error =>
|
|
872
|
+
this.logger.error(`Save failed for ${uri}: ${error instanceof Error ? error.message : String(error)}`)
|
|
873
|
+
);
|
|
551
874
|
return undefined;
|
|
552
875
|
}
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
|
|
876
|
+
// Awaited: any failure propagates to GLSP's save-action handler, which
|
|
877
|
+
// then sends no dirty state, so the one the save held back goes here.
|
|
878
|
+
// An override that throws before it returns a promise throws inside the
|
|
879
|
+
// async function too, so the hold always ends.
|
|
880
|
+
this.ownSavesPending++;
|
|
881
|
+
return (async () => {
|
|
882
|
+
try {
|
|
883
|
+
await this.flushWriteSet(uri);
|
|
884
|
+
} catch (error: unknown) {
|
|
885
|
+
this.sendDirtyState();
|
|
886
|
+
throw error;
|
|
887
|
+
} finally {
|
|
888
|
+
this.ownSavesPending--;
|
|
889
|
+
}
|
|
890
|
+
})();
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Save the stored text of `primaryUri`, every tracked secondary and every
|
|
895
|
+
* document that left the write set since the last save, each only where
|
|
896
|
+
* the diagram's session has it open; then close those that left.
|
|
897
|
+
*
|
|
898
|
+
* Deduplicated, because a state that tracks its own primary as a secondary
|
|
899
|
+
* would otherwise save it twice and fire two save notifications for one
|
|
900
|
+
* save. Every save is called in one synchronous step, so each takes its text
|
|
901
|
+
* before anything can close a document of the set: saved one after another,
|
|
902
|
+
* a GLSP session ending during the first write closes the rest unsaved.
|
|
903
|
+
*/
|
|
904
|
+
protected async flushWriteSet(primaryUri: string): Promise<void> {
|
|
905
|
+
// Refused like the load: without a session the client id's opens are
|
|
906
|
+
// another participant's, and this would save them.
|
|
907
|
+
this.requireModelSession();
|
|
908
|
+
const documents = this.sharedServices.workspace.AstDocumentManager;
|
|
909
|
+
const textDocuments = this.sharedServices.workspace.TextDocuments;
|
|
910
|
+
const clientId = this.state.clientId;
|
|
911
|
+
// Taken with the targets: a document leaving the set during the writes
|
|
912
|
+
// was not saved by them, and stays open for the next save.
|
|
913
|
+
const departed = [...this.departedSecondaries];
|
|
914
|
+
const targets = [...new Set([primaryUri, ...this.state.secondaryUris, ...departed])].filter(uri =>
|
|
915
|
+
textDocuments.isOpenInClient(uri, clientId)
|
|
916
|
+
);
|
|
917
|
+
await Promise.all(targets.map(target => documents.save(target, clientId)));
|
|
918
|
+
for (const uri of departed) {
|
|
919
|
+
if (this.departedSecondaries.delete(uri) && uri !== this.state.sourceUri) {
|
|
920
|
+
await this.state.modelSession?.close(uri);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
556
923
|
}
|
|
557
924
|
|
|
558
925
|
/**
|
|
@@ -584,11 +951,25 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
|
|
|
584
951
|
*
|
|
585
952
|
* Returns it VERBATIM, in whatever form the client sent;
|
|
586
953
|
* {@link toSourceModelUri} is what normalises it.
|
|
954
|
+
*
|
|
955
|
+
* A model request DOES carry a request id, so a throw here takes the
|
|
956
|
+
* client-request path rather than the toast path — and on that path `detail`
|
|
957
|
+
* comes from `cause?.toString?.()`. A single-argument throw therefore reaches
|
|
958
|
+
* neither the server log nor the client console: both print `undefined`. So
|
|
959
|
+
* the two readers are addressed separately, as on the save path: `message`
|
|
960
|
+
* names what failed in the user's terms and is rendered, `cause` names the
|
|
961
|
+
* action and the option key that was missing and stays English. On THIS path
|
|
962
|
+
* the cause reaches only logs — `RejectAction.detail` is read by nothing but
|
|
963
|
+
* the client action-dispatcher's `logger.warn` — which is not what makes it
|
|
964
|
+
* English; its content is.
|
|
587
965
|
*/
|
|
588
966
|
protected getSourceUri(action: RequestModelAction): string {
|
|
589
967
|
const sourceUri = action.options?.[SOURCE_URI_ARG];
|
|
590
968
|
if (typeof sourceUri !== 'string') {
|
|
591
|
-
throw new GLSPServerError(
|
|
969
|
+
throw new GLSPServerError(
|
|
970
|
+
this.sharedServices.MessageRenderer.renderMessage(SOURCE_URI_MISSING),
|
|
971
|
+
`no '${SOURCE_URI_ARG}' option on the ${action.kind} action (received ${typeof sourceUri})`
|
|
972
|
+
);
|
|
592
973
|
}
|
|
593
974
|
return sourceUri;
|
|
594
975
|
}
|
|
@@ -600,18 +981,24 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
|
|
|
600
981
|
* {@link AbstractHydraniumGlspState.setSourceRoot}.
|
|
601
982
|
*
|
|
602
983
|
* A save action carries no request id, so a throw here reaches the user as a
|
|
603
|
-
* toast built from `message`,
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
984
|
+
* toast built from `message`, while `cause` travels in
|
|
985
|
+
* `MessageAction.details` — **which a Theia host DOES put in front of a
|
|
986
|
+
* user**, as a "Show details" button on the toast opening a dialog. So the
|
|
987
|
+
* two are not split by reachability; they are split by AUDIENCE, which is
|
|
988
|
+
* the older rule and the one that holds here. The message names what failed
|
|
989
|
+
* in the user's terms and is rendered; the cause names which lookups came
|
|
990
|
+
* back empty for whom, and stays English because a wire action kind, an
|
|
991
|
+
* option key and a client id address whoever composes the system — a
|
|
992
|
+
* translated developer string is a worse outcome than an untranslated one.
|
|
993
|
+
* Neither can name the URI — not having one IS the failure, and both sources
|
|
994
|
+
* are written together, so the typed `sourceUri` is equally empty whenever
|
|
995
|
+
* this fires.
|
|
609
996
|
*/
|
|
610
997
|
protected getFileUri(action: SaveModelAction): string {
|
|
611
998
|
const uri = action.fileUri ?? this.state.get<string>(SOURCE_URI_ARG);
|
|
612
999
|
if (!uri) {
|
|
613
1000
|
throw new GLSPServerError(
|
|
614
|
-
|
|
1001
|
+
this.sharedServices.MessageRenderer.renderMessage(SAVE_TARGET_UNKNOWN),
|
|
615
1002
|
`no fileUri on the save action and no ${SOURCE_URI_ARG} in the model state (clientId=${this.state.clientId})`
|
|
616
1003
|
);
|
|
617
1004
|
}
|
|
@@ -644,10 +1031,14 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
|
|
|
644
1031
|
}
|
|
645
1032
|
|
|
646
1033
|
/**
|
|
647
|
-
* Cancel any pending resubmit
|
|
1034
|
+
* Cancel any pending resubmit, drain every subscription — those registered
|
|
648
1035
|
* during {@link doLoadSourceModel} and the per-secondary ones, which live
|
|
649
|
-
* outside {@link toDispose} because they come and go with the write set
|
|
1036
|
+
* outside {@link toDispose} because they come and go with the write set —
|
|
1037
|
+
* and end the diagram's client session, closing everything it has open.
|
|
650
1038
|
* Idempotent.
|
|
1039
|
+
*
|
|
1040
|
+
* The session ends last, once the detach listener is gone: its closes would
|
|
1041
|
+
* otherwise report this storage's own teardown as a client detaching.
|
|
651
1042
|
*/
|
|
652
1043
|
dispose(): void {
|
|
653
1044
|
this.resubmitDebouncer?.dispose();
|
|
@@ -656,5 +1047,8 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
|
|
|
656
1047
|
}
|
|
657
1048
|
this.secondarySubscriptions.clear();
|
|
658
1049
|
this.toDispose.dispose();
|
|
1050
|
+
this.disposed = true;
|
|
1051
|
+
this.state.modelSession?.dispose();
|
|
1052
|
+
this.state.modelSession = undefined;
|
|
659
1053
|
}
|
|
660
1054
|
}
|