@hydranium/core 1.0.0-next.205 → 1.0.0-next.206
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/lib/documents/ast-document-manager.js +4 -4
- package/lib/documents/ast-document-manager.js.map +1 -1
- package/lib/documents/client-ids.d.ts +1 -1
- package/lib/documents/client-ids.d.ts.map +1 -1
- package/lib/documents/client-ids.js +1 -1
- package/lib/documents/client-ids.js.map +1 -1
- package/lib/documents/client-session-registry.d.ts +24 -17
- package/lib/documents/client-session-registry.d.ts.map +1 -1
- package/lib/documents/client-session-registry.js +49 -29
- package/lib/documents/client-session-registry.js.map +1 -1
- package/lib/documents/dirty-state-tracker.d.ts +79 -0
- package/lib/documents/dirty-state-tracker.d.ts.map +1 -0
- package/lib/documents/dirty-state-tracker.js +69 -0
- package/lib/documents/dirty-state-tracker.js.map +1 -0
- package/lib/documents/document-release-handler.d.ts +174 -0
- package/lib/documents/document-release-handler.d.ts.map +1 -0
- package/lib/documents/document-release-handler.js +270 -0
- package/lib/documents/document-release-handler.js.map +1 -0
- package/lib/documents/document-release-scheduler.d.ts +67 -0
- package/lib/documents/document-release-scheduler.d.ts.map +1 -0
- package/lib/documents/document-release-scheduler.js +80 -0
- package/lib/documents/document-release-scheduler.js.map +1 -0
- package/lib/documents/hydranium-text-documents.d.ts +130 -455
- package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
- package/lib/documents/hydranium-text-documents.js +362 -835
- package/lib/documents/hydranium-text-documents.js.map +1 -1
- package/lib/documents/index.d.ts +5 -1
- package/lib/documents/index.d.ts.map +1 -1
- package/lib/documents/index.js +5 -1
- package/lib/documents/index.js.map +1 -1
- package/lib/documents/language-client-shadow.d.ts +221 -0
- package/lib/documents/language-client-shadow.d.ts.map +1 -0
- package/lib/documents/language-client-shadow.js +288 -0
- package/lib/documents/language-client-shadow.js.map +1 -0
- package/lib/documents/text-ledger.d.ts +64 -0
- package/lib/documents/text-ledger.d.ts.map +1 -0
- package/lib/documents/text-ledger.js +63 -0
- package/lib/documents/text-ledger.js.map +1 -0
- package/lib/langium/document-builder/document-builder.d.ts +8 -7
- package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
- package/lib/langium/document-builder/document-builder.js +8 -7
- package/lib/langium/document-builder/document-builder.js.map +1 -1
- package/lib/langium/integrity/integrity-service.d.ts +1 -1
- package/lib/langium/integrity/integrity-service.js +4 -4
- package/lib/langium/integrity/integrity-service.js.map +1 -1
- package/lib/langium/model-service/client-session.d.ts +4 -4
- package/lib/langium/model-service/client-session.js +2 -2
- package/lib/langium/model-service/client-session.js.map +1 -1
- package/lib/langium/model-service/model-events.d.ts +8 -11
- package/lib/langium/model-service/model-events.d.ts.map +1 -1
- package/lib/langium/model-service/model-service.d.ts +12 -4
- package/lib/langium/model-service/model-service.d.ts.map +1 -1
- package/lib/langium/model-service/model-service.js +1 -1
- package/lib/langium/model-service/model-service.js.map +1 -1
- package/lib/langium/module.d.ts +7 -0
- package/lib/langium/module.d.ts.map +1 -1
- package/lib/langium/module.js +3 -1
- package/lib/langium/module.js.map +1 -1
- package/lib/langium/workspace/file-not-found.d.ts +7 -1
- package/lib/langium/workspace/file-not-found.d.ts.map +1 -1
- package/lib/langium/workspace/file-not-found.js +14 -3
- package/lib/langium/workspace/file-not-found.js.map +1 -1
- package/lib/lsp/hydranium-document-update-handler.d.ts +6 -6
- package/lib/lsp/hydranium-document-update-handler.js +10 -10
- package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
- package/lib/testing/make-test-services.d.ts +3 -0
- package/lib/testing/make-test-services.d.ts.map +1 -1
- package/lib/testing/make-test-services.js +5 -2
- package/lib/testing/make-test-services.js.map +1 -1
- package/lib/testing/stub-hydranium-text-documents.d.ts +9 -6
- package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
- package/lib/testing/stub-hydranium-text-documents.js +19 -11
- package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
- package/package.json +5 -5
- package/src/documents/ast-document-manager.ts +4 -4
- package/src/documents/client-ids.ts +1 -1
- package/src/documents/client-session-registry.ts +55 -34
- package/src/documents/dirty-state-tracker.ts +130 -0
- package/src/documents/document-release-handler.ts +351 -0
- package/src/documents/document-release-scheduler.ts +121 -0
- package/src/documents/hydranium-text-documents.ts +395 -1034
- package/src/documents/index.ts +5 -1
- package/src/documents/language-client-shadow.ts +463 -0
- package/src/documents/text-ledger.ts +112 -0
- package/src/langium/document-builder/document-builder.ts +8 -7
- package/src/langium/integrity/integrity-service.ts +4 -4
- package/src/langium/model-service/client-session.ts +6 -6
- package/src/langium/model-service/model-events.ts +12 -11
- package/src/langium/model-service/model-service.ts +13 -4
- package/src/langium/module.ts +9 -1
- package/src/langium/workspace/file-not-found.ts +15 -3
- package/src/lsp/hydranium-document-update-handler.ts +10 -10
- package/src/testing/make-test-services.ts +7 -2
- package/src/testing/stub-hydranium-text-documents.ts +34 -21
- package/lib/documents/language-client-text-shadow.d.ts +0 -133
- package/lib/documents/language-client-text-shadow.d.ts.map +0 -1
- package/lib/documents/language-client-text-shadow.js +0 -201
- package/lib/documents/language-client-text-shadow.js.map +0 -1
- package/src/documents/language-client-text-shadow.ts +0 -223
|
@@ -37,7 +37,6 @@ import {
|
|
|
37
37
|
OptionalVersionedTextDocumentIdentifier,
|
|
38
38
|
type RequestHandler,
|
|
39
39
|
type TextDocumentChangeEvent,
|
|
40
|
-
TextDocumentContentChangeEvent as ContentChange,
|
|
41
40
|
TextDocumentEdit,
|
|
42
41
|
TextDocumentSyncKind,
|
|
43
42
|
type TextDocumentWillSaveEvent,
|
|
@@ -51,21 +50,26 @@ import {
|
|
|
51
50
|
type LanguageClientUri,
|
|
52
51
|
asLanguageClientUri,
|
|
53
52
|
DisposableCollection,
|
|
54
|
-
type Stopwatch,
|
|
55
53
|
type TextState,
|
|
56
54
|
type TextVersion,
|
|
57
|
-
textHash,
|
|
58
|
-
type Tracer,
|
|
59
55
|
STALE_VERSION,
|
|
56
|
+
type Tracer,
|
|
60
57
|
UNRECORDED_VERSION
|
|
61
58
|
} from '@hydranium/protocol';
|
|
62
59
|
import { type LogNameOptions } from '../langium/diagnostics/logger.js';
|
|
63
|
-
import { HYDRANIUM_BUILD_REASONS } from '../langium/document-builder/document-builder.js';
|
|
64
|
-
import { isConnectionGoneError } from '../util/connection-liveness.js';
|
|
65
60
|
import { LANGUAGE_CLIENT_ID } from './client-ids.js';
|
|
66
61
|
import { INTEGRITY_CLIENT_ID } from '../langium/integrity/integrity-rule.js';
|
|
67
62
|
import { type ClientSessionClosedEvent, ClientSessionRegistry, type SessionEndCause } from './client-session-registry.js';
|
|
68
|
-
import {
|
|
63
|
+
import { DefaultLanguageClientShadow, type LanguageClientChangeVerdict, type LanguageClientShadow } from './language-client-shadow.js';
|
|
64
|
+
import { DefaultTextLedger, type TextLedger } from './text-ledger.js';
|
|
65
|
+
import { isDocumentReleaseSkippedError, type ReleasedDocument } from './document-release-handler.js';
|
|
66
|
+
import { DefaultDocumentReleaseScheduler, type DocumentReleaseScheduler } from './document-release-scheduler.js';
|
|
67
|
+
import {
|
|
68
|
+
type CleanAnnouncement,
|
|
69
|
+
DefaultDirtyStateTracker,
|
|
70
|
+
type DirtyStateTracker,
|
|
71
|
+
type DocumentDirtyChangedEvent
|
|
72
|
+
} from './dirty-state-tracker.js';
|
|
69
73
|
|
|
70
74
|
/**
|
|
71
75
|
* The LSP spec's "version is intentionally unknown" for an
|
|
@@ -93,134 +97,37 @@ export interface HydraniumTextDocumentsOptions<T extends TextDocument = TextDocu
|
|
|
93
97
|
readonly configuration?: TextDocumentsConfiguration<T>;
|
|
94
98
|
/**
|
|
95
99
|
* How long a document whose last open closed because its client's
|
|
96
|
-
* connection was lost keeps its text before
|
|
97
|
-
* a client lost from the document, within this time of its own loss,
|
|
98
|
-
* cancels the
|
|
100
|
+
* connection was lost keeps its text before the store releases it. An open
|
|
101
|
+
* by a client lost from the document, within this time of its own loss,
|
|
102
|
+
* cancels the release, so a client that registers again under its id after
|
|
99
103
|
* a dropped connection finds its unsaved edits. Any other open releases the
|
|
100
104
|
* document first and then opens it as a first open does, from the text the
|
|
101
105
|
* opener supplies (an editor's own) or else from the file. Meanwhile the
|
|
102
106
|
* document counts as open for the integrity service, which therefore writes
|
|
103
107
|
* none of its unsaved text to disk. A close the client makes itself, or
|
|
104
|
-
* ending its session,
|
|
108
|
+
* ending its session, releases at once whatever this is.
|
|
105
109
|
*
|
|
106
|
-
* Defaults to 10 s. `0`
|
|
107
|
-
*
|
|
110
|
+
* Defaults to 10 s. `0` releases such a document at once as well, in the
|
|
111
|
+
* close itself rather than on a timer.
|
|
108
112
|
*/
|
|
109
|
-
readonly
|
|
113
|
+
readonly releaseGraceMs?: number;
|
|
110
114
|
}
|
|
111
115
|
|
|
112
116
|
/**
|
|
113
|
-
* The default of {@link HydraniumTextDocumentsOptions.
|
|
117
|
+
* The default of {@link HydraniumTextDocumentsOptions.releaseGraceMs}: long
|
|
114
118
|
* enough for a client whose connection dropped to register again and reopen
|
|
115
119
|
* its documents, which a data client does as soon as it has a connection.
|
|
116
120
|
*/
|
|
117
|
-
const
|
|
121
|
+
const DEFAULT_RELEASE_GRACE_MS = 10_000;
|
|
118
122
|
|
|
119
123
|
/** Delivered by {@link HydraniumTextDocuments.onDidSaveInLanguageClient}. */
|
|
120
124
|
export interface LanguageClientSavedEvent {
|
|
121
125
|
readonly uri: string;
|
|
122
126
|
}
|
|
123
127
|
|
|
124
|
-
/** Delivered by {@link HydraniumTextDocuments.
|
|
125
|
-
export interface
|
|
126
|
-
readonly uri: CanonicalUri;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** Delivered by {@link HydraniumTextDocuments.onDidChangeDirty}. */
|
|
130
|
-
export interface DocumentDirtyChangedEvent {
|
|
128
|
+
/** Delivered by {@link HydraniumTextDocuments.onDidReleaseDocument}. */
|
|
129
|
+
export interface DocumentReleasedEvent {
|
|
131
130
|
readonly uri: CanonicalUri;
|
|
132
|
-
/**
|
|
133
|
-
* The text the new answer of {@link HydraniumTextDocuments.isDirty} was
|
|
134
|
-
* decided on. The answer changes with the text, before any build, so it can
|
|
135
|
-
* name text whose model has not been sent yet. Absent when the document no
|
|
136
|
-
* longer exists, or when the build that follows its release failed.
|
|
137
|
-
*/
|
|
138
|
-
readonly text?: TextState;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* The language client's open of a document under one URI, from its didOpen to
|
|
143
|
-
* its didClose. Each URI is its own editor buffer with its own version counter,
|
|
144
|
-
* so a file reached through a symlink and its real path has one of these each.
|
|
145
|
-
*/
|
|
146
|
-
export interface LanguageClientDocumentState {
|
|
147
|
-
/** The version the client last declared for this URI. */
|
|
148
|
-
declaredVersion: number;
|
|
149
|
-
/**
|
|
150
|
-
* The version an applied versioned push moved this URI to, ahead of its
|
|
151
|
-
* echo. Apart from `declaredVersion`, whose staleness guard would drop that
|
|
152
|
-
* echo and strand its pending push.
|
|
153
|
-
*/
|
|
154
|
-
pushedVersion?: number;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* All per-URI client-facing tracking the manager keys by normalized URI,
|
|
159
|
-
* collapsed into one record so a URI's full state lives in one place and the
|
|
160
|
-
* last-client close clears every axis in a single delete. (Parallel per-axis
|
|
161
|
-
* maps are the substrate of a close-on-stale-state desync — one map can be
|
|
162
|
-
* cleared while another lingers.)
|
|
163
|
-
*
|
|
164
|
-
* Two neighbours deliberately stay separate:
|
|
165
|
-
* - The inherited `__syncedDocuments` (Langium's parsed `TextDocument` store).
|
|
166
|
-
* - {@link LanguageClientTextShadow} (`__shadow`), a self-contained,
|
|
167
|
-
* separately-tested diff/apply-verify abstraction that owns its own baseline
|
|
168
|
-
* text; folding its storage here would couple a clean utility to this record
|
|
169
|
-
* for no real gain.
|
|
170
|
-
*
|
|
171
|
-
* Returned by the `protected` {@link HydraniumTextDocuments.trackingFor}, so an
|
|
172
|
-
* override has to name it. Restating the shape structurally instead compiles
|
|
173
|
-
* until a field is added here, and then fails at the adopter rather than at the
|
|
174
|
-
* change that caused it.
|
|
175
|
-
*/
|
|
176
|
-
export interface DocumentTrackingRecord {
|
|
177
|
-
/** Author of each version, sparse-indexed by the SHARED (server-assigned) version number. */
|
|
178
|
-
readonly versionAuthors: string[];
|
|
179
|
-
/**
|
|
180
|
-
* Last version id each client declared for this document (didOpen baseline,
|
|
181
|
-
* advanced by every accepted didChange), for the per-client staleness guard.
|
|
182
|
-
* Client version ids are CLIENT-owned per LSP (Monaco numbers its own
|
|
183
|
-
* buffer) — they never leak into the shared version sequence, which the
|
|
184
|
-
* server assigns (see {@link HydraniumTextDocuments.__versionSequences}).
|
|
185
|
-
* The language client's entry is the latest any of its URIs declared; the
|
|
186
|
-
* store checks and addresses it per URI, through
|
|
187
|
-
* {@link DocumentTrackingRecord.languageClientDocuments}.
|
|
188
|
-
*/
|
|
189
|
-
readonly clientVersions: Map<string, number>;
|
|
190
|
-
/** Content staged by integrity rules for a closed document. Consumed on next open. */
|
|
191
|
-
pendingContent?: string;
|
|
192
|
-
/**
|
|
193
|
-
* Each URI the LSP textual language client opened this (canonically-keyed)
|
|
194
|
-
* document under, with that open's state. Usually one; more when the same
|
|
195
|
-
* file is opened under a symlink path and its real path. These are the
|
|
196
|
-
* egress addresses: the document is *keyed* by its canonical identity, but
|
|
197
|
-
* Monaco holds it under the URI it opened. The language client holds the
|
|
198
|
-
* document while any entry remains.
|
|
199
|
-
*/
|
|
200
|
-
languageClientDocuments?: Map<LanguageClientUri, LanguageClientDocumentState>;
|
|
201
|
-
/**
|
|
202
|
-
* The clients whose close of this document was caused by a lost connection
|
|
203
|
-
* and that have not opened it again, each with a stopwatch started at its
|
|
204
|
-
* loss on the store's `Clock`. While the document waits out the revert grace, only an
|
|
205
|
-
* open by one of them within its own
|
|
206
|
-
* {@link HydraniumTextDocumentsOptions.revertGraceMs} of that loss cancels
|
|
207
|
-
* the revert. Every such client counts, not only the last to close: one
|
|
208
|
-
* connection's sessions all end lost together, and any of them may reopen
|
|
209
|
-
* first. Without the time limit, a client that returns late inherits the
|
|
210
|
-
* unsaved text of a holder lost after it. A stopwatch rather than a `now()`
|
|
211
|
-
* reading, because a wall-clock step would otherwise expire a claim early or
|
|
212
|
-
* revive one past its grace, against a grace timer that the step leaves
|
|
213
|
-
* alone. An entry past its grace counts as any other client's and is pruned
|
|
214
|
-
* at the next open of the document.
|
|
215
|
-
*/
|
|
216
|
-
lostClients?: Map<string, Stopwatch>;
|
|
217
|
-
/**
|
|
218
|
-
* The text the server last knew the file to hold, `undefined` for no file.
|
|
219
|
-
* Set by the first open and moved by {@link HydraniumTextDocuments.updateDiskBaseline}.
|
|
220
|
-
*/
|
|
221
|
-
diskBaseline?: string;
|
|
222
|
-
/** The last answer {@link HydraniumTextDocuments.onDidChangeDirty} announced. */
|
|
223
|
-
dirty?: boolean;
|
|
224
131
|
}
|
|
225
132
|
|
|
226
133
|
/**
|
|
@@ -250,29 +157,6 @@ export type RepairCommit<T extends TextDocument> =
|
|
|
250
157
|
| { readonly status: 'stale' }
|
|
251
158
|
| { readonly status: 'not-open' };
|
|
252
159
|
|
|
253
|
-
/**
|
|
254
|
-
* Where a URI's shared version sequence left off while no client holds it —
|
|
255
|
-
* started by its first build, stepped by builds of changed text, written at
|
|
256
|
-
* last-client close, and consulted at the next open so the sequence CONTINUES
|
|
257
|
-
* instead of restarting at whatever version id the opening client declares. One entry
|
|
258
|
-
* of {@link HydraniumTextDocuments.__versionSequences} — a `protected` field, so
|
|
259
|
-
* a subclass reading the map has to name what it holds.
|
|
260
|
-
*/
|
|
261
|
-
export interface VersionSequence {
|
|
262
|
-
/** The shared version of the text last closed or built. */
|
|
263
|
-
readonly version: number;
|
|
264
|
-
/** {@link textHash} of that text. */
|
|
265
|
-
readonly contentHash: string;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* Upper bound on {@link HydraniumTextDocuments.__pendingPushes} entries
|
|
270
|
-
* per URI. Echoes normally return within milliseconds and consume their
|
|
271
|
-
* entry; a queue this deep means the client stopped echoing — cap instead
|
|
272
|
-
* of leaking.
|
|
273
|
-
*/
|
|
274
|
-
const PENDING_ECHO_CAP = 32;
|
|
275
|
-
|
|
276
160
|
/**
|
|
277
161
|
* The one field of `vscode-languageserver`'s `Connection` this class has to
|
|
278
162
|
* reach that its public type does not declare. Named here rather than cast
|
|
@@ -283,215 +167,134 @@ interface ConnectionWithTextDocumentSync {
|
|
|
283
167
|
}
|
|
284
168
|
|
|
285
169
|
/**
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
* `undefined` only when that buffer is unknown — the client never declared
|
|
299
|
-
* one, or a rejection invalidated what was tracked. The echo is then
|
|
300
|
-
* reconstructed against the synced text, which is sound only for a
|
|
301
|
-
* position-independent (full-text) change.
|
|
302
|
-
*/
|
|
303
|
-
readonly before: string | undefined;
|
|
304
|
-
/** {@link textHash} of the text this push moves the client to. */
|
|
305
|
-
readonly afterHash: string;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* What an incoming language-client change turns out to be once reconstructed
|
|
310
|
-
* against the buffer its ranges address — the return of
|
|
311
|
-
* {@link HydraniumTextDocuments.classifyLanguageClientChange}. That method is
|
|
312
|
-
* `protected`, so an override has to name every arm it can return.
|
|
313
|
-
*/
|
|
314
|
-
export type LanguageClientChangeOrigin =
|
|
315
|
-
/** The client is reporting a text we pushed it. The synced document is already there. */
|
|
316
|
-
| { readonly kind: 'echo' }
|
|
317
|
-
/**
|
|
318
|
-
* The client's buffer holds a text we did not push it — a keystroke that
|
|
319
|
-
* raced a push, or an edit to a buffer the store has already been written
|
|
320
|
-
* past. The reconstructed text is what it now holds, and is authoritative.
|
|
321
|
-
*/
|
|
322
|
-
| { readonly kind: 'divergent'; readonly text: string }
|
|
323
|
-
/**
|
|
324
|
-
* The change carries ranges and no known text addresses them, so no
|
|
325
|
-
* reconstruction is offered. Adopting one anyway splices the document and
|
|
326
|
-
* stores an edit nobody made; dropping costs at most the one keystroke the
|
|
327
|
-
* client still holds and the next push contradicts.
|
|
328
|
-
*/
|
|
329
|
-
| { readonly kind: 'unreconstructable' };
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* Whether `err` reports that the file of `target` does not exist, as a Node
|
|
333
|
-
* file system and the framework's providers do: code `ENOENT`, with `target`'s
|
|
334
|
-
* path. A missing file of another document is a different failure.
|
|
335
|
-
*/
|
|
336
|
-
function isFileNotFound(err: unknown, target: URI): boolean {
|
|
337
|
-
if (typeof err !== 'object' || err === null || !('code' in err) || err.code !== 'ENOENT') {
|
|
338
|
-
return false;
|
|
339
|
-
}
|
|
340
|
-
return !('path' in err) || err.path === target.fsPath;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
* Multi-client text-document tracking on top of Langium's `NormalizedTextDocuments`.
|
|
170
|
+
* The one text store every head writes to, on top of Langium's
|
|
171
|
+
* `NormalizedTextDocuments`, and the LSP text-sync endpoint: every
|
|
172
|
+
* `textDocument/*` notification and every `workspace/applyEdit` push goes
|
|
173
|
+
* through here.
|
|
174
|
+
*
|
|
175
|
+
* Each open, change, close and save is one synchronous transition over the
|
|
176
|
+
* held document and the collaborators its `create…` methods build. A
|
|
177
|
+
* collaborator that defers its part lets a listener of the transition's event
|
|
178
|
+
* read state from before it. A document no client holds any more is
|
|
179
|
+
* released to the `DocumentReleaseHandler` slot, after
|
|
180
|
+
* {@link HydraniumTextDocumentsOptions.releaseGraceMs} when its last client's
|
|
181
|
+
* connection was lost.
|
|
345
182
|
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
* root that records a version, starts at its opener's declared id: no version
|
|
357
|
-
* was handed out for it.
|
|
358
|
-
* - Version-author history so each edit is attributable to its originating client.
|
|
359
|
-
* - The revert to disk once no client has a document open, for every head
|
|
360
|
-
* ({@link revertToDisk}), deferred by
|
|
361
|
-
* {@link HydraniumTextDocumentsOptions.revertGraceMs} after a lost connection.
|
|
362
|
-
* - A disk baseline per open document, and whether its text differs from it
|
|
363
|
-
* ({@link isDirty}).
|
|
364
|
-
* - Pending-content staging used by the integrity service to thread corrections
|
|
365
|
-
* through `workspace/applyEdit` cycles for currently-closed documents.
|
|
366
|
-
* - `didOpen` notifications arriving over the LSP connection wait on the
|
|
367
|
-
* workspace-ready promise, so a client's first open cannot race workspace
|
|
368
|
-
* discovery. Direct {@link notifyDidOpenTextDocument} calls (the non-LSP
|
|
369
|
-
* heads) do not pass that gate — their caller owns the ordering.
|
|
183
|
+
* Client-declared version ids feed only the per-client staleness guard and
|
|
184
|
+
* never a running shared sequence: the two count different things, and
|
|
185
|
+
* splicing them lets versions drift past base-version gate holders. A URI
|
|
186
|
+
* with no ledger record and no built root starts at its opener's declared id:
|
|
187
|
+
* no version was handed out for it.
|
|
188
|
+
*
|
|
189
|
+
* `didOpen` notifications arriving over the LSP connection wait on the
|
|
190
|
+
* workspace-ready promise, so a client's first open cannot race workspace
|
|
191
|
+
* discovery. Direct {@link notifyDidOpenTextDocument} calls (the non-LSP
|
|
192
|
+
* heads) do not pass that gate — their caller owns the ordering.
|
|
370
193
|
*/
|
|
371
194
|
export class HydraniumTextDocuments<T extends TextDocument = TextDocument> extends NormalizedTextDocuments<T> {
|
|
372
|
-
/**
|
|
373
|
-
|
|
374
|
-
* canonical URI. One record per URI, so dropping it clears every axis at
|
|
375
|
-
* once. Which client has the document open is kept apart, in
|
|
376
|
-
* {@link __sessions}.
|
|
377
|
-
*/
|
|
378
|
-
protected __documents = new Map<CanonicalUri, DocumentTrackingRecord>();
|
|
195
|
+
/** Content staged by integrity rules for a document no client holds, consumed by its first open. */
|
|
196
|
+
protected readonly __pendingContent = new Map<CanonicalUri, string>();
|
|
379
197
|
|
|
380
198
|
/**
|
|
381
|
-
* Which client has which document open,
|
|
382
|
-
* sessions
|
|
199
|
+
* Which client has which document open, which client ids are registered
|
|
200
|
+
* sessions, and each client's declared version, the staleness guard's
|
|
201
|
+
* baseline. Every open-state predicate on this class reads it, so an open
|
|
383
202
|
* recorded anywhere else is invisible to the last-close transition.
|
|
384
203
|
*/
|
|
385
204
|
protected readonly __sessions = new ClientSessionRegistry();
|
|
386
205
|
|
|
387
|
-
/**
|
|
388
|
-
* Per-URI shared-version continuity across close/reopen cycles
|
|
389
|
-
* ({@link VersionSequence}), kept for every document a build or a client
|
|
390
|
-
* gave the store, and consulted by the next first-client open. A URI
|
|
391
|
-
* with none, and no root that records a version, starts at its opener's
|
|
392
|
-
* declared version.
|
|
393
|
-
* DELIBERATELY outside {@link DocumentTrackingRecord}: that record is deleted on last close, while the version sequence must
|
|
394
|
-
* survive it — the shared version is a server-owned, monotonic,
|
|
395
|
-
* advances-iff-content-changes counter that never resets while the server
|
|
396
|
-
* lives. That invariant is what makes an optimistic base-version gate
|
|
397
|
-
* sound: "version unchanged ⇔ content unchanged", with no false conflicts
|
|
398
|
-
* from close/reopen version resets and no false passes from a reopened
|
|
399
|
-
* sequence coincidentally landing on a stale writer's number.
|
|
400
|
-
*
|
|
401
|
-
* Never pruned, not even when the file is deleted: a recreated file
|
|
402
|
-
* restarting at `0` would let a write based on the deleted text pass. Two
|
|
403
|
-
* small values per URI ever built, however often it changes.
|
|
404
|
-
*/
|
|
405
|
-
protected readonly __versionSequences = new Map<CanonicalUri, VersionSequence>();
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* Released documents last announced dirty. Their clean flip waits for the
|
|
409
|
-
* revert, so it carries the reverted text's version; a first open before the
|
|
410
|
-
* revert takes the entry over, and its own dirty answer decides the flip.
|
|
411
|
-
* Each release enters a token of its own, so the revert of an earlier
|
|
412
|
-
* release cannot announce a later one clean before that one's revert.
|
|
413
|
-
*/
|
|
414
|
-
protected readonly __releasedDirty = new Map<CanonicalUri, object>();
|
|
415
|
-
|
|
416
|
-
/** Per held document, the {@link textHash} of its text at the version it was taken. */
|
|
417
|
-
protected readonly __textHashes = new WeakMap<TextDocument, { readonly version: number; readonly hash: string }>();
|
|
418
|
-
|
|
419
|
-
/**
|
|
420
|
-
* Texts pushed to the LSP textual language client via
|
|
421
|
-
* {@link applyEditToLanguageClient} whose echoes have not come back yet
|
|
422
|
-
* ({@link PendingLanguageClientPush}), keyed like the shadow by language-client URI.
|
|
423
|
-
* Outbound pushes and inbound echoes are uncorrelated on the wire; this
|
|
424
|
-
* FIFO is the explicit correlation, and it is what
|
|
425
|
-
* {@link classifyLanguageClientChange} reconstructs against.
|
|
426
|
-
*
|
|
427
|
-
* **Each entry keeps the client's PRE-push text, not only a hash of the
|
|
428
|
-
* post-push one.** A hash alone can classify a full-text echo, whose
|
|
429
|
-
* application is a no-op either way — and that is all it ever classified,
|
|
430
|
-
* because a conforming client echoes INCREMENTAL ranges keyed to its
|
|
431
|
-
* previous buffer. Those ranges cannot be applied to the synced text (which
|
|
432
|
-
* the authored write already advanced) and cannot be reconstructed without
|
|
433
|
-
* the baseline: the line they insert lands twice, validates cleanly, and
|
|
434
|
-
* compounds on every later edit.
|
|
435
|
-
*
|
|
436
|
-
* Lifecycle: entries are consumed by the matching echo (together with any
|
|
437
|
-
* older entries it supersedes), and the whole queue drops when the client
|
|
438
|
-
* stops being a pure mirror — a divergent change, an `applyEdit`
|
|
439
|
-
* failure/rejection (shadow invalidation), a close, or an explicit
|
|
440
|
-
* shadow (re)baseline. {@link PENDING_ECHO_CAP} bounds the queue against
|
|
441
|
-
* a pathological echo that never arrives; the memory cost until then is
|
|
442
|
-
* one pre-push text per in-flight push, for milliseconds.
|
|
443
|
-
*/
|
|
444
|
-
protected readonly __pendingPushes = new Map<LanguageClientUri, PendingLanguageClientPush[]>();
|
|
445
|
-
|
|
446
|
-
/**
|
|
447
|
-
* Tracked text content per URI for the LSP textual language client (Monaco / VS Code).
|
|
448
|
-
* Owned here so {@link applyEditToLanguageClient} can compute minimal `workspace/applyEdit`
|
|
449
|
-
* diffs instead of full-document replaces (5–20 s → <500 ms on 20-30 KB YAML diagrams).
|
|
450
|
-
*
|
|
451
|
-
* Auto-tracked from the multi-client text-document events: open / change / close of
|
|
452
|
-
* the language client (re)baseline or invalidate the shadow. Other client ids
|
|
453
|
-
* (form editor, GLSP, integrity) do NOT touch the shadow — only what Monaco believes
|
|
454
|
-
* it has matters for the diff.
|
|
455
|
-
*
|
|
456
|
-
* Apply-verify safety net is built in: if the diff doesn't reconstruct `newText`
|
|
457
|
-
* exactly, {@link LanguageClientTextShadow.computeEdits} falls back to a full-range
|
|
458
|
-
* replace and invokes the `onFallback` callback — a diff regression becomes log
|
|
459
|
-
* noise, not a 0-byte save.
|
|
460
|
-
*
|
|
461
|
-
* Assigned in the constructor body so the `onFallback` callback can capture
|
|
462
|
-
* `this.logger` after the parameter-property assignment has run (field
|
|
463
|
-
* initializers fire BEFORE parameter-property assignment in TS).
|
|
464
|
-
*/
|
|
465
|
-
protected readonly __shadow: LanguageClientTextShadow;
|
|
466
|
-
|
|
467
206
|
protected readonly tracer: Tracer;
|
|
468
207
|
protected readonly configuration: TextDocumentsConfiguration<T>;
|
|
469
|
-
|
|
470
|
-
protected
|
|
471
|
-
protected
|
|
208
|
+
protected __textLedger: TextLedger | undefined;
|
|
209
|
+
protected __languageClientShadow: LanguageClientShadow | undefined;
|
|
210
|
+
protected __dirtyStateTracker: DirtyStateTracker | undefined;
|
|
211
|
+
protected __documentReleaseScheduler: DocumentReleaseScheduler | undefined;
|
|
212
|
+
protected readonly documentReleasedEmitter = new Emitter<DocumentReleasedEvent>();
|
|
472
213
|
protected readonly languageClientSavedEmitter = new Emitter<LanguageClientSavedEvent>();
|
|
473
|
-
protected readonly dirtyChangedEmitter = new Emitter<DocumentDirtyChangedEvent>();
|
|
474
214
|
|
|
475
215
|
constructor(
|
|
476
216
|
protected services: ServerSharedServices,
|
|
477
|
-
options: HydraniumTextDocumentsOptions<T> = {}
|
|
217
|
+
protected readonly options: HydraniumTextDocumentsOptions<T> = {}
|
|
478
218
|
) {
|
|
479
219
|
const configuration = options.configuration ?? (TextDocument as unknown as TextDocumentsConfiguration<T>);
|
|
480
220
|
super(configuration);
|
|
481
221
|
this.configuration = configuration;
|
|
482
222
|
this.tracer = services.Tracer.for(options.logName ?? 'TextDocuments').trace('instantiated');
|
|
483
|
-
this.__shadow = new LanguageClientTextShadow(
|
|
484
|
-
(uri, reason) => this.tracer.with(uri).warn(`Diff apply-verify fallback (${reason}) — using full-document replace`),
|
|
485
|
-
this
|
|
486
|
-
);
|
|
487
|
-
this.revertGraceMs = options.revertGraceMs ?? DEFAULT_REVERT_GRACE_MS;
|
|
488
|
-
this.onDidCloseLastOpen(event => void this.revertToDisk(event.uri));
|
|
489
223
|
}
|
|
490
224
|
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
|
|
494
|
-
|
|
225
|
+
// Each collaborator is built on first use, after every constructor has run,
|
|
226
|
+
// so a create method may read its subclass's fields and the other collaborators.
|
|
227
|
+
|
|
228
|
+
protected get textLedger(): TextLedger {
|
|
229
|
+
return (this.__textLedger ??= this.createTextLedger());
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
protected get languageClientShadow(): LanguageClientShadow {
|
|
233
|
+
return (this.__languageClientShadow ??= this.createLanguageClientShadow());
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
protected get dirtyStateTracker(): DirtyStateTracker {
|
|
237
|
+
return (this.__dirtyStateTracker ??= this.createDirtyStateTracker());
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
protected get documentReleaseScheduler(): DocumentReleaseScheduler {
|
|
241
|
+
return (this.__documentReleaseScheduler ??= this.createDocumentReleaseScheduler());
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
protected createTextLedger(): TextLedger {
|
|
245
|
+
return new DefaultTextLedger();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
protected createLanguageClientShadow(): LanguageClientShadow {
|
|
249
|
+
return new DefaultLanguageClientShadow(this, this.tracer);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
protected createDirtyStateTracker(): DirtyStateTracker {
|
|
253
|
+
return new DefaultDirtyStateTracker(this.textLedger);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
protected createDocumentReleaseScheduler(): DocumentReleaseScheduler {
|
|
257
|
+
return new DefaultDocumentReleaseScheduler(this.services.Clock, this.options.releaseGraceMs ?? DEFAULT_RELEASE_GRACE_MS);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Hold `document` as the text of `key`, a new version authored by `author`. */
|
|
261
|
+
protected commitText(key: CanonicalUri, document: T, author: string): void {
|
|
262
|
+
this.__syncedDocuments.set(key, document);
|
|
263
|
+
this.setAuthor(key, document.version, author);
|
|
264
|
+
this.dirtyStateTracker.refreshDirty(key, document);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Apply `changes` to `document`, the held text of `key`, and hold the
|
|
269
|
+
* result. The version steps only when the text changes, which is what a
|
|
270
|
+
* base-version gate relies on: unchanged text keeps its version and its
|
|
271
|
+
* author. The new text is known only once the changes are applied, so they
|
|
272
|
+
* go in at a tentative step that an identical result rolls back.
|
|
273
|
+
*/
|
|
274
|
+
protected commitChange(
|
|
275
|
+
key: CanonicalUri,
|
|
276
|
+
document: T,
|
|
277
|
+
changes: TextDocumentContentChangeEvent[],
|
|
278
|
+
author: string
|
|
279
|
+
): { document: T; changed: boolean } {
|
|
280
|
+
const previousText = document.getText();
|
|
281
|
+
const version = document.version;
|
|
282
|
+
let next = this.update(document, changes, version + 1);
|
|
283
|
+
const changed = next.getText() !== previousText;
|
|
284
|
+
if (changed) {
|
|
285
|
+
this.commitText(key, next, author);
|
|
286
|
+
} else {
|
|
287
|
+
// An empty-changes update only re-stamps the version.
|
|
288
|
+
next = this.update(next, [], version);
|
|
289
|
+
this.__syncedDocuments.set(key, next);
|
|
290
|
+
}
|
|
291
|
+
return { document: next, changed };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// The configuration's factories. Every document the store makes or changes
|
|
295
|
+
// goes through them, so an override sees each call: the store's own writes,
|
|
296
|
+
// callers outside the didOpen/didChange flow, and the shadow's throwaway
|
|
297
|
+
// probes, at version 0 under a client URI.
|
|
495
298
|
|
|
496
299
|
public create(uri: string, languageId: string, version: number, content: string): T {
|
|
497
300
|
return this.configuration.create(uri, languageId, version, content);
|
|
@@ -607,20 +410,19 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
607
410
|
const uri = this.documentKey(td.uri);
|
|
608
411
|
let document = this.__syncedDocuments.get(uri);
|
|
609
412
|
if (document !== undefined) {
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
//
|
|
613
|
-
//
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
//
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
const lastSeen = languageClientDocument?.declaredVersion ?? record.clientVersions.get(clientId) ?? document.version;
|
|
413
|
+
// Client version ids are the client's own, so compared against what
|
|
414
|
+
// that client declared, never against the shared version: an authored
|
|
415
|
+
// write advances that without the client knowing, and gating on it
|
|
416
|
+
// drops real edits. The editor is checked per URI, since each is its
|
|
417
|
+
// own buffer; its client-wide entry is only the fallback for a URI it
|
|
418
|
+
// never opened, and may name another URI's buffer. A client with no
|
|
419
|
+
// baseline falls back to the shared version.
|
|
420
|
+
const clientUri = this.toLanguageClientUri(td.uri);
|
|
421
|
+
const editor = clientId === LANGUAGE_CLIENT_ID;
|
|
422
|
+
const lastSeen =
|
|
423
|
+
(editor ? this.languageClientShadow.declaredVersion(uri, clientUri) : undefined) ??
|
|
424
|
+
this.__sessions.clientVersionOf(uri, clientId) ??
|
|
425
|
+
document.version;
|
|
624
426
|
if (lastSeen >= td.version) {
|
|
625
427
|
// Distinguish "already at this version" (common: an echo from the client that triggered
|
|
626
428
|
// the update) from "incoming version older than ours" (stale race).
|
|
@@ -629,76 +431,46 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
629
431
|
this.logUri(uri, `Ignore update by ${this.formatClientId(clientId)}: ${reason}`, 'debug');
|
|
630
432
|
return;
|
|
631
433
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
//
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
if (origin?.kind === 'echo') {
|
|
647
|
-
// The client is reporting a text we pushed it, so the synced
|
|
648
|
-
// document is ALREADY there and the changes must not be applied a
|
|
649
|
-
// second time. Nothing is minted and no rebuild fires. The
|
|
650
|
-
// per-client baseline advanced above (the client's ids keep
|
|
651
|
-
// counting); the shadow is deliberately NOT touched — it
|
|
652
|
-
// optimistically tracks the NEWEST pushed text, and dragging it
|
|
653
|
-
// back would make the next outbound diff wrong against what the
|
|
654
|
-
// client actually holds.
|
|
434
|
+
this.__sessions.setClientVersion(uri, clientId, td.version);
|
|
435
|
+
|
|
436
|
+
// An editor change is keyed to the buffer the editor holds, which is
|
|
437
|
+
// the synced text only while the two agree — an authored write
|
|
438
|
+
// advances the synced text without the editor knowing, and a push in
|
|
439
|
+
// flight moves the editor without the store knowing.
|
|
440
|
+
const verdict: LanguageClientChangeVerdict = editor
|
|
441
|
+
? this.languageClientShadow.acceptChange(uri, clientUri, td.version, document, changes)
|
|
442
|
+
: { kind: 'direct' };
|
|
443
|
+
if (verdict.kind === 'echo') {
|
|
444
|
+
// The synced document is already there, so the changes must not be
|
|
445
|
+
// applied a second time: nothing is minted and no rebuild fires. The
|
|
446
|
+
// shadow keeps the newest pushed text, which is what the next
|
|
447
|
+
// outbound diff has to be keyed to.
|
|
655
448
|
this.logUri(uri, `Skip rebuild: echo of a server-authored push (client version ${td.version})`, 'debug');
|
|
656
449
|
return;
|
|
657
450
|
}
|
|
658
|
-
if (
|
|
451
|
+
if (verdict.kind === 'unreconstructable') {
|
|
659
452
|
this.tracer.with(uri).warn(`Drop change: no known client buffer for its ranges (client version ${td.version})`);
|
|
660
453
|
return;
|
|
661
454
|
}
|
|
662
455
|
|
|
663
|
-
//
|
|
664
|
-
//
|
|
665
|
-
// only known after applying the (possibly incremental) changes, so
|
|
666
|
-
// apply at a tentative +1 and roll the version back on an identical
|
|
667
|
-
// result (an empty-changes update only re-stamps the version).
|
|
668
|
-
//
|
|
669
|
-
// A divergent change is applied as its RECONSTRUCTED text rather than
|
|
670
|
-
// as its own ranges: those ranges address the client's own buffer, so
|
|
456
|
+
// A divergent change is applied as its reconstructed text rather than
|
|
457
|
+
// as its own ranges: those ranges address the editor's own buffer, so
|
|
671
458
|
// applying them here would splice the wrong lines.
|
|
672
|
-
const
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
if (changed) {
|
|
681
|
-
this.setAuthor(uri, document.version, clientId);
|
|
682
|
-
this.refreshDirty(uri);
|
|
683
|
-
}
|
|
684
|
-
if (clientId === LANGUAGE_CLIENT_ID) {
|
|
685
|
-
// Monaco just told us about its new content; record it so the next outbound
|
|
686
|
-
// applyEditToLanguageClient diffs against the right baseline. Keyed by the
|
|
687
|
-
// language-client URI (what Monaco holds), not the canonical document key.
|
|
688
|
-
this.__shadow.set(this.toLanguageClientUri(td.uri), document.getText());
|
|
689
|
-
// Content-identical echo: the language client is echoing text we already had
|
|
690
|
-
// (e.g. Monaco re-emitting a server-pushed applyEditToLanguageClient). The model is
|
|
691
|
-
// unchanged, so skip the rebuild. The per-client baseline + shadow still advanced
|
|
692
|
-
// above so future staleness checks and diffs are correct. Restricted to the
|
|
693
|
-
// language client: a ModelService-authored change is never skipped.
|
|
694
|
-
if (!changed) {
|
|
459
|
+
const committed = this.commitChange(uri, document, verdict.kind === 'direct' ? changes : [{ text: verdict.text }], clientId);
|
|
460
|
+
document = committed.document;
|
|
461
|
+
if (editor) {
|
|
462
|
+
this.languageClientShadow.setClientText(clientUri, document.getText());
|
|
463
|
+
// Content-identical echo: the editor is echoing text we already had.
|
|
464
|
+
// The model is unchanged, so skip the rebuild. Restricted to the
|
|
465
|
+
// editor: a ModelService-authored change is never skipped.
|
|
466
|
+
if (!committed.changed) {
|
|
695
467
|
this.logUri(uri, `Skip rebuild: content unchanged (echo at client version ${td.version})`, 'debug');
|
|
696
468
|
return;
|
|
697
469
|
}
|
|
698
470
|
}
|
|
699
471
|
this.log(
|
|
700
472
|
document.uri,
|
|
701
|
-
`Update to version ${document.version} by ${this.formatClientId(clientId)}${changed ? '' : ' (content unchanged)'}`
|
|
473
|
+
`Update to version ${document.version} by ${this.formatClientId(clientId)}${committed.changed ? '' : ' (content unchanged)'}`
|
|
702
474
|
);
|
|
703
475
|
this.__onDidChangeContent.fire(Object.freeze({ document, clientId }));
|
|
704
476
|
}
|
|
@@ -723,17 +495,13 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
723
495
|
*/
|
|
724
496
|
applyContentChange(uri: DocumentUri, text: string, clientId: string): TextVersion {
|
|
725
497
|
const key = this.documentKey(uri);
|
|
726
|
-
|
|
727
|
-
if (
|
|
498
|
+
const synced = this.__syncedDocuments.get(key);
|
|
499
|
+
if (synced === undefined) {
|
|
728
500
|
throw new Error(`Document ${uri} is not open for content changes`);
|
|
729
501
|
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
document
|
|
733
|
-
this.__syncedDocuments.set(key, document);
|
|
734
|
-
this.setAuthor(key, document.version, clientId);
|
|
735
|
-
this.refreshDirty(key);
|
|
736
|
-
}
|
|
502
|
+
// Unchanged text calls no update: a configuration may return a new document for one.
|
|
503
|
+
const { document, changed } =
|
|
504
|
+
synced.getText() === text ? { document: synced, changed: false } : this.commitChange(key, synced, [{ text }], clientId);
|
|
737
505
|
this.log(
|
|
738
506
|
document.uri,
|
|
739
507
|
`Update to version ${document.version} by ${this.formatClientId(clientId)}${changed ? '' : ' (content unchanged)'}`
|
|
@@ -744,8 +512,8 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
744
512
|
|
|
745
513
|
/**
|
|
746
514
|
* Close `clientId`'s open of the document. When it was the last open, the
|
|
747
|
-
* document is released
|
|
748
|
-
* close, after {@link HydraniumTextDocumentsOptions.
|
|
515
|
+
* document is released, at once or, for a `'lost'`
|
|
516
|
+
* close, after {@link HydraniumTextDocumentsOptions.releaseGraceMs}.
|
|
749
517
|
*/
|
|
750
518
|
public notifyDidCloseTextDocument(
|
|
751
519
|
event: DidCloseTextDocumentParams,
|
|
@@ -754,38 +522,30 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
754
522
|
): void {
|
|
755
523
|
const uri = this.documentKey(event.textDocument.uri);
|
|
756
524
|
if (clientId === LANGUAGE_CLIENT_ID) {
|
|
757
|
-
const
|
|
758
|
-
|
|
759
|
-
if (
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
this.
|
|
525
|
+
const clientUri = this.toLanguageClientUri(event.textDocument.uri);
|
|
526
|
+
// A close under a URI the editor never opened the document under ends its hold.
|
|
527
|
+
if (this.languageClientShadow.isOpen(uri, clientUri)) {
|
|
528
|
+
this.languageClientShadow.removeOpen(uri, clientUri);
|
|
529
|
+
} else {
|
|
530
|
+
this.languageClientShadow.removeAllOpens(uri);
|
|
531
|
+
}
|
|
532
|
+
// Another URI still holds the document, so only this one's buffer goes.
|
|
533
|
+
if (this.languageClientShadow.isOpen(uri)) {
|
|
763
534
|
return;
|
|
764
535
|
}
|
|
765
536
|
}
|
|
766
537
|
if (!this.__sessions.removeOpen(uri, clientId)) {
|
|
767
538
|
return;
|
|
768
539
|
}
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
if (record && cause === 'lost') {
|
|
772
|
-
(record.lostClients ??= new Map()).set(clientId, this.services.Clock.stopwatch());
|
|
540
|
+
if (cause === 'lost') {
|
|
541
|
+
this.documentReleaseScheduler.recordLoss(uri, clientId);
|
|
773
542
|
}
|
|
774
543
|
const syncedDocument = this.__syncedDocuments.get(uri);
|
|
775
544
|
if (syncedDocument !== undefined) {
|
|
776
545
|
this.log(syncedDocument.uri, `Closed synced document: ${syncedDocument.version} by ${this.formatClientId(clientId)}`);
|
|
777
546
|
this.__onDidClose.fire(Object.freeze({ document: syncedDocument, clientId }));
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
// Monaco closed the document; drop the shadow baselined under the URI
|
|
781
|
-
// it held. (If this was the last client the whole record is deleted on
|
|
782
|
-
// release.)
|
|
783
|
-
const droppedUri = this.toLanguageClientUri(event.textDocument.uri);
|
|
784
|
-
this.__shadow.invalidate(droppedUri);
|
|
785
|
-
this.__pendingPushes.delete(droppedUri);
|
|
786
|
-
}
|
|
787
|
-
if (!this.__sessions.isOpen(uri)) {
|
|
788
|
-
if (cause === 'lost' && this.revertGraceMs > 0) {
|
|
547
|
+
if (!this.isOpenInAnyClient(uri)) {
|
|
548
|
+
if (cause === 'lost') {
|
|
789
549
|
this.deferRelease(uri);
|
|
790
550
|
} else {
|
|
791
551
|
this.releaseDocument(uri);
|
|
@@ -795,58 +555,36 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
795
555
|
}
|
|
796
556
|
|
|
797
557
|
/**
|
|
798
|
-
* Keep the document, text and all, for the
|
|
558
|
+
* Keep the document, text and all, for the release grace, then release it.
|
|
799
559
|
* The document stays in the store meanwhile, so a lost client that opens it
|
|
800
560
|
* again within its own grace attaches to it and finds its unsaved text
|
|
801
|
-
* rather than reading disk; see {@link
|
|
561
|
+
* rather than reading disk; see {@link resolveDeferredRelease} for any other
|
|
802
562
|
* open.
|
|
803
563
|
*/
|
|
804
564
|
protected deferRelease(uri: CanonicalUri): void {
|
|
805
|
-
this.
|
|
806
|
-
|
|
807
|
-
this.__sessions.cancelRevert(uri);
|
|
808
|
-
if (!this.__sessions.isOpen(uri)) {
|
|
565
|
+
this.documentReleaseScheduler.defer(uri, () => {
|
|
566
|
+
if (!this.isOpenInAnyClient(uri)) {
|
|
809
567
|
this.releaseDocument(uri);
|
|
810
568
|
}
|
|
811
|
-
}
|
|
812
|
-
this.
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
/**
|
|
816
|
-
* Resolve the pending revert of `uri` for an open by `clientId`. An open by a
|
|
817
|
-
* client lost from the document within its grace cancels the revert, and
|
|
818
|
-
* the client finds its unsaved text. Any other open releases the document
|
|
819
|
-
* first, so it opens as a first open does: cancelling for every open hands
|
|
820
|
-
* a lost client's unsaved text to whoever opens next, a reloaded page or an
|
|
821
|
-
* editor, with nothing marking it unsaved.
|
|
822
|
-
*/
|
|
823
|
-
protected resolvePendingRevert(uri: CanonicalUri, clientId: string): void {
|
|
824
|
-
const record = this.__documents.get(uri);
|
|
825
|
-
if (record) {
|
|
826
|
-
this.pruneLostClients(record);
|
|
827
|
-
}
|
|
828
|
-
const returning = record?.lostClients?.delete(clientId) ?? false;
|
|
829
|
-
if (!this.__sessions.isRevertPending(uri)) {
|
|
830
|
-
return;
|
|
831
|
-
}
|
|
832
|
-
this.__sessions.cancelRevert(uri);
|
|
833
|
-
if (!returning) {
|
|
834
|
-
this.releaseDocument(uri);
|
|
569
|
+
});
|
|
570
|
+
if (this.documentReleaseScheduler.isDeferred(uri)) {
|
|
571
|
+
this.log(uri, `No client left; release deferred for ${this.documentReleaseScheduler.graceMs} ms (connection lost)`);
|
|
835
572
|
}
|
|
836
573
|
}
|
|
837
574
|
|
|
838
|
-
/**
|
|
839
|
-
protected
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
record.lostClients?.delete(clientId);
|
|
843
|
-
}
|
|
575
|
+
/** Resolve a deferred release of `uri` for an open by `clientId`: a release the scheduler decides on runs now. */
|
|
576
|
+
protected resolveDeferredRelease(uri: CanonicalUri, clientId: string): void {
|
|
577
|
+
if (this.documentReleaseScheduler.resolveOpen(uri, clientId) === 'release') {
|
|
578
|
+
this.releaseDocument(uri);
|
|
844
579
|
}
|
|
845
580
|
}
|
|
846
581
|
|
|
847
582
|
/**
|
|
848
|
-
* Drop the document no client has open any more,
|
|
849
|
-
* {@link
|
|
583
|
+
* Drop the document no client has open any more, announce it on
|
|
584
|
+
* {@link onDidReleaseDocument}, then hand it to the
|
|
585
|
+
* `DocumentReleaseHandler` slot: its listeners, such as the update
|
|
586
|
+
* handler dropping a change it still holds back, act before any build the
|
|
587
|
+
* handler runs.
|
|
850
588
|
*/
|
|
851
589
|
protected releaseDocument(uri: CanonicalUri): void {
|
|
852
590
|
const syncedDocument = this.__syncedDocuments.get(uri);
|
|
@@ -854,172 +592,74 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
854
592
|
return;
|
|
855
593
|
}
|
|
856
594
|
this.log(syncedDocument.uri, `Remove synced document: ${syncedDocument.version} (no client left)`);
|
|
857
|
-
//
|
|
858
|
-
//
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
this.
|
|
862
|
-
version: syncedDocument.version,
|
|
863
|
-
contentHash: this.heldTextHash(syncedDocument)
|
|
864
|
-
});
|
|
865
|
-
if (this.__documents.get(uri)?.dirty === true) {
|
|
866
|
-
this.__releasedDirty.set(uri, {});
|
|
867
|
-
}
|
|
595
|
+
// The next open continues the sequence instead of restarting at the
|
|
596
|
+
// reopening client's declared id.
|
|
597
|
+
this.textLedger.record(uri, syncedDocument);
|
|
598
|
+
this.textLedger.clearAuthors(uri);
|
|
599
|
+
const cleanAnnouncement = this.dirtyStateTracker.release(uri);
|
|
868
600
|
this.__syncedDocuments.delete(uri);
|
|
869
|
-
|
|
870
|
-
//
|
|
871
|
-
this.
|
|
872
|
-
this.
|
|
601
|
+
this.__sessions.forgetClientVersions(uri);
|
|
602
|
+
// A stage is for a first open; one released unconsumed is stale.
|
|
603
|
+
this.__pendingContent.delete(uri);
|
|
604
|
+
this.documentReleaseScheduler.clearLosses(uri);
|
|
605
|
+
this.documentReleasedEmitter.fire(Object.freeze({ uri }));
|
|
606
|
+
this.handOverRelease(this.toReleasedDocument(uri), cleanAnnouncement);
|
|
873
607
|
}
|
|
874
608
|
|
|
875
609
|
/**
|
|
876
|
-
*
|
|
877
|
-
*
|
|
878
|
-
*
|
|
879
|
-
*
|
|
880
|
-
*
|
|
881
|
-
* buffer, a file never saved, or one deleted meanwhile — is removed
|
|
882
|
-
* from the workspace. A `virtual:` document survives, since the framework's
|
|
883
|
-
* provider for that scheme serves it from the index, whatever provider the
|
|
884
|
-
* host passes as `context.fileSystemProvider`; an edited one therefore keeps
|
|
885
|
-
* its last client's text, and keeping it read-only is the client's job.
|
|
886
|
-
*
|
|
887
|
-
* The answer is read in the document's disk queue, so the rebuild follows
|
|
888
|
-
* any save still queued rather than reverting past it; a file that goes
|
|
889
|
-
* after that read is removed when its rebuild finds none.
|
|
890
|
-
*
|
|
891
|
-
* Whether to revert at all is decided inside the write lock, as its holder:
|
|
892
|
-
* decided before waiting for the lock, a client that opens or re-creates the
|
|
893
|
-
* document meanwhile would have its text rebuilt over, or the document
|
|
894
|
-
* removed. A document some client has open again, or that waits out a new
|
|
895
|
-
* grace, is left to that client.
|
|
896
|
-
*
|
|
897
|
-
* A document released dirty is announced clean, so a watcher is never left
|
|
898
|
-
* holding it dirty: once the revert has parsed the file, even if it is then
|
|
899
|
-
* cancelled, or else once the build it requests in its place has parsed it,
|
|
900
|
-
* as the announcement names the store's text. One the revert or that build
|
|
901
|
-
* removed, or that build failed, is announced without text. A reopen before
|
|
902
|
-
* that takes the announcement over.
|
|
610
|
+
* Call the `DocumentReleaseHandler` slot, and announce a document released
|
|
611
|
+
* dirty clean once the promise it returns settles, which is after the
|
|
612
|
+
* release event. A failure is logged rather than thrown: the store has let
|
|
613
|
+
* go of the document by now, and a throw would abort the transition that
|
|
614
|
+
* released it, a session's close of its other documents included.
|
|
903
615
|
*/
|
|
904
|
-
protected
|
|
905
|
-
|
|
906
|
-
const target = UriUtils.toUri(uri);
|
|
907
|
-
const reopened = (): boolean => this.isOpenInAnyClient(uri) || this.__syncedDocuments.has(uri);
|
|
908
|
-
const releasedDirty = this.__releasedDirty.get(uri);
|
|
909
|
-
const owesFlip = (): boolean => releasedDirty !== undefined && this.__releasedDirty.get(uri) === releasedDirty;
|
|
910
|
-
const announceClean = (withText = true): void => {
|
|
911
|
-
if (owesFlip()) {
|
|
912
|
-
this.__releasedDirty.delete(uri);
|
|
913
|
-
// The sequence of a removed document still names the discarded text.
|
|
914
|
-
const text = !withText || workspace.LangiumDocuments.getDocument(target) === undefined ? undefined : this.textState(uri);
|
|
915
|
-
this.dirtyChangedEmitter.fire(Object.freeze(text ? { uri, text } : { uri }));
|
|
916
|
-
}
|
|
917
|
-
};
|
|
918
|
-
let parsedFromFile = false;
|
|
919
|
-
let parses: Disposable | undefined;
|
|
920
|
-
let onDisk: boolean | undefined;
|
|
921
|
-
let cancelled = false;
|
|
922
|
-
let stopWaiting: (() => void) | undefined;
|
|
923
|
-
// Without it, a revert stopped short leaves the root on the released text.
|
|
924
|
-
const buildInstead = (): void => {
|
|
925
|
-
void workspace.VersionSyncService.requestRecoveryBuild(target, {
|
|
926
|
-
deleted: onDisk === false,
|
|
927
|
-
reason: HYDRANIUM_BUILD_REASONS.didClose,
|
|
928
|
-
// The update handler dropped the change it held back at the release.
|
|
929
|
-
ignoreDeferred: true,
|
|
930
|
-
stillNeeded: () => !reopened()
|
|
931
|
-
}).then(built => {
|
|
932
|
-
if (!built) {
|
|
933
|
-
this.tracer.with(uri).error('Build after a revert that stopped short failed; the store keeps the released text');
|
|
934
|
-
// No parse or removal is coming.
|
|
935
|
-
stopWaiting?.();
|
|
936
|
-
announceClean(false);
|
|
937
|
-
}
|
|
938
|
-
});
|
|
939
|
-
};
|
|
616
|
+
protected handOverRelease(released: ReleasedDocument, cleanAnnouncement: CleanAnnouncement | undefined): void {
|
|
617
|
+
let settled: Promise<void>;
|
|
940
618
|
try {
|
|
941
|
-
// Read
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
return;
|
|
948
|
-
}
|
|
949
|
-
await workspace.WorkspaceLock.write(async token => {
|
|
950
|
-
if (reopened()) {
|
|
951
|
-
return;
|
|
952
|
-
}
|
|
953
|
-
// Observed rather than awaited: a build cancelled after its parse
|
|
954
|
-
// returns early, and the build it yields to does not parse again.
|
|
955
|
-
parses = workspace.VersionSyncService.onDidRecordModel(document => {
|
|
956
|
-
parsedFromFile ||= this.documentKey(document.uri.toString()) === uri;
|
|
957
|
-
});
|
|
958
|
-
workspace.DocumentBuilder.markNextReason(HYDRANIUM_BUILD_REASONS.didClose);
|
|
959
|
-
// Cleared once the build ends: a cancelled one throws, and the lock resolves.
|
|
960
|
-
cancelled = true;
|
|
961
|
-
try {
|
|
962
|
-
await (onDisk
|
|
963
|
-
? workspace.DocumentBuilder.update([target], [], token)
|
|
964
|
-
: workspace.DocumentBuilder.update([], [target], token));
|
|
965
|
-
} catch (err: unknown) {
|
|
966
|
-
if (!onDisk || !isFileNotFound(err, target)) {
|
|
967
|
-
throw err;
|
|
968
|
-
}
|
|
969
|
-
// The file went after its existence was read.
|
|
970
|
-
this.tracer.with(uri).debug('Revert found no file; removing the document instead');
|
|
971
|
-
workspace.DocumentBuilder.markNextReason(HYDRANIUM_BUILD_REASONS.didClose);
|
|
972
|
-
await workspace.DocumentBuilder.update([], [target], token);
|
|
973
|
-
}
|
|
974
|
-
cancelled = false;
|
|
975
|
-
});
|
|
976
|
-
// Cancelled before its parse: the write that cancelled it may build nothing.
|
|
977
|
-
if (cancelled && !parsedFromFile && !reopened() && workspace.LangiumDocuments.getDocument(target) !== undefined) {
|
|
978
|
-
buildInstead();
|
|
979
|
-
}
|
|
619
|
+
// Read here: a lazily built slot whose factory throws throws on this read.
|
|
620
|
+
const handler = this.services.workspace.DocumentReleaseHandler;
|
|
621
|
+
settled =
|
|
622
|
+
handler === undefined
|
|
623
|
+
? Promise.reject(new Error('no workspace.DocumentReleaseHandler bound'))
|
|
624
|
+
: Promise.resolve(handler.didReleaseDocument(released));
|
|
980
625
|
} catch (err: unknown) {
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
// without this build the root stays behind the store's version. A
|
|
992
|
-
// document known to have no file is removed, as the revert would have.
|
|
993
|
-
if (!reopened()) {
|
|
994
|
-
buildInstead();
|
|
626
|
+
settled = Promise.reject(err);
|
|
627
|
+
}
|
|
628
|
+
// Names the text the build holds at the settle, so it follows anything a
|
|
629
|
+
// release listener did meanwhile: none when its build failed or it no
|
|
630
|
+
// longer has the document, since a removal leaves the record on the
|
|
631
|
+
// discarded text.
|
|
632
|
+
const announceClean = (built: boolean): void => {
|
|
633
|
+
if (cleanAnnouncement?.isOwed()) {
|
|
634
|
+
const inBuild = built && this.services.workspace.LangiumDocuments.getDocument(UriUtils.toUri(released.uri)) !== undefined;
|
|
635
|
+
cleanAnnouncement.announce(inBuild ? this.textState(released.uri) : undefined);
|
|
995
636
|
}
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
//
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
const parsed = workspace.VersionSyncService.onDidRecordModel(document => {
|
|
1012
|
-
if (this.documentKey(document.uri.toString()) === uri) {
|
|
1013
|
-
done();
|
|
1014
|
-
}
|
|
1015
|
-
});
|
|
1016
|
-
const deleted = workspace.DocumentBuilder.onUpdate((_changed, deletedUris) => {
|
|
1017
|
-
if (deletedUris.some(deletedUri => this.documentKey(deletedUri.toString()) === uri)) {
|
|
1018
|
-
done();
|
|
1019
|
-
}
|
|
1020
|
-
});
|
|
637
|
+
};
|
|
638
|
+
settled.then(
|
|
639
|
+
() => announceClean(true),
|
|
640
|
+
(err: unknown) => {
|
|
641
|
+
// A release skipped at teardown, its peer or its workspace gone, is
|
|
642
|
+
// routine, not a fault to investigate.
|
|
643
|
+
if (isDocumentReleaseSkippedError(err)) {
|
|
644
|
+
this.tracer.with(released.uri).debug(err.message);
|
|
645
|
+
} else {
|
|
646
|
+
this.tracer
|
|
647
|
+
.with(released.uri)
|
|
648
|
+
.error(`Release handler failed. ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
|
|
649
|
+
}
|
|
650
|
+
// Left unsettled, a watcher keeps the document dirty though the store answers clean.
|
|
651
|
+
announceClean(false);
|
|
1021
652
|
}
|
|
1022
|
-
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** `uri` as the `DocumentReleaseHandler` slot receives it. */
|
|
657
|
+
protected toReleasedDocument(uri: CanonicalUri): ReleasedDocument {
|
|
658
|
+
return {
|
|
659
|
+
uri,
|
|
660
|
+
isFor: other => this.documentKey(other) === uri,
|
|
661
|
+
isReclaimed: () => this.isOpenInAnyClient(uri) || this.__syncedDocuments.has(uri)
|
|
662
|
+
};
|
|
1023
663
|
}
|
|
1024
664
|
|
|
1025
665
|
public notifyWillSaveTextDocument(event: WillSaveTextDocumentParams): void {
|
|
@@ -1076,7 +716,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1076
716
|
}
|
|
1077
717
|
// What the file holds, not what the editor meant to write: an editor
|
|
1078
718
|
// that saved older text leaves the document dirty.
|
|
1079
|
-
this.
|
|
719
|
+
this.setDiskBaseline(uri, onDisk);
|
|
1080
720
|
// An editor that saves and then closes releases the document while the
|
|
1081
721
|
// read is under way; its save is still a save of the text it held.
|
|
1082
722
|
const document = this.__syncedDocuments.get(this.documentKey(uri)) ?? syncedDocument;
|
|
@@ -1097,7 +737,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1097
737
|
const syncedDocument = this.__syncedDocuments.get(this.documentKey(event.textDocument.uri));
|
|
1098
738
|
if (syncedDocument !== undefined) {
|
|
1099
739
|
if (event.text !== undefined) {
|
|
1100
|
-
this.
|
|
740
|
+
this.setDiskBaseline(syncedDocument.uri, event.text);
|
|
1101
741
|
}
|
|
1102
742
|
this.announceSave(syncedDocument, clientId);
|
|
1103
743
|
}
|
|
@@ -1112,101 +752,51 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1112
752
|
public notifyDidOpenTextDocument(event: DidOpenTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void {
|
|
1113
753
|
const td = event.textDocument;
|
|
1114
754
|
const uri = this.documentKey(td.uri);
|
|
1115
|
-
|
|
755
|
+
// Before the deferred release is resolved: refused after it, the open
|
|
756
|
+
// would leave a document whose grace it ended with no holder and no timer.
|
|
757
|
+
// A repeat open stays a no-op.
|
|
758
|
+
if (!this.__sessions.isOpenIn(uri, clientId)) {
|
|
759
|
+
this.__sessions.assertCanOpen(clientId);
|
|
760
|
+
}
|
|
761
|
+
this.resolveDeferredRelease(uri, clientId);
|
|
762
|
+
let document = this.__syncedDocuments.get(uri);
|
|
763
|
+
// The client's declared text, never the synced document: staged
|
|
764
|
+
// content this open consumes leaves the two different, and a baseline
|
|
765
|
+
// asserting the client already holds it suppresses the one sync that
|
|
766
|
+
// would deliver it. On an attach, another client may already have
|
|
767
|
+
// changed the synced text, so the baseline is equality-only; a refresh
|
|
768
|
+
// pushing the full text would dirty the file on open.
|
|
769
|
+
if (clientId === LANGUAGE_CLIENT_ID) {
|
|
770
|
+
this.languageClientShadow.addOpen(uri, this.toLanguageClientUri(td.uri), td.version, td.text, document === undefined);
|
|
771
|
+
}
|
|
1116
772
|
if (this.isOpenInClient(uri, clientId)) {
|
|
1117
|
-
//
|
|
1118
|
-
//
|
|
1119
|
-
//
|
|
1120
|
-
// edits reach this tab too — but do NOT re-fire open/rebuild; the document
|
|
1121
|
-
// is already live.
|
|
1122
|
-
if (clientId === LANGUAGE_CLIENT_ID) {
|
|
1123
|
-
const clientFacing = this.toLanguageClientUri(td.uri);
|
|
1124
|
-
const record = this.__documents.get(uri);
|
|
1125
|
-
if (record && !record.languageClientDocuments?.has(clientFacing)) {
|
|
1126
|
-
(record.languageClientDocuments ??= new Map()).set(clientFacing, { declaredVersion: td.version });
|
|
1127
|
-
// This tab's own buffer, NOT the synced text: a second tab is a second
|
|
1128
|
-
// client model, read from disk, so a server-authored write already
|
|
1129
|
-
// applied to the first tab leaves it BEHIND the synced document. Keying
|
|
1130
|
-
// a diff to the synced text here addresses lines this tab does not have
|
|
1131
|
-
// and drops content it does.
|
|
1132
|
-
this.__shadow.setOpenedText(clientFacing, td.text);
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
773
|
+
// A repeat open, or the editor's second URI for the file, e.g. a
|
|
774
|
+
// symlink and its real path: the shadow records it so pushes reach it,
|
|
775
|
+
// and nothing re-fires.
|
|
1135
776
|
return;
|
|
1136
777
|
}
|
|
1137
|
-
let document = this.__syncedDocuments.get(uri);
|
|
1138
778
|
const existingClients = this.__sessions.clientsOf(uri);
|
|
1139
779
|
this.__sessions.addOpen(uri, clientId);
|
|
1140
|
-
|
|
1141
|
-
// Baseline the per-client staleness guard at the version id the client
|
|
1142
|
-
// declared for its own buffer (client-owned per LSP).
|
|
1143
|
-
record.clientVersions.set(clientId, td.version);
|
|
1144
|
-
if (clientId === LANGUAGE_CLIENT_ID) {
|
|
1145
|
-
// Remember the URI Monaco opened under (may differ from the canonical key)
|
|
1146
|
-
// so outbound applyEditToLanguageClient can address the URI it actually holds.
|
|
1147
|
-
// A fresh state, since a reopened buffer numbers its versions afresh.
|
|
1148
|
-
(record.languageClientDocuments ??= new Map()).set(this.toLanguageClientUri(td.uri), { declaredVersion: td.version });
|
|
1149
|
-
}
|
|
780
|
+
this.__sessions.setClientVersion(uri, clientId, td.version);
|
|
1150
781
|
if (!document) {
|
|
1151
782
|
// Use integrity-staged content if available, otherwise the client-provided (disk) text.
|
|
1152
783
|
const pendingText = this.consumePendingContent(uri);
|
|
1153
784
|
const text = pendingText ?? td.text;
|
|
1154
785
|
const source = pendingText ? ', source=pending' : '';
|
|
1155
|
-
|
|
1156
|
-
// sequence — same version when the content is unchanged since the
|
|
1157
|
-
// last close (so watchers' base versions stay valid), one
|
|
1158
|
-
// step when it changed (so no stale pointer can coincidentally pass
|
|
1159
|
-
// the optimistic gate). An open with no sequence yet seeds it from the built root.
|
|
1160
|
-
const sequence = this.__versionSequences.get(uri);
|
|
1161
|
-
const version =
|
|
1162
|
-
sequence === undefined
|
|
1163
|
-
? this.firstOpenVersion(uri, text, td.version)
|
|
1164
|
-
: sequence.contentHash === textHash(text)
|
|
1165
|
-
? sequence.version
|
|
1166
|
-
: sequence.version + 1;
|
|
786
|
+
const version = this.textLedger.openingVersion(uri, text) ?? this.builtRootOpeningVersion(uri, text) ?? td.version;
|
|
1167
787
|
this.log(uri, `Open document: Version ${version} by ${this.formatClientId(clientId)} [first client${source}]`);
|
|
1168
|
-
document = this.
|
|
1169
|
-
this.
|
|
1170
|
-
this.setAuthor(uri, version, clientId);
|
|
788
|
+
document = this.create(uri, td.languageId, version, text);
|
|
789
|
+
this.commitText(uri, document, clientId);
|
|
1171
790
|
// The opener's text, not the staged content: a session's open read
|
|
1172
791
|
// it from the file, and an editor opened its buffer from there. An
|
|
1173
792
|
// editor that opens a buffer it never saved is taken as clean.
|
|
1174
|
-
|
|
1175
|
-
if (this.__releasedDirty.delete(uri)) {
|
|
1176
|
-
record.dirty = true;
|
|
1177
|
-
}
|
|
1178
|
-
this.refreshDirty(uri);
|
|
1179
|
-
if (clientId === LANGUAGE_CLIENT_ID) {
|
|
1180
|
-
// Baseline the shadow to what Monaco just opened so the next outbound
|
|
1181
|
-
// applyEditToLanguageClient diffs against the right starting point. Keyed by the
|
|
1182
|
-
// language-client URI (what Monaco holds), not the canonical document key.
|
|
1183
|
-
//
|
|
1184
|
-
// The CLIENT's declared text, never the synced document: staged content
|
|
1185
|
-
// consumed above leaves the two different, and a baseline asserting the
|
|
1186
|
-
// client already holds the staged text suppresses the one sync that would
|
|
1187
|
-
// deliver it. Skipped entirely when a push is already outstanding for this
|
|
1188
|
-
// URI — an `applyEdit` to a closed file has the client open from disk and
|
|
1189
|
-
// apply afterwards, so that shadow records what it is about to hold and
|
|
1190
|
-
// disk text would key the next diff to a buffer nobody has.
|
|
1191
|
-
const clientFacing = this.toLanguageClientUri(td.uri);
|
|
1192
|
-
if (!this.__shadow.isTracked(clientFacing)) {
|
|
1193
|
-
this.__shadow.set(clientFacing, td.text);
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
793
|
+
this.dirtyStateTracker.track(uri, document, td.text);
|
|
1196
794
|
const toFire = Object.freeze({ document, clientId });
|
|
1197
795
|
this.__onDidOpen.fire(toFire);
|
|
1198
796
|
this.__onDidChangeContent.fire(toFire);
|
|
1199
797
|
} else {
|
|
1200
798
|
// An additional client attaches to a document already open by another client.
|
|
1201
799
|
this.logClientJoined(uri, clientId, document.version, existingClients);
|
|
1202
|
-
if (clientId === LANGUAGE_CLIENT_ID) {
|
|
1203
|
-
// Monaco's own buffer, which on this path is NOT the synced text — another
|
|
1204
|
-
// client opened the document and may already have changed it. Recorded as
|
|
1205
|
-
// an equality-only baseline (never a diff one, see `setOpenedText`) so the
|
|
1206
|
-
// refresh below does not push a full replace of content Monaco already
|
|
1207
|
-
// holds, which would dirty the file on open.
|
|
1208
|
-
this.__shadow.setOpenedText(this.toLanguageClientUri(td.uri), td.text);
|
|
1209
|
-
}
|
|
1210
800
|
this.refreshContent(uri, clientId);
|
|
1211
801
|
}
|
|
1212
802
|
}
|
|
@@ -1221,54 +811,32 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1221
811
|
* and refreshing per attach turns each into a Langium rebuild and dependent
|
|
1222
812
|
* relink cascade.
|
|
1223
813
|
*
|
|
1224
|
-
* The staleness guard baselines at the SYNCED version, not a declared buffer
|
|
1225
|
-
* version, because a client arriving this way holds no buffer of its own.
|
|
1226
|
-
* Unifying the two routes therefore rebaselines a textual client's guard to a
|
|
1227
|
-
* version it never declared.
|
|
1228
|
-
*
|
|
1229
814
|
* Returns whether a hold was added — `false` when `uri` is not open,
|
|
1230
|
-
* `clientId` already holds it, or the document was waiting out the
|
|
815
|
+
* `clientId` already holds it, or the document was waiting out the release
|
|
1231
816
|
* grace for other clients and was released instead (see
|
|
1232
|
-
* {@link
|
|
817
|
+
* {@link resolveDeferredRelease}); the caller then opens it anew.
|
|
1233
818
|
*/
|
|
1234
819
|
attachClient(uri: DocumentUri, clientId: string): boolean {
|
|
1235
820
|
const key = this.documentKey(uri);
|
|
1236
821
|
if (!this.__syncedDocuments.has(key) || this.isOpenInClient(key, clientId)) {
|
|
1237
822
|
return false;
|
|
1238
823
|
}
|
|
1239
|
-
|
|
824
|
+
// Before the deferred release is resolved, as for an open.
|
|
825
|
+
this.__sessions.assertCanOpen(clientId);
|
|
826
|
+
this.resolveDeferredRelease(key, clientId);
|
|
1240
827
|
const document = this.__syncedDocuments.get(key);
|
|
1241
828
|
if (!document) {
|
|
1242
829
|
return false;
|
|
1243
830
|
}
|
|
1244
831
|
const existingClients = this.__sessions.clientsOf(key);
|
|
1245
832
|
this.__sessions.addOpen(key, clientId);
|
|
1246
|
-
|
|
1247
|
-
|
|
833
|
+
// A client arriving this way holds no buffer of its own, so its guard
|
|
834
|
+
// starts at the synced version.
|
|
835
|
+
this.__sessions.setClientVersion(key, clientId, document.version);
|
|
1248
836
|
this.logClientJoined(key, clientId, document.version, existingClients);
|
|
1249
837
|
return true;
|
|
1250
838
|
}
|
|
1251
839
|
|
|
1252
|
-
/**
|
|
1253
|
-
* The built root's recorded version, one on when `text` differs from the root's;
|
|
1254
|
-
* `declared` when the root records no store version. Seeded from `declared`, a write based
|
|
1255
|
-
* on the root passes the gate over other text, and the same text looks newer than its model.
|
|
1256
|
-
* A built root has no sequence only under a `LangiumDocuments` that does not reconcile at registration.
|
|
1257
|
-
*/
|
|
1258
|
-
protected firstOpenVersion(uri: CanonicalUri, text: string, declared: number): number {
|
|
1259
|
-
const built = this.services.workspace.LangiumDocuments.getDocument(UriUtils.toUri(uri));
|
|
1260
|
-
if (built === undefined) {
|
|
1261
|
-
return declared;
|
|
1262
|
-
}
|
|
1263
|
-
const ledger = this.services.workspace.ModelLedger;
|
|
1264
|
-
const root = built.parseResult.value;
|
|
1265
|
-
const recorded = ledger.versionOf(root);
|
|
1266
|
-
if (recorded === UNRECORDED_VERSION || recorded === STALE_VERSION) {
|
|
1267
|
-
return declared;
|
|
1268
|
-
}
|
|
1269
|
-
return (ledger.textOf(root) ?? built.textDocument.getText()) === text ? recorded : recorded + 1;
|
|
1270
|
-
}
|
|
1271
|
-
|
|
1272
840
|
protected logClientJoined(uri: DocumentUri, clientId: string, version: number, existingClients: readonly string[]): void {
|
|
1273
841
|
this.log(
|
|
1274
842
|
uri,
|
|
@@ -1297,7 +865,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1297
865
|
* two URIs for one physical file (a symlink path and its real path) into a
|
|
1298
866
|
* single registration — dedup at the editor layer, not just in
|
|
1299
867
|
* `LangiumDocuments`. The URI the client opened under is preserved separately
|
|
1300
|
-
* for egress addressing
|
|
868
|
+
* for egress addressing, by the {@link LanguageClientShadow}. The
|
|
1301
869
|
* policy is always bound (the framework defaults it to
|
|
1302
870
|
* `DefaultDocumentUriPolicy`, where canonical ≡ syntactic normalize).
|
|
1303
871
|
*/
|
|
@@ -1317,18 +885,8 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1317
885
|
return asLanguageClientUri(UriUtils.normalize(uri));
|
|
1318
886
|
}
|
|
1319
887
|
|
|
1320
|
-
/** Get-or-create the per-URI tracking record. `uri` must already be a {@link documentKey}. */
|
|
1321
|
-
protected trackingFor(uri: CanonicalUri): DocumentTrackingRecord {
|
|
1322
|
-
let record = this.__documents.get(uri);
|
|
1323
|
-
if (!record) {
|
|
1324
|
-
record = { versionAuthors: [], clientVersions: new Map() };
|
|
1325
|
-
this.__documents.set(uri, record);
|
|
1326
|
-
}
|
|
1327
|
-
return record;
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
888
|
setAuthor(uri: DocumentUri, version: number, author: string): void {
|
|
1331
|
-
this.
|
|
889
|
+
this.textLedger.setAuthor(this.documentKey(uri), version, author);
|
|
1332
890
|
}
|
|
1333
891
|
|
|
1334
892
|
/**
|
|
@@ -1361,7 +919,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1361
919
|
* content unchanged.
|
|
1362
920
|
*/
|
|
1363
921
|
version(uri: DocumentUri): TextVersion {
|
|
1364
|
-
return this.get(uri)?.version ?? this.
|
|
922
|
+
return this.get(uri)?.version ?? this.textLedger.recordOf(this.documentKey(uri))?.version ?? 0;
|
|
1365
923
|
}
|
|
1366
924
|
|
|
1367
925
|
/**
|
|
@@ -1373,30 +931,38 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1373
931
|
textState(uri: DocumentUri): TextState | undefined {
|
|
1374
932
|
const document = this.get(uri);
|
|
1375
933
|
if (document) {
|
|
1376
|
-
return { version: document.version, hash: this.
|
|
934
|
+
return { version: document.version, hash: this.textLedger.hashOf(document), dirty: this.isDirty(uri) };
|
|
1377
935
|
}
|
|
1378
|
-
const
|
|
1379
|
-
return
|
|
936
|
+
const recorded = this.textLedger.recordOf(this.documentKey(uri));
|
|
937
|
+
return recorded && { version: recorded.version, hash: recorded.hash, dirty: false };
|
|
1380
938
|
}
|
|
1381
939
|
|
|
1382
940
|
/**
|
|
1383
|
-
*
|
|
1384
|
-
*
|
|
941
|
+
* The built root's recorded version for a first open of `key` with `text`,
|
|
942
|
+
* one on when the text differs from the root's; `undefined` when the root
|
|
943
|
+
* records no store version. Seeded from the opener's declared version
|
|
944
|
+
* instead, a write based on the built root passes the gate over other text,
|
|
945
|
+
* and the same text looks newer than its model. A built root records none
|
|
946
|
+
* only under a `LangiumDocuments` that does not reconcile at registration.
|
|
1385
947
|
*/
|
|
1386
|
-
protected
|
|
1387
|
-
const
|
|
1388
|
-
if (
|
|
1389
|
-
return
|
|
948
|
+
protected builtRootOpeningVersion(key: CanonicalUri, text: string): TextVersion | undefined {
|
|
949
|
+
const built = this.services.workspace.LangiumDocuments.getDocument(UriUtils.toUri(key));
|
|
950
|
+
if (built === undefined) {
|
|
951
|
+
return undefined;
|
|
952
|
+
}
|
|
953
|
+
const ledger = this.services.workspace.ModelLedger;
|
|
954
|
+
const root = built.parseResult.value;
|
|
955
|
+
const recorded = ledger.versionOf(root);
|
|
956
|
+
if (recorded === UNRECORDED_VERSION || recorded === STALE_VERSION) {
|
|
957
|
+
return undefined;
|
|
1390
958
|
}
|
|
1391
|
-
|
|
1392
|
-
this.__textHashes.set(document, { version: document.version, hash });
|
|
1393
|
-
return hash;
|
|
959
|
+
return (ledger.textOf(root) ?? built.textDocument.getText()) === text ? recorded : recorded + 1;
|
|
1394
960
|
}
|
|
1395
961
|
|
|
1396
962
|
/**
|
|
1397
963
|
* Reconcile the persisted version sequence with content that reached the
|
|
1398
964
|
* build OUTSIDE the store's write paths — a closed document rebuilt from
|
|
1399
|
-
* disk
|
|
965
|
+
* disk after its release, or replaced by a watched-file change. Steps the
|
|
1400
966
|
* sequence iff `text` differs from the sequence's last-known content and
|
|
1401
967
|
* returns the resulting sequence version so the caller can re-stamp the
|
|
1402
968
|
* rebuilt document (`VersionSyncService.modelProduced`) —
|
|
@@ -1417,19 +983,12 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1417
983
|
if (open !== undefined) {
|
|
1418
984
|
return open.getText() === text ? open.version : undefined;
|
|
1419
985
|
}
|
|
1420
|
-
const
|
|
1421
|
-
const
|
|
1422
|
-
if (
|
|
1423
|
-
this.
|
|
1424
|
-
return 0;
|
|
986
|
+
const before = this.textLedger.recordOf(key)?.version;
|
|
987
|
+
const version = this.textLedger.reconcile(key, text);
|
|
988
|
+
if (before !== undefined && version !== before) {
|
|
989
|
+
this.logUri(key, `External content change while closed: sequence stepped to version ${version}`, 'debug');
|
|
1425
990
|
}
|
|
1426
|
-
|
|
1427
|
-
return sequence.version;
|
|
1428
|
-
}
|
|
1429
|
-
const stepped: VersionSequence = { version: sequence.version + 1, contentHash: hash };
|
|
1430
|
-
this.__versionSequences.set(key, stepped);
|
|
1431
|
-
this.logUri(key, `External content change while closed: sequence stepped to version ${stepped.version}`, 'debug');
|
|
1432
|
-
return stepped.version;
|
|
991
|
+
return version;
|
|
1433
992
|
}
|
|
1434
993
|
|
|
1435
994
|
/**
|
|
@@ -1477,19 +1036,15 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1477
1036
|
// Reassigned rather than mutated in place: the default configuration
|
|
1478
1037
|
// updates and returns the SAME instance, but an adopter-supplied one may
|
|
1479
1038
|
// return a new object, and the store must end up holding whichever it is.
|
|
1480
|
-
const updated = this.
|
|
1481
|
-
this.__syncedDocuments.set(key, updated);
|
|
1482
|
-
this.setAuthor(key, updated.version, INTEGRITY_CLIENT_ID);
|
|
1483
|
-
this.refreshDirty(key);
|
|
1039
|
+
const updated = this.commitChange(key, document, [{ text: repaired }], INTEGRITY_CLIENT_ID).document;
|
|
1484
1040
|
this.log(updated.uri, `Update to version ${updated.version} by ${this.formatClientId(INTEGRITY_CLIENT_ID)} (repair)`);
|
|
1485
1041
|
return { status: 'committed', document: updated };
|
|
1486
1042
|
}
|
|
1487
1043
|
|
|
1488
1044
|
getAuthor(uri: DocumentUri, version?: number): string | undefined {
|
|
1489
|
-
const
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
if (!clientId && history) {
|
|
1045
|
+
const key = this.documentKey(uri);
|
|
1046
|
+
const clientId = this.textLedger.authorOf(key, version);
|
|
1047
|
+
if (!clientId && this.textLedger.authorOf(key) !== undefined) {
|
|
1493
1048
|
// Only warn when there IS a history but the specific version is missing; no history at all
|
|
1494
1049
|
// means the document was rebuilt internally (e.g. by a project manager), not an error.
|
|
1495
1050
|
this.log(uri, `Could not detect author of version ${version}.`);
|
|
@@ -1507,8 +1062,10 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1507
1062
|
* `onDidClose` event fires for the last client, so `isOpen` returns `true`
|
|
1508
1063
|
* during the close event itself. `isOpenInAnyClient` reads the open table
|
|
1509
1064
|
* ({@link __sessions}), which is updated BEFORE the fire, so an `onDidClose`
|
|
1510
|
-
* subscriber that finds this `false` knows the last client just closed
|
|
1511
|
-
*
|
|
1065
|
+
* subscriber that finds this `false` knows the last client just closed.
|
|
1066
|
+
* What the build keeps is the release's: a subscriber that re-read or
|
|
1067
|
+
* rebuilt the document would race the `DocumentReleaseHandler`, and after a
|
|
1068
|
+
* lost connection the release waits out its grace.
|
|
1512
1069
|
*/
|
|
1513
1070
|
isOpenInAnyClient(uri: DocumentUri): boolean {
|
|
1514
1071
|
return this.__sessions.isOpen(this.documentKey(uri));
|
|
@@ -1556,12 +1113,12 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1556
1113
|
|
|
1557
1114
|
/**
|
|
1558
1115
|
* Fires once a document no client has open is released: at its last close,
|
|
1559
|
-
* or, for a lost client's last close, when its
|
|
1560
|
-
* another client opens it, or it is deleted
|
|
1561
|
-
*
|
|
1116
|
+
* or, for a lost client's last close, when its release grace runs out,
|
|
1117
|
+
* another client opens it, or it is deleted, before the
|
|
1118
|
+
* `DocumentReleaseHandler` slot is handed the document.
|
|
1562
1119
|
*/
|
|
1563
|
-
get
|
|
1564
|
-
return this.
|
|
1120
|
+
get onDidReleaseDocument(): Event<DocumentReleasedEvent> {
|
|
1121
|
+
return this.documentReleasedEmitter.event;
|
|
1565
1122
|
}
|
|
1566
1123
|
|
|
1567
1124
|
/**
|
|
@@ -1574,19 +1131,19 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1574
1131
|
}
|
|
1575
1132
|
|
|
1576
1133
|
/**
|
|
1577
|
-
* Whether `uri` is waiting out the
|
|
1134
|
+
* Whether `uri` is waiting out the release grace: its last open closed with a
|
|
1578
1135
|
* lost connection, and it still holds its unsaved text. Such a document is
|
|
1579
1136
|
* open for no client, yet not closed either, so a caller that would persist
|
|
1580
1137
|
* a closed document's text to disk treats it as open.
|
|
1581
1138
|
*/
|
|
1582
|
-
|
|
1583
|
-
return this.
|
|
1139
|
+
isReleaseDeferred(uri: DocumentUri): boolean {
|
|
1140
|
+
return this.documentReleaseScheduler.isDeferred(this.documentKey(uri));
|
|
1584
1141
|
}
|
|
1585
1142
|
|
|
1586
1143
|
/**
|
|
1587
1144
|
* Whether the store holds `uri` with text that differs from its disk
|
|
1588
1145
|
* baseline: what the server last knew the file to hold. `false` for a URI
|
|
1589
|
-
* the store does not hold; a document waiting out the
|
|
1146
|
+
* the store does not hold; a document waiting out the release grace is still
|
|
1590
1147
|
* held.
|
|
1591
1148
|
*
|
|
1592
1149
|
* The baseline is the text a first open brought, or what the server wrote,
|
|
@@ -1595,17 +1152,18 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1595
1152
|
* must know the file reads it instead.
|
|
1596
1153
|
*/
|
|
1597
1154
|
isDirty(uri: DocumentUri): boolean {
|
|
1598
|
-
return this.
|
|
1155
|
+
return this.dirtyStateTracker.isDirty(this.documentKey(uri));
|
|
1599
1156
|
}
|
|
1600
1157
|
|
|
1601
1158
|
/**
|
|
1602
|
-
* Fires each time the answer of {@link isDirty} changes.
|
|
1603
|
-
*
|
|
1604
|
-
*
|
|
1605
|
-
*
|
|
1159
|
+
* Fires each time the answer of {@link isDirty} changes. For a document
|
|
1160
|
+
* released dirty it fires once the `DocumentReleaseHandler` slot reports the
|
|
1161
|
+
* release settled: at the version of the text the build then holds, or
|
|
1162
|
+
* without text when the document is gone or the handler failed, though
|
|
1163
|
+
* {@link isDirty} answers clean from the release on.
|
|
1606
1164
|
*/
|
|
1607
1165
|
get onDidChangeDirty(): Event<DocumentDirtyChangedEvent> {
|
|
1608
|
-
return this.
|
|
1166
|
+
return this.dirtyStateTracker.onDidChangeDirty;
|
|
1609
1167
|
}
|
|
1610
1168
|
|
|
1611
1169
|
/**
|
|
@@ -1613,14 +1171,12 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1613
1171
|
* `undefined`. A no-op for a URI the store does not hold: the next first
|
|
1614
1172
|
* open sets the baseline from its own text.
|
|
1615
1173
|
*/
|
|
1616
|
-
|
|
1174
|
+
setDiskBaseline(uri: DocumentUri, text: string | undefined): void {
|
|
1617
1175
|
const key = this.documentKey(uri);
|
|
1618
|
-
const
|
|
1619
|
-
if (
|
|
1620
|
-
|
|
1176
|
+
const document = this.__syncedDocuments.get(key);
|
|
1177
|
+
if (document !== undefined) {
|
|
1178
|
+
this.dirtyStateTracker.setDiskBaseline(key, document, text);
|
|
1621
1179
|
}
|
|
1622
|
-
record.diskBaseline = text;
|
|
1623
|
-
this.refreshDirty(key);
|
|
1624
1180
|
}
|
|
1625
1181
|
|
|
1626
1182
|
/**
|
|
@@ -1641,23 +1197,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1641
1197
|
} catch (err: unknown) {
|
|
1642
1198
|
this.tracer.with(key).debug(`Disk baseline: the file cannot be read. ${err instanceof Error ? err.message : String(err)}`);
|
|
1643
1199
|
}
|
|
1644
|
-
this.
|
|
1645
|
-
}
|
|
1646
|
-
|
|
1647
|
-
/** Compare the held text of `uri` with its baseline, and announce a changed answer. */
|
|
1648
|
-
protected refreshDirty(uri: CanonicalUri): void {
|
|
1649
|
-
const record = this.__documents.get(uri);
|
|
1650
|
-
const document = this.__syncedDocuments.get(uri);
|
|
1651
|
-
if (!record || !document) {
|
|
1652
|
-
return;
|
|
1653
|
-
}
|
|
1654
|
-
const dirty = document.getText() !== record.diskBaseline;
|
|
1655
|
-
if (dirty !== (record.dirty ?? false)) {
|
|
1656
|
-
record.dirty = dirty;
|
|
1657
|
-
this.dirtyChangedEmitter.fire(
|
|
1658
|
-
Object.freeze({ uri, text: { version: document.version, hash: this.heldTextHash(document), dirty } })
|
|
1659
|
-
);
|
|
1660
|
-
}
|
|
1200
|
+
this.setDiskBaseline(key, onDisk);
|
|
1661
1201
|
}
|
|
1662
1202
|
|
|
1663
1203
|
/**
|
|
@@ -1693,7 +1233,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1693
1233
|
|
|
1694
1234
|
/**
|
|
1695
1235
|
* Close every document the language client has open, as a `didClose` for
|
|
1696
|
-
* each would, so each last close
|
|
1236
|
+
* each would, so each last close releases its document. For a host whose editor connection
|
|
1697
1237
|
* can end while the process lives on, such as a worker whose port's peer
|
|
1698
1238
|
* closed: the language client is no session, so nothing else closes them.
|
|
1699
1239
|
*
|
|
@@ -1705,7 +1245,8 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1705
1245
|
async closeLanguageClientDocuments(): Promise<void> {
|
|
1706
1246
|
await this.initialBuildFinished();
|
|
1707
1247
|
for (const uri of this.__sessions.opensOf(LANGUAGE_CLIENT_ID)) {
|
|
1708
|
-
|
|
1248
|
+
// A close for one URI while others remain keeps the client's hold.
|
|
1249
|
+
this.languageClientShadow.removeAllOpens(uri);
|
|
1709
1250
|
this.notifyDidCloseTextDocument({ textDocument: { uri } });
|
|
1710
1251
|
}
|
|
1711
1252
|
}
|
|
@@ -1721,21 +1262,6 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1721
1262
|
await this.services.workspace.WorkspaceManager.workspaceInitialized.catch(() => undefined);
|
|
1722
1263
|
}
|
|
1723
1264
|
|
|
1724
|
-
/**
|
|
1725
|
-
* Stop tracking every URI the language client holds the document `key`
|
|
1726
|
-
* under, with its shadow and pending pushes. Call before a close by
|
|
1727
|
-
* canonical key that means all of them: a close for one URI while others
|
|
1728
|
-
* remain drops only that URI's and keeps the client's hold.
|
|
1729
|
-
*/
|
|
1730
|
-
protected untrackLanguageClientDocuments(key: CanonicalUri): void {
|
|
1731
|
-
const languageClientDocuments = this.__documents.get(key)?.languageClientDocuments;
|
|
1732
|
-
for (const clientUri of languageClientDocuments?.keys() ?? []) {
|
|
1733
|
-
this.__shadow.invalidate(clientUri);
|
|
1734
|
-
this.__pendingPushes.delete(clientUri);
|
|
1735
|
-
}
|
|
1736
|
-
languageClientDocuments?.clear();
|
|
1737
|
-
}
|
|
1738
|
-
|
|
1739
1265
|
/**
|
|
1740
1266
|
* The file behind `uri` was deleted: close every open of it except the
|
|
1741
1267
|
* language client's.
|
|
@@ -1765,14 +1291,14 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1765
1291
|
*/
|
|
1766
1292
|
override delete(uri: string | URI | T): void {
|
|
1767
1293
|
const key = this.documentKey((typeof uri === 'object' && 'uri' in uri ? uri.uri : uri).toString());
|
|
1768
|
-
this.
|
|
1294
|
+
this.languageClientShadow.removeAllOpens(key);
|
|
1769
1295
|
for (const clientId of this.__sessions.clientsOf(key)) {
|
|
1770
1296
|
this.notifyDidCloseTextDocument({ textDocument: { uri: key } }, clientId);
|
|
1771
1297
|
}
|
|
1772
1298
|
// A document waiting out the grace has no client left to close, and is
|
|
1773
1299
|
// released now as its last close would have released it.
|
|
1774
|
-
if (this.
|
|
1775
|
-
this.
|
|
1300
|
+
if (this.documentReleaseScheduler.isDeferred(key)) {
|
|
1301
|
+
this.documentReleaseScheduler.cancel(key);
|
|
1776
1302
|
this.releaseDocument(key);
|
|
1777
1303
|
}
|
|
1778
1304
|
super.delete(key);
|
|
@@ -1801,13 +1327,12 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1801
1327
|
* Only a FIRST open consumes it, so stage only for a URI no client holds
|
|
1802
1328
|
* ({@link isOpenInAnyClient} is `false`). A URI held only through another
|
|
1803
1329
|
* head is not closed: an editor attaching to it joins the existing entry and
|
|
1804
|
-
* never reads the stage, and the
|
|
1805
|
-
*
|
|
1806
|
-
*
|
|
1807
|
-
* URI.
|
|
1330
|
+
* never reads the stage, and the release discards it. An entry lingers
|
|
1331
|
+
* only if `workspace/applyEdit` fails and the file is never opened — the
|
|
1332
|
+
* memory cost is one serialised string per URI.
|
|
1808
1333
|
*/
|
|
1809
1334
|
stagePendingContent(uri: DocumentUri, text: string): void {
|
|
1810
|
-
this.
|
|
1335
|
+
this.__pendingContent.set(this.documentKey(uri), text);
|
|
1811
1336
|
}
|
|
1812
1337
|
|
|
1813
1338
|
/**
|
|
@@ -1827,20 +1352,19 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1827
1352
|
*
|
|
1828
1353
|
* The diff path is apply-verify-safe: the shadow internally checks that
|
|
1829
1354
|
* `TextDocument.applyEdits(old, edits) === newText` and falls back to a
|
|
1830
|
-
* full replace on mismatch
|
|
1831
|
-
*
|
|
1355
|
+
* full replace on mismatch, logged, so a diff regression becomes log noise,
|
|
1356
|
+
* not data loss.
|
|
1832
1357
|
*
|
|
1833
1358
|
* That safety net verifies the diff against the SHADOW, which is what the
|
|
1834
1359
|
* client is *believed* to hold — so it cannot see the client's buffer moving
|
|
1835
1360
|
* underneath a push. A line-keyed edit is position-dependent: if a genuine
|
|
1836
|
-
* client keystroke lands between
|
|
1837
|
-
*
|
|
1838
|
-
*
|
|
1361
|
+
* client keystroke lands between computing the edits and the client
|
|
1362
|
+
* applying them, the ranges address the wrong lines and splice the buffer
|
|
1363
|
+
* (observed as a duplicated declaration, which the integrity tier then
|
|
1839
1364
|
* "repairs" into a suffixed name and persists). The edit is therefore
|
|
1840
|
-
* addressed at the language client's last known version for that URI
|
|
1841
|
-
*
|
|
1842
|
-
*
|
|
1843
|
-
* reject a push its buffer has outrun. On rejection the shadow is invalidated,
|
|
1365
|
+
* addressed at the language client's last known version for that URI rather
|
|
1366
|
+
* than at `null` ("version intentionally unknown"), which is what lets the
|
|
1367
|
+
* client reject a push its buffer has outrun. On rejection the shadow is invalidated,
|
|
1844
1368
|
* so the caller's retry is a full-range replace — position-independent, and
|
|
1845
1369
|
* safe to apply to whatever the client now holds.
|
|
1846
1370
|
*/
|
|
@@ -1853,38 +1377,19 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1853
1377
|
if (!connection) {
|
|
1854
1378
|
return undefined;
|
|
1855
1379
|
}
|
|
1856
|
-
// The document is keyed by its canonical identity, but
|
|
1857
|
-
//
|
|
1858
|
-
|
|
1859
|
-
// when the same file was opened under a symlink and its real path. Falls back
|
|
1860
|
-
// to the normalized URI when nothing is tracked. The shadow is keyed by each
|
|
1861
|
-
// URI, so every diff is against the right baseline.
|
|
1862
|
-
const recorded = this.__documents.get(this.documentKey(uri))?.languageClientDocuments;
|
|
1863
|
-
const targets: Iterable<LanguageClientUri> = recorded && recorded.size > 0 ? [...recorded.keys()] : [this.toLanguageClientUri(uri)];
|
|
1380
|
+
// The document is keyed by its canonical identity, but the client holds it
|
|
1381
|
+
// under each URI it opened, and each is diffed against its own baseline.
|
|
1382
|
+
const key = this.documentKey(uri);
|
|
1864
1383
|
let lastResult: ApplyWorkspaceEditResult | undefined;
|
|
1865
|
-
for (const
|
|
1866
|
-
//
|
|
1867
|
-
//
|
|
1868
|
-
//
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
const edits = this.__shadow.computeEdits(targetUri, newText);
|
|
1872
|
-
if (edits.length === 0) {
|
|
1384
|
+
for (const clientUri of this.languageClientShadow.pushTargets(key, this.toLanguageClientUri(uri))) {
|
|
1385
|
+
// Prepared per target, after the previous target's reply: prepared up
|
|
1386
|
+
// front, a later target is diffed against what it held before a change
|
|
1387
|
+
// that arrived meanwhile.
|
|
1388
|
+
const push = this.languageClientShadow.preparePush(key, clientUri, newText);
|
|
1389
|
+
if (push === undefined) {
|
|
1873
1390
|
continue;
|
|
1874
1391
|
}
|
|
1875
|
-
// Record the push for echo correlation BEFORE the RPC — the client's
|
|
1876
|
-
// echo can race the applyEdit response. See `__pendingPushes`.
|
|
1877
|
-
this.recordPendingPush(targetUri, before, newText);
|
|
1878
1392
|
try {
|
|
1879
|
-
// Version the push only when it is position-DEPENDENT. A full-range
|
|
1880
|
-
// replace lands correctly on any buffer, so gating it would turn a
|
|
1881
|
-
// stale-by-one version into a refused update for no safety gain — and
|
|
1882
|
-
// it is exactly what the caller retries with after a rejection, so
|
|
1883
|
-
// gating it there would refuse the recovery too.
|
|
1884
|
-
const version = isFullReplace(edits) ? UNKNOWN_CLIENT_VERSION : this.languageClientVersion(uri, targetUri);
|
|
1885
|
-
// Captured before the await: a reopen replaces the state, so a late
|
|
1886
|
-
// reply then writes to the old one instead of the new buffer's.
|
|
1887
|
-
const languageClientDocument = recorded?.get(targetUri);
|
|
1888
1393
|
// A full `ApplyWorkspaceEditParams`, `edit` and all — NOT a bare
|
|
1889
1394
|
// `WorkspaceEdit` with a `label` beside it. `applyEdit` takes
|
|
1890
1395
|
// `ApplyWorkspaceEditParams | WorkspaceEdit` and discriminates on
|
|
@@ -1894,34 +1399,21 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1894
1399
|
// The union is also what hides it at compile time: excess-property
|
|
1895
1400
|
// checking admits a property present in EITHER member, so an object
|
|
1896
1401
|
// matching neither type-checks against the union.
|
|
1402
|
+
const version = push.version ?? UNKNOWN_CLIENT_VERSION;
|
|
1897
1403
|
const result = await connection.workspace.applyEdit({
|
|
1898
1404
|
label: options?.label,
|
|
1899
1405
|
edit: {
|
|
1900
|
-
documentChanges: [TextDocumentEdit.create(OptionalVersionedTextDocumentIdentifier.create(
|
|
1406
|
+
documentChanges: [TextDocumentEdit.create(OptionalVersionedTextDocumentIdentifier.create(clientUri, version), push.edits)]
|
|
1901
1407
|
}
|
|
1902
1408
|
});
|
|
1903
1409
|
if (result && result.applied === false) {
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
this.tracer
|
|
1908
|
-
.with(uri)
|
|
1909
|
-
.warn(
|
|
1910
|
-
`Language client refused applyEdit addressed at version ${version} (it last declared version ${languageClientDocument?.declaredVersion})`
|
|
1911
|
-
);
|
|
1912
|
-
}
|
|
1913
|
-
} else if (result?.applied && version !== UNKNOWN_CLIENT_VERSION && languageClientDocument) {
|
|
1914
|
-
// A client steps once per applied edit that changes its buffer. A
|
|
1915
|
-
// line edit does while the shadow is right; a full replace may be a
|
|
1916
|
-
// no-op the client drops. Left to the echo, a push sent first is
|
|
1917
|
-
// refused; advanced after a no-op, one could pass at a version a
|
|
1918
|
-
// keystroke reached.
|
|
1919
|
-
languageClientDocument.pushedVersion = version + 1;
|
|
1410
|
+
push.notifyOutcome('refused');
|
|
1411
|
+
} else if (result?.applied) {
|
|
1412
|
+
push.notifyOutcome('applied');
|
|
1920
1413
|
}
|
|
1921
1414
|
lastResult = result;
|
|
1922
|
-
} catch (err) {
|
|
1923
|
-
|
|
1924
|
-
this.__pendingPushes.delete(targetUri);
|
|
1415
|
+
} catch (err: unknown) {
|
|
1416
|
+
push.notifyOutcome('failed');
|
|
1925
1417
|
throw err;
|
|
1926
1418
|
}
|
|
1927
1419
|
}
|
|
@@ -1929,155 +1421,24 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1929
1421
|
}
|
|
1930
1422
|
|
|
1931
1423
|
/**
|
|
1932
|
-
*
|
|
1933
|
-
* `targetUri`, for addressing an outgoing `workspace/applyEdit`.
|
|
1934
|
-
*
|
|
1935
|
-
* Client version ids are CLIENT-owned per LSP, so this is the id the client
|
|
1936
|
-
* itself stamped on its last `didOpen` / `didChange` for `targetUri`, or the
|
|
1937
|
-
* one an applied push moved it to ahead of the push's echo
|
|
1938
|
-
* ({@link LanguageClientDocumentState}) — never the shared server version,
|
|
1939
|
-
* which advances on authored writes the client knows nothing about and would
|
|
1940
|
-
* therefore reject every push.
|
|
1941
|
-
*
|
|
1942
|
-
* Falls back to {@link UNKNOWN_CLIENT_VERSION} when the client has not opened
|
|
1943
|
-
* the document under `targetUri`, which is the honest answer. That is also
|
|
1944
|
-
* the case in which there is no shadow, so the push is already a
|
|
1945
|
-
* position-independent full replace and has nothing to gain from a gate.
|
|
1946
|
-
*/
|
|
1947
|
-
protected languageClientVersion(uri: DocumentUri, targetUri: LanguageClientUri = this.toLanguageClientUri(uri)): number {
|
|
1948
|
-
const state = this.__documents.get(this.documentKey(uri))?.languageClientDocuments?.get(targetUri);
|
|
1949
|
-
if (state === undefined) {
|
|
1950
|
-
return UNKNOWN_CLIENT_VERSION;
|
|
1951
|
-
}
|
|
1952
|
-
return Math.max(state.declaredVersion, state.pushedVersion ?? state.declaredVersion);
|
|
1953
|
-
}
|
|
1954
|
-
|
|
1955
|
-
/**
|
|
1956
|
-
* Append a push to the in-flight queue for `targetUri`
|
|
1957
|
-
* (see {@link __pendingPushes}). Bounded: beyond
|
|
1958
|
-
* {@link PENDING_ECHO_CAP} the oldest entry drops with a debug log — an
|
|
1959
|
-
* echo that far outstanding means the client is not echoing at all, and
|
|
1960
|
-
* an unbounded queue must not become the leak.
|
|
1961
|
-
*/
|
|
1962
|
-
protected recordPendingPush(targetUri: LanguageClientUri, before: string | undefined, newText: string): void {
|
|
1963
|
-
let pending = this.__pendingPushes.get(targetUri);
|
|
1964
|
-
if (!pending) {
|
|
1965
|
-
pending = [];
|
|
1966
|
-
this.__pendingPushes.set(targetUri, pending);
|
|
1967
|
-
}
|
|
1968
|
-
pending.push({ before, afterHash: textHash(newText) });
|
|
1969
|
-
if (pending.length > PENDING_ECHO_CAP) {
|
|
1970
|
-
pending.shift();
|
|
1971
|
-
this.logUri(targetUri, `Pending-echo queue exceeded ${PENDING_ECHO_CAP} entries; dropped the oldest`, 'debug');
|
|
1972
|
-
}
|
|
1973
|
-
}
|
|
1974
|
-
|
|
1975
|
-
/**
|
|
1976
|
-
* Decide what an incoming language-client change actually is, by
|
|
1977
|
-
* reconstructing the client's resulting buffer against the text its ranges
|
|
1978
|
-
* address — the pre-push buffer of the OLDEST push still in flight for
|
|
1979
|
-
* `clientFacing`, else whatever that client is believed to hold.
|
|
1980
|
-
*
|
|
1981
|
-
* `undefined` when the client's ranges address the synced text itself —
|
|
1982
|
-
* nothing in flight, and no evidence the client holds anything else. That
|
|
1983
|
-
* is the ordinary path, and the caller then applies the ranges directly.
|
|
1984
|
-
* The two texts part company without a push in flight whenever a client
|
|
1985
|
-
* attaches to a document another client has already written: it opened
|
|
1986
|
-
* from disk, so applying its ranges to the synced text splices lines they
|
|
1987
|
-
* never addressed.
|
|
1988
|
-
*
|
|
1989
|
-
* **Why the oldest, and why reconstruct at all.** The client applies our
|
|
1990
|
-
* pushes in order and echoes each against the buffer it held before that
|
|
1991
|
-
* push, so the first echo to arrive belongs to the oldest entry — a FIFO
|
|
1992
|
-
* correspondence the queue preserves by consuming from the front. Comparing
|
|
1993
|
-
* the reconstruction against every pending hash, not only the oldest, is
|
|
1994
|
-
* what recognises an echo that a newer write already superseded: a rapid
|
|
1995
|
-
* write sequence can deliver the echo of push N after the store applied
|
|
1996
|
-
* push N+1, and everything older is then accounted for too.
|
|
1997
|
-
*
|
|
1998
|
-
* A reconstruction matching NO pending push means the client's buffer went
|
|
1999
|
-
* somewhere we did not send it — it coalesced a keystroke into the echo, or
|
|
2000
|
-
* typed before the push landed. That text is authoritative, and the queue
|
|
2001
|
-
* drops: the client has stopped being a pure mirror, so no outstanding echo
|
|
2002
|
-
* can match again. (A push still in flight at that point will be refused by
|
|
2003
|
-
* the client's own version gate, which invalidates the shadow and makes the
|
|
2004
|
-
* next sync a position-independent full replace.)
|
|
2005
|
-
*
|
|
2006
|
-
* Content equality is a sound echo proof because entries live only between a
|
|
2007
|
-
* push and its echo — a milliseconds window, never history — and the
|
|
2008
|
-
* client's `didChange` stream is ordered, so every buffer state arrives in
|
|
2009
|
-
* mutation order. An UNDO returning the buffer to previously-pushed text is
|
|
2010
|
-
* therefore never swallowed: the edit that preceded it already emptied the
|
|
2011
|
-
* queue, so undo revisits PAST states while the queue holds IN-FLIGHT ones.
|
|
2012
|
-
*/
|
|
2013
|
-
protected classifyLanguageClientChange(
|
|
2014
|
-
clientFacing: LanguageClientUri,
|
|
2015
|
-
document: T,
|
|
2016
|
-
changes: TextDocumentContentChangeEvent[]
|
|
2017
|
-
): LanguageClientChangeOrigin | undefined {
|
|
2018
|
-
const queued = this.__pendingPushes.get(clientFacing);
|
|
2019
|
-
const pending = queued?.length ? queued : undefined;
|
|
2020
|
-
// The text the client's ranges address: the buffer it held before the
|
|
2021
|
-
// oldest push still in flight, else the buffer it is believed to hold.
|
|
2022
|
-
const clientText = pending?.[0].before ?? this.__shadow.clientText(clientFacing);
|
|
2023
|
-
if (pending === undefined && (clientText === undefined || clientText === document.getText())) {
|
|
2024
|
-
return undefined;
|
|
2025
|
-
}
|
|
2026
|
-
if (pending !== undefined && pending[0].before === undefined && changes.some(change => ContentChange.isIncremental(change))) {
|
|
2027
|
-
// A push sent to a client whose buffer was unknown — a first sync, or
|
|
2028
|
-
// the recovery push after a rejection. The shadow now holds the text
|
|
2029
|
-
// that push MOVES the client to, which is the one text the echo's
|
|
2030
|
-
// ranges provably do not address, so the fallback above is a baseline
|
|
2031
|
-
// known to be wrong rather than merely unverified. Drop the queue and
|
|
2032
|
-
// the shadow: the next sync is then a full replace, which lands on
|
|
2033
|
-
// whatever the client holds. A full-text change is exempt because it
|
|
2034
|
-
// reconstructs identically against any baseline.
|
|
2035
|
-
pending.length = 0;
|
|
2036
|
-
this.__shadow.invalidate(clientFacing);
|
|
2037
|
-
return { kind: 'unreconstructable' };
|
|
2038
|
-
}
|
|
2039
|
-
// Through the configured factories, not `TextDocument` directly, so an
|
|
2040
|
-
// adopter's custom text-document type governs how the ranges are applied
|
|
2041
|
-
// here exactly as it does on the synced document.
|
|
2042
|
-
const probe = this.create(clientFacing, document.languageId, 0, clientText ?? document.getText());
|
|
2043
|
-
const reconstructed = this.update(probe, changes, 0).getText();
|
|
2044
|
-
if (pending !== undefined) {
|
|
2045
|
-
const matchIndex = pending.findIndex(push => push.afterHash === textHash(reconstructed));
|
|
2046
|
-
if (matchIndex >= 0) {
|
|
2047
|
-
pending.splice(0, matchIndex + 1);
|
|
2048
|
-
return { kind: 'echo' };
|
|
2049
|
-
}
|
|
2050
|
-
pending.length = 0;
|
|
2051
|
-
}
|
|
2052
|
-
return { kind: 'divergent', text: reconstructed };
|
|
2053
|
-
}
|
|
2054
|
-
|
|
2055
|
-
/**
|
|
2056
|
-
* Explicitly baseline the language-client text shadow for a URI. Useful in
|
|
1424
|
+
* Explicitly baseline the language-client shadow for a URI. Useful in
|
|
2057
1425
|
* tests and for adopters that need to seed the shadow without going through
|
|
2058
1426
|
* a `didOpen` event (e.g. after a sideband save). Normal didOpen / didChange
|
|
2059
1427
|
* paths from the LSP language client already auto-track the shadow.
|
|
2060
1428
|
*/
|
|
2061
1429
|
setLanguageClientText(uri: DocumentUri, text: string): void {
|
|
2062
|
-
|
|
2063
|
-
this.__shadow.set(clientFacing, text);
|
|
2064
|
-
// An explicit rebaseline supersedes whatever was in flight.
|
|
2065
|
-
this.__pendingPushes.delete(clientFacing);
|
|
1430
|
+
this.languageClientShadow.setClientText(this.toLanguageClientUri(uri), text);
|
|
2066
1431
|
}
|
|
2067
1432
|
|
|
2068
1433
|
/** Drop the shadow baseline for a URI; the next applyEditToLanguageClient sends a full replace. */
|
|
2069
1434
|
invalidateLanguageClientText(uri: DocumentUri): void {
|
|
2070
|
-
|
|
2071
|
-
this.__shadow.invalidate(clientFacing);
|
|
2072
|
-
this.__pendingPushes.delete(clientFacing);
|
|
1435
|
+
this.languageClientShadow.invalidateClientText(this.toLanguageClientUri(uri));
|
|
2073
1436
|
}
|
|
2074
1437
|
|
|
2075
1438
|
protected consumePendingContent(uri: DocumentUri): string | undefined {
|
|
2076
|
-
const
|
|
2077
|
-
const content =
|
|
2078
|
-
|
|
2079
|
-
record.pendingContent = undefined;
|
|
2080
|
-
}
|
|
1439
|
+
const key = this.documentKey(uri);
|
|
1440
|
+
const content = this.__pendingContent.get(key);
|
|
1441
|
+
this.__pendingContent.delete(key);
|
|
2081
1442
|
return content;
|
|
2082
1443
|
}
|
|
2083
1444
|
|