@hydranium/glsp-server 1.0.0-next.123 → 1.0.0-next.127

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 (51) hide show
  1. package/README.md +8 -4
  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 +43 -8
  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/index.d.ts +1 -0
  11. package/lib/command/index.d.ts.map +1 -1
  12. package/lib/command/index.js +1 -0
  13. package/lib/command/index.js.map +1 -1
  14. package/lib/launcher/abstract-hydranium-glsp-diagram-module.d.ts +12 -3
  15. package/lib/launcher/abstract-hydranium-glsp-diagram-module.d.ts.map +1 -1
  16. package/lib/launcher/abstract-hydranium-glsp-diagram-module.js +18 -2
  17. package/lib/launcher/abstract-hydranium-glsp-diagram-module.js.map +1 -1
  18. package/lib/launcher/hydranium-glsp-server.d.ts +6 -3
  19. package/lib/launcher/hydranium-glsp-server.d.ts.map +1 -1
  20. package/lib/launcher/hydranium-glsp-server.js +12 -3
  21. package/lib/launcher/hydranium-glsp-server.js.map +1 -1
  22. package/lib/node/start-glsp-server.d.ts +1 -1
  23. package/lib/node/start-glsp-server.d.ts.map +1 -1
  24. package/lib/node/start-glsp-server.js +1 -1
  25. package/lib/node/start-glsp-server.js.map +1 -1
  26. package/lib/state/abstract-hydranium-glsp-state.d.ts.map +1 -1
  27. package/lib/state/abstract-hydranium-glsp-state.js +12 -22
  28. package/lib/state/abstract-hydranium-glsp-state.js.map +1 -1
  29. package/lib/storage/hydranium-glsp-request-save-model-action-handler.d.ts +30 -0
  30. package/lib/storage/hydranium-glsp-request-save-model-action-handler.d.ts.map +1 -0
  31. package/lib/storage/hydranium-glsp-request-save-model-action-handler.js +47 -0
  32. package/lib/storage/hydranium-glsp-request-save-model-action-handler.js.map +1 -0
  33. package/lib/storage/hydranium-glsp-storage.d.ts +25 -4
  34. package/lib/storage/hydranium-glsp-storage.d.ts.map +1 -1
  35. package/lib/storage/hydranium-glsp-storage.js +57 -12
  36. package/lib/storage/hydranium-glsp-storage.js.map +1 -1
  37. package/lib/storage/index.d.ts +1 -0
  38. package/lib/storage/index.d.ts.map +1 -1
  39. package/lib/storage/index.js +1 -0
  40. package/lib/storage/index.js.map +1 -1
  41. package/package.json +7 -7
  42. package/src/browser/start-glsp-server-in-worker.ts +41 -44
  43. package/src/command/hydranium-glsp-command-stack.ts +36 -0
  44. package/src/command/index.ts +1 -0
  45. package/src/launcher/abstract-hydranium-glsp-diagram-module.ts +27 -3
  46. package/src/launcher/hydranium-glsp-server.ts +12 -3
  47. package/src/node/start-glsp-server.ts +1 -2
  48. package/src/state/abstract-hydranium-glsp-state.ts +16 -26
  49. package/src/storage/hydranium-glsp-request-save-model-action-handler.ts +40 -0
  50. package/src/storage/hydranium-glsp-storage.ts +61 -15
  51. package/src/storage/index.ts +1 -0
@@ -14,43 +14,38 @@ import {
14
14
  WorkerServerLauncher,
15
15
  createAppModule
16
16
  } from '@eclipse-glsp/server/browser.js';
17
- import { Container, type ContainerModule } from 'inversify';
18
- import type { Logger } from '@hydranium/protocol';
17
+ import { Container, injectable, type ContainerModule } from 'inversify';
18
+ import { createMessagePortTransport, type Logger, type TransferredMessagePort } from '@hydranium/protocol';
19
+ import { createMessageConnection, type MessageConnection } from 'vscode-jsonrpc/browser';
19
20
  import type { IntegratedServer } from '@hydranium/core';
20
21
  import { createGlspFrameworkOverrides } from '../launcher/glsp-framework-overrides.js';
21
22
  import { createGlspServerOverrides } from '../launcher/glsp-server-overrides.js';
22
23
 
24
+ // Re-exported so the type of this head's `context` still resolves from here.
25
+ export type { TransferredMessagePort } from '@hydranium/protocol';
26
+
23
27
  /**
24
- * A `MessagePort` the host transferred into the worker, described structurally.
25
- *
26
- * This package inherits `tsconfig.base.json`'s `lib: ["ES2022"]` and so compiles
27
- * without the DOM lib, which is what keeps `document` / `window` compile errors
28
- * here — so `MessagePort` has no name either and the contract has to be spelled
29
- * out.
30
- *
31
- * **The member set is chosen to admit a `MessagePort` and REJECT a `Worker` or
32
- * the worker global**, both of which the launcher's own reader would otherwise
33
- * accept. Only a port needs starting, so `start` is what tells the three apart,
34
- * and naming it here turns "never bind a head to the global" from a rule in a
35
- * document into a compile error at the call site.
28
+ * The worker launcher every {@link startGlspServerInWorker} head runs on: GLSP's
29
+ * own, with its connection built over `createMessagePortTransport` rather than
30
+ * over `BrowserMessageReader`/`BrowserMessageWriter`, which never report a
31
+ * close. So the client disposing its connection closes this one, and upstream
32
+ * disposes the server instance, and with it every client session, as it does
33
+ * when a socket closes.
36
34
  *
37
- * **That compile error happens at the ADOPTER, not here.** This project resolves
38
- * neither `MessagePort` nor `Worker` as a type, so nothing in this package can
39
- * demonstrate the rejection; a host compiling its worker against `lib.webworker`
40
- * (or a page against `lib.dom`) is where the names resolve and the guard bites.
41
- * Measured there: passing the worker global fails with `Property 'start' is
42
- * missing in type 'DedicatedWorkerGlobalScope'`.
35
+ * Requires `context`: unlike upstream it never falls back to the worker global.
43
36
  *
44
- * `addEventListener` is not a discriminator — `postMessage` plus `start` already
45
- * excludes the other two — and nothing in this module calls any of the three
46
- * (the reader assigns `onmessage`, which starts a port implicitly). It is listed
47
- * because it is part of what the reader uses, so the interface reads as the
48
- * contract rather than as a minimal trick.
37
+ * **The install needs a single `vscode-jsonrpc`.** The connection comes from
38
+ * this package's copy, and GLSP's request and notification types from the copy
39
+ * `@eclipse-glsp/protocol` resolves. With two copies, a typed notification or
40
+ * request GLSP sends throws `Unknown parameter structure auto`, because the
41
+ * connection compares the type's parameter-structure marker by identity.
49
42
  */
50
- export interface TransferredMessagePort {
51
- postMessage(message: unknown): void;
52
- addEventListener(type: 'message', listener: (event: unknown) => void, options?: unknown): void;
53
- start(): void;
43
+ @injectable()
44
+ export class HydraniumGlspWorkerServerLauncher extends WorkerServerLauncher {
45
+ protected override createConnection(options: WorkerLaunchOptions): MessageConnection {
46
+ const transport = createMessagePortTransport(options.context as unknown as TransferredMessagePort);
47
+ return createMessageConnection(transport.reader, transport.writer);
48
+ }
54
49
  }
55
50
 
56
51
  /**
@@ -66,14 +61,15 @@ export interface BrowserGlspServerOptions {
66
61
  /**
67
62
  * The port the host transferred into the worker for this head.
68
63
  *
69
- * **Required, and that is the whole point.** GLSP's
70
- * `WorkerServerLauncher.createConnection` falls back to the worker global
71
- * when it is omitted, and a head on the global receives every other head's
72
- * traffic — `BrowserMessageReader` filters nothing, so this is not a race
73
- * under load but every message delivered to the wrong reader. The global is
74
- * unusable even for a single head, because the launcher posts its startup
75
- * string through the global `postMessage` regardless of the connection it
76
- * was given, and no JSON-RPC reader can parse that.
64
+ * **Required: the worker global is no substitute.** A head on the global
65
+ * receives every other head's traffic — a reader filters nothing, so this is
66
+ * not a race under load but every message delivered to the wrong reader — and
67
+ * GLSP's launcher posts its startup string through the global `postMessage`
68
+ * regardless of the connection it was given, which no JSON-RPC reader parses.
69
+ * Unlike upstream's, this head's launcher never falls back to the global.
70
+ *
71
+ * The page's end must connect through `createMessagePortTransport` as well:
72
+ * the head reads its close signal from the port, and posts one on it.
77
73
  */
78
74
  readonly context: TransferredMessagePort;
79
75
  /**
@@ -126,8 +122,9 @@ export interface BrowserGlspServerOptions {
126
122
  * below. There is no transport handshake to wait on: the port arrived live,
127
123
  * and the launcher's own readiness signal is a string it posts on the worker
128
124
  * global, which nothing here reads.
129
- * - `stopped` resolves when the connection closes, and rejects on a connection
130
- * error.
125
+ * - `stopped` resolves when the connection closes, which the client disposing
126
+ * its connection causes, and rejects on a connection error. Nothing reports a
127
+ * page that dies, because the port cannot.
131
128
  */
132
129
  export function startGlspServerInWorker(options: BrowserGlspServerOptions): IntegratedServer {
133
130
  const lifecycle = options.logger;
@@ -145,7 +142,7 @@ export function startGlspServerInWorker(options: BrowserGlspServerOptions): Inte
145
142
  const appContainer = new Container();
146
143
  appContainer.load(glspAppModule, createGlspFrameworkOverrides(options.createLogger), ...(options.appModules ?? []));
147
144
 
148
- const launcher = appContainer.resolve<WorkerServerLauncher>(WorkerServerLauncher);
145
+ const launcher = appContainer.resolve<WorkerServerLauncher>(HydraniumGlspWorkerServerLauncher);
149
146
  // Additional module rather than an app-container binding, as on Node: the
150
147
  // launcher loads these into the per-connection SERVER container, the only
151
148
  // tier where the adopter's `ServerModule` binding of `GLSPServer` is
@@ -153,10 +150,10 @@ export function startGlspServerInWorker(options: BrowserGlspServerOptions): Inte
153
150
  launcher.configure(options.serverModule, createGlspServerOverrides());
154
151
 
155
152
  try {
156
- // Upstream declares `WorkerLaunchOptions.context` as `Worker`, narrower
157
- // than the `MessagePort | Worker | DedicatedWorkerGlobalScope` its own
158
- // `BrowserMessageReader` accepts — so the value that is CORRECT here is
159
- // the one the declaration names as wrong.
153
+ // Upstream declares `WorkerLaunchOptions.context` as `Worker`, and
154
+ // `HydraniumGlspWorkerServerLauncher.createConnection` reads it as a
155
+ // port — so the value that is CORRECT here is the one the declaration
156
+ // names as wrong.
160
157
  //
161
158
  // **The cast is currently redundant, and is kept deliberately.** `Worker`
162
159
  // does not resolve under this package's `lib`, so `skipLibCheck` leaves
@@ -0,0 +1,36 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ import { DefaultCommandStack, ModelState } from '@eclipse-glsp/server';
11
+ import { type ServerSharedServices } from '@hydranium/core';
12
+ import { inject, injectable } from 'inversify';
13
+ import { HydraniumTypes } from '../state/hydranium-shared-core-services.js';
14
+
15
+ /**
16
+ * GLSP's command stack with the diagram's dirty state read from the text store:
17
+ * dirty while any document the diagram's client session has open differs from
18
+ * its file, as the store last knew the file. Undo and redo are GLSP's.
19
+ *
20
+ * GLSP's own answer counts the diagram's commands since its last save, so it
21
+ * misses every change the diagram did not make: another client's unsaved edit
22
+ * leaves it clean, and a save it did not make of a document other than the
23
+ * diagram's own leaves it dirty. Every `SetDirtyStateAction` GLSP sends reads
24
+ * this answer.
25
+ */
26
+ @injectable()
27
+ export class HydraniumGlspCommandStack extends DefaultCommandStack {
28
+ @inject(ModelState) protected readonly modelState!: ModelState;
29
+ @inject(HydraniumTypes.SharedCoreServices) protected readonly sharedServices!: ServerSharedServices;
30
+
31
+ override get isDirty(): boolean {
32
+ const textDocuments = this.sharedServices.workspace.TextDocuments;
33
+ const clientId = this.modelState.clientId;
34
+ return textDocuments.openDocuments().some(open => open.clients.includes(clientId) && textDocuments.isDirty(open.uri));
35
+ }
36
+ }
@@ -8,3 +8,4 @@
8
8
  ********************************************************************************/
9
9
 
10
10
  export * from './hydranium-glsp-recording-command.js';
11
+ export * from './hydranium-glsp-command-stack.js';
@@ -7,11 +7,19 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
- import { DiagramModule } from '@eclipse-glsp/server';
10
+ import {
11
+ type ActionHandlerConstructor,
12
+ type BindingTarget,
13
+ type CommandStack,
14
+ DiagramModule,
15
+ type InstanceMultiBinding
16
+ } from '@eclipse-glsp/server';
11
17
  import { injectable, type interfaces } from 'inversify';
12
18
  import type { LanguageMetaData } from '@hydranium/langium';
13
19
  import { type ServerLanguageServices, type ServerSharedServices, typedMetadata } from '@hydranium/core';
14
20
  import { HydraniumTypes } from '../state/hydranium-shared-core-services.js';
21
+ import { HydraniumGlspRequestSaveModelActionHandler } from '../storage/hydranium-glsp-request-save-model-action-handler.js';
22
+ import { HydraniumGlspCommandStack } from '../command/hydranium-glsp-command-stack.js';
15
23
 
16
24
  /**
17
25
  * Bind {@link HydraniumTypes}.DiagramLanguage on a GLSP **session** container
@@ -20,8 +28,10 @@ import { HydraniumTypes } from '../state/hydranium-shared-core-services.js';
20
28
  * Exported separately from {@link AbstractHydraniumGlspDiagramModule} for adopters
21
29
  * whose diagram module already extends an intermediate base of their own and
22
30
  * cannot take the framework base class; call it from `configure` after
23
- * `super.configure(...)`. The base class is the preferred entry point because
24
- * it makes the declaration non-optional.
31
+ * `super.configure(...)`, add {@link HydraniumGlspRequestSaveModelActionHandler}
32
+ * in `configureActionHandlers`, and return {@link HydraniumGlspCommandStack} from
33
+ * `bindCommandStack`, all of which the base also does. The base class is the
34
+ * preferred entry point because it makes the declaration non-optional.
25
35
  *
26
36
  * **Why the session tier.** GLSP builds its app container once per process,
27
37
  * before any document exists, so a per-language service bound there can only
@@ -114,4 +124,18 @@ export abstract class AbstractHydraniumGlspDiagramModule extends DiagramModule {
114
124
  super.configure(bind, unbind, isBound, rebind);
115
125
  bindDiagramLanguage(bind, this.declareLanguage());
116
126
  }
127
+
128
+ /** {@link HydraniumGlspCommandStack}, so the diagram is dirty exactly while a document it has open is. */
129
+ protected override bindCommandStack(): BindingTarget<CommandStack> {
130
+ return HydraniumGlspCommandStack;
131
+ }
132
+
133
+ /**
134
+ * Adds {@link HydraniumGlspRequestSaveModelActionHandler}, the save the
135
+ * Theia client sends, beside GLSP's own save handler.
136
+ */
137
+ protected override configureActionHandlers(binding: InstanceMultiBinding<ActionHandlerConstructor>): void {
138
+ super.configureActionHandlers(binding);
139
+ binding.add(HydraniumGlspRequestSaveModelActionHandler);
140
+ }
117
141
  }
@@ -15,6 +15,7 @@ import {
15
15
  type RequestAction,
16
16
  type ResponseAction
17
17
  } from '@eclipse-glsp/server';
18
+ import { RequestSaveModelAction } from '@hydranium/protocol';
18
19
  import { injectable } from 'inversify';
19
20
 
20
21
  /**
@@ -64,9 +65,12 @@ export class HydraniumGlspServer extends DefaultGLSPServer {
64
65
  /**
65
66
  * Lifted verbatim from `@eclipse-glsp/server` 2.7.0's
66
67
  * {@link DefaultGLSPServer.handleClientRequest}, with the detail computation
67
- * delegated to {@link requestFailureDetail}. Nothing else here — the
68
- * timeout branch, the response dispatch, the nested send-failure handling —
69
- * relates to that fallback.
68
+ * delegated to {@link requestFailureDetail}, and one addition: a failed
69
+ * {@link RequestSaveModelAction} also raises the error notification a failed
70
+ * `SaveModelAction` raises. A rejection reaches only the client's log, and a
71
+ * Theia host only logs a rejected save, so without it a save that failed
72
+ * would tell the user nothing. Nothing else here — the timeout branch, the
73
+ * response dispatch, the nested send-failure handling — relates to either.
70
74
  *
71
75
  * **This body does not track upstream.** A release that changes request
72
76
  * handling would silently not apply, on the path every request takes. There
@@ -97,6 +101,11 @@ export class HydraniumGlspServer extends DefaultGLSPServer {
97
101
  detail
98
102
  });
99
103
  this.sendResponseToClient(clientId, reject);
104
+ // Inside the guard: `process` does not await this method, so a
105
+ // closed connection throwing here would be an unhandled rejection.
106
+ if (action.kind === RequestSaveModelAction.KIND) {
107
+ this.handleProcessError({ clientId, action }, error);
108
+ }
100
109
  } catch (sendError: unknown) {
101
110
  this.logger.error(`Failed to send rejection for request '${action.requestId}':`, sendError);
102
111
  }
@@ -16,8 +16,7 @@ import {
16
16
  defaultSocketLaunchOptions
17
17
  } from '@eclipse-glsp/server/node.js';
18
18
  import { Container, type ContainerModule } from 'inversify';
19
- import type { Logger } from '@hydranium/protocol';
20
- import { Deferred } from '@hydranium/langium';
19
+ import { Deferred, type Logger } from '@hydranium/protocol';
21
20
  import type * as net from 'node:net';
22
21
  import type { IntegratedServer } from '@hydranium/core';
23
22
  import { createGlspFrameworkOverrides } from '../launcher/glsp-framework-overrides.js';
@@ -24,11 +24,11 @@ import {
24
24
  type BasedOn,
25
25
  type SnapshotVersion,
26
26
  type ConflictResolver,
27
- type Disposable,
28
27
  type Logger,
29
28
  type Tracer,
30
29
  asSnapshotVersion,
31
- NO_MATCHING_VERSION
30
+ NO_MATCHING_VERSION,
31
+ TIMED_OUT
32
32
  } from '@hydranium/protocol';
33
33
  import { type HydraniumGlspIndex } from './hydranium-glsp-index.js';
34
34
  import { HydraniumTypes } from './hydranium-shared-core-services.js';
@@ -514,31 +514,21 @@ export abstract class AbstractHydraniumGlspState<TRoot extends AstNode, TSourceM
514
514
  protected async readyWithTimeout(state: DocumentState): Promise<void> {
515
515
  const uri = this._sourceUri;
516
516
  const stopwatch = this.sharedServices.Clock.stopwatch();
517
- let timer: Disposable | undefined;
518
-
519
- const timeout = new Promise<void>((resolve, reject) => {
520
- timer = this.sharedServices.Clock.setTimer(() => {
521
- const elapsed = Math.round(stopwatch.elapsedMs);
522
- // Through the model service's canonicalizing gateway, so a symlinked
523
- // `_sourceUri` is not reported as a hard timeout.
524
- const doc = this.sharedServices.model.ModelService.getDocument(uri);
525
- if (doc && doc.state >= state) {
526
- this.logger.warn(
527
- `Missed '${DocumentState[state]}' notification after ${elapsed}ms; document is already at state ` +
528
- `'${DocumentState[doc.state]}'. Likely a Langium build-phase event race.`
529
- );
530
- resolve();
531
- } else {
532
- reject(this.buildReadyTimeoutError(state, elapsed));
533
- }
534
- }, this.readyTimeoutMs);
535
- });
536
-
537
- try {
538
- await Promise.race([this.sharedServices.model.ModelService.waitForDocumentState(uri, state), timeout]);
539
- } finally {
540
- timer?.dispose();
517
+ const reached = this.sharedServices.model.ModelService.waitForDocumentState(uri, state);
518
+ if ((await this.sharedServices.Clock.raceTimer(reached, this.readyTimeoutMs)) !== TIMED_OUT) {
519
+ return;
520
+ }
521
+ const elapsed = Math.round(stopwatch.elapsedMs);
522
+ // Through the model service's canonicalizing gateway, so a symlinked
523
+ // `_sourceUri` is not reported as a hard timeout.
524
+ const doc = this.sharedServices.model.ModelService.getDocument(uri);
525
+ if (!doc || doc.state < state) {
526
+ throw this.buildReadyTimeoutError(state, elapsed);
541
527
  }
528
+ this.logger.warn(
529
+ `Missed '${DocumentState[state]}' notification after ${elapsed}ms; document is already at state ` +
530
+ `'${DocumentState[doc.state]}'. Likely a Langium build-phase event race.`
531
+ );
542
532
  }
543
533
 
544
534
  /**
@@ -0,0 +1,40 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ import { type Action, SaveModelAction, SaveModelActionHandler } from '@eclipse-glsp/server';
11
+ import { ModelSavedAction, RequestSaveModelAction } from '@hydranium/protocol';
12
+ import { injectable } from 'inversify';
13
+
14
+ /**
15
+ * Answers a {@link RequestSaveModelAction}: saves exactly as GLSP's
16
+ * {@link SaveModelActionHandler} saves a `SaveModelAction` carrying the same
17
+ * `fileUri`, dirty-state change included, then replies with a
18
+ * {@link ModelSavedAction}.
19
+ *
20
+ * A failed save throws, and the server turns the throw into a `RejectAction`
21
+ * for the request. The save handler's own `SaveModelAction` stays registered,
22
+ * so a client that sends it is unaffected.
23
+ *
24
+ * `AbstractHydraniumGlspDiagramModule` registers it. A diagram module that does
25
+ * not extend that base adds it in `configureActionHandlers`. Without it no
26
+ * handler is advertised for the request, and the Theia client saves as GLSP's
27
+ * saveable does: a save nothing answers, so a failed one surfaces only as a
28
+ * timeout, and a window can close over a save still under way.
29
+ */
30
+ @injectable()
31
+ export class HydraniumGlspRequestSaveModelActionHandler extends SaveModelActionHandler {
32
+ override actionKinds = [RequestSaveModelAction.KIND];
33
+
34
+ // Typed `Action`, the one parameter type both this kind and the base's accept.
35
+ override async execute(action: Action): Promise<Action[]> {
36
+ const fileUri = 'fileUri' in action && typeof action.fileUri === 'string' ? action.fileUri : undefined;
37
+ const actions = await super.execute(SaveModelAction.create({ fileUri }));
38
+ return [...actions, ModelSavedAction.create()];
39
+ }
40
+ }
@@ -27,6 +27,7 @@ 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
@@ -47,6 +48,7 @@ import {
47
48
  type AstDocumentSavedEvent,
48
49
  type AstDocumentUpdatedEvent,
49
50
  type ClientSession as ModelClientSession,
51
+ type DocumentDirtyChangedEvent,
50
52
  type ServerSharedServices
51
53
  } from '@hydranium/core';
52
54
  import { DiagnosticSeverity } from 'vscode-languageserver-types';
@@ -262,6 +264,8 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
262
264
 
263
265
  /** Set by {@link dispose}; a disposed storage registers no session again. */
264
266
  protected disposed = false;
267
+ /** Saves of the diagram's own that GLSP's save handler awaits; see {@link handleDirtyChanged}. */
268
+ protected ownSavesPending = 0;
265
269
 
266
270
  /**
267
271
  * Whether an external resubmit has ever been dispatched. Gates the
@@ -329,10 +333,7 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
329
333
  */
330
334
  protected requireModelSession(): ModelClientSession<AstNode> {
331
335
  if (this.disposed) {
332
- throw new SessionClosedError(
333
- this.state.clientId,
334
- `The diagram of ${this.state.clientId} has closed; its client session has ended`
335
- );
336
+ throw new SessionClosedError(this.state.clientId, 'The diagram has closed; its client session has ended.');
336
337
  }
337
338
  if (!this.state.modelSession) {
338
339
  this.state.modelSession = this.registerModelSession();
@@ -427,9 +428,12 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
427
428
  // React to external rebuilds: settle-gated capture + debounced/deduped resubmit.
428
429
  this.toDispose.push(modelService.onModelUpdated(rootUri, event => this.handleModelUpdated(rootUri, event)));
429
430
 
430
- // Coordinate the command stack's dirty state when another client saves.
431
+ // GLSP's own command stack counts commands; tell it of another client's save.
431
432
  this.toDispose.push(modelService.onModelSaved(rootUri, event => this.handleModelSaved(event)));
432
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
+
433
437
  // Capture the initial settled root.
434
438
  const document = await modelService.settled(rootUri);
435
439
  await this.captureSettledRoot(rootUri, document);
@@ -650,9 +654,10 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
650
654
  }
651
655
 
652
656
  /**
653
- * Coordinate the command stack when another client persists the document:
654
- * mark our stack clean so the editor doesn't prompt to re-save identical
655
- * content. Our own saves already settle the stack through the save flow.
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.
656
661
  */
657
662
  protected handleModelSaved(event: AstDocumentSavedEvent<AstNode>): void {
658
663
  if (this.state.clientId !== event.sourceClientId) {
@@ -660,6 +665,35 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
660
665
  }
661
666
  }
662
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
+
663
697
  /**
664
698
  * Re-settle to a guaranteed fully-linked + reprojected root, capture it, and
665
699
  * (unless suppressed) resubmit a deduped external GModel. Diagnostics for the
@@ -831,17 +865,29 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
831
865
  // the call site rather than inside `getFileUri` so that method's contract —
832
866
  // return what the action said — is unchanged for adopters overriding it.
833
867
  const uri = this.toSourceModelUri(this.getFileUri(action));
834
- const policy = this.saveDeliveryPolicy;
835
- const persisted = this.flushWriteSet(uri);
836
-
837
- if (policy.kind === 'fire-and-forget') {
868
+ if (this.saveDeliveryPolicy.kind === 'fire-and-forget') {
838
869
  // Log rather than leave an unhandled rejection: the promise is not
839
870
  // returned, so nothing else will observe a failure.
840
- persisted.catch(error => this.logger.error(`Save failed for ${uri}: ${error instanceof Error ? error.message : String(error)}`));
871
+ this.flushWriteSet(uri).catch(error =>
872
+ this.logger.error(`Save failed for ${uri}: ${error instanceof Error ? error.message : String(error)}`)
873
+ );
841
874
  return undefined;
842
875
  }
843
- // Awaited: any failure propagates to GLSP's save-action handler.
844
- return persisted;
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
+ })();
845
891
  }
846
892
 
847
893
  /**
@@ -7,5 +7,6 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
+ export * from './hydranium-glsp-request-save-model-action-handler.js';
10
11
  export * from './hydranium-glsp-storage.js';
11
12
  export * from './save-delivery-policy.js';