diff options
Diffstat (limited to 'language-web/src/main/js/xtext/UpdateService.ts')
-rw-r--r-- | language-web/src/main/js/xtext/UpdateService.ts | 125 |
1 files changed, 89 insertions, 36 deletions
diff --git a/language-web/src/main/js/xtext/UpdateService.ts b/language-web/src/main/js/xtext/UpdateService.ts index 9b672e79..e78944a9 100644 --- a/language-web/src/main/js/xtext/UpdateService.ts +++ b/language-web/src/main/js/xtext/UpdateService.ts | |||
@@ -1,6 +1,8 @@ | |||
1 | import { | 1 | import { |
2 | ChangeDesc, | 2 | ChangeDesc, |
3 | ChangeSet, | 3 | ChangeSet, |
4 | ChangeSpec, | ||
5 | StateEffect, | ||
4 | Transaction, | 6 | Transaction, |
5 | } from '@codemirror/state'; | 7 | } from '@codemirror/state'; |
6 | import { nanoid } from 'nanoid'; | 8 | import { nanoid } from 'nanoid'; |
@@ -11,10 +13,11 @@ import { ConditionVariable } from '../utils/ConditionVariable'; | |||
11 | import { getLogger } from '../utils/logger'; | 13 | import { getLogger } from '../utils/logger'; |
12 | import { Timer } from '../utils/Timer'; | 14 | import { Timer } from '../utils/Timer'; |
13 | import { | 15 | import { |
14 | IContentAssistEntry, | 16 | ContentAssistEntry, |
15 | isContentAssistResult, | 17 | contentAssistResult, |
16 | isDocumentStateResult, | 18 | documentStateResult, |
17 | isInvalidStateIdConflictResult, | 19 | formattingResult, |
20 | isConflictResult, | ||
18 | } from './xtextServiceResults'; | 21 | } from './xtextServiceResults'; |
19 | 22 | ||
20 | const UPDATE_TIMEOUT_MS = 500; | 23 | const UPDATE_TIMEOUT_MS = 500; |
@@ -23,6 +26,8 @@ const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; | |||
23 | 26 | ||
24 | const log = getLogger('xtext.UpdateService'); | 27 | const log = getLogger('xtext.UpdateService'); |
25 | 28 | ||
29 | const setDirtyChanges = StateEffect.define<ChangeSet>(); | ||
30 | |||
26 | export interface IAbortSignal { | 31 | export interface IAbortSignal { |
27 | aborted: boolean; | 32 | aborted: boolean; |
28 | } | 33 | } |
@@ -38,12 +43,12 @@ export class UpdateService { | |||
38 | * The changes being synchronized to the server if a full or delta text update is running, | 43 | * The changes being synchronized to the server if a full or delta text update is running, |
39 | * `null` otherwise. | 44 | * `null` otherwise. |
40 | */ | 45 | */ |
41 | private pendingUpdate: ChangeDesc | null = null; | 46 | private pendingUpdate: ChangeSet | null = null; |
42 | 47 | ||
43 | /** | 48 | /** |
44 | * Local changes not yet sychronized to the server and not part of the running update, if any. | 49 | * Local changes not yet sychronized to the server and not part of the running update, if any. |
45 | */ | 50 | */ |
46 | private dirtyChanges: ChangeDesc; | 51 | private dirtyChanges: ChangeSet; |
47 | 52 | ||
48 | private readonly webSocketClient: XtextWebSocketClient; | 53 | private readonly webSocketClient: XtextWebSocketClient; |
49 | 54 | ||
@@ -59,7 +64,7 @@ export class UpdateService { | |||
59 | constructor(store: EditorStore, webSocketClient: XtextWebSocketClient) { | 64 | constructor(store: EditorStore, webSocketClient: XtextWebSocketClient) { |
60 | this.resourceName = `${nanoid(7)}.problem`; | 65 | this.resourceName = `${nanoid(7)}.problem`; |
61 | this.store = store; | 66 | this.store = store; |
62 | this.dirtyChanges = this.newEmptyChangeDesc(); | 67 | this.dirtyChanges = this.newEmptyChangeSet(); |
63 | this.webSocketClient = webSocketClient; | 68 | this.webSocketClient = webSocketClient; |
64 | } | 69 | } |
65 | 70 | ||
@@ -71,8 +76,19 @@ export class UpdateService { | |||
71 | } | 76 | } |
72 | 77 | ||
73 | onTransaction(transaction: Transaction): void { | 78 | onTransaction(transaction: Transaction): void { |
79 | const setDirtyChangesEffect = transaction.effects.find( | ||
80 | (effect) => effect.is(setDirtyChanges), | ||
81 | ) as StateEffect<ChangeSet> | undefined; | ||
82 | if (setDirtyChangesEffect) { | ||
83 | const { value } = setDirtyChangesEffect; | ||
84 | if (this.pendingUpdate !== null) { | ||
85 | this.pendingUpdate = ChangeSet.empty(value.length); | ||
86 | } | ||
87 | this.dirtyChanges = value; | ||
88 | return; | ||
89 | } | ||
74 | if (transaction.docChanged) { | 90 | if (transaction.docChanged) { |
75 | this.dirtyChanges = this.dirtyChanges.composeDesc(transaction.changes.desc); | 91 | this.dirtyChanges = this.dirtyChanges.compose(transaction.changes); |
76 | this.idleUpdateTimer.reschedule(); | 92 | this.idleUpdateTimer.reschedule(); |
77 | } | 93 | } |
78 | } | 94 | } |
@@ -86,7 +102,7 @@ export class UpdateService { | |||
86 | * @return the summary of changes since the last update | 102 | * @return the summary of changes since the last update |
87 | */ | 103 | */ |
88 | computeChangesSinceLastUpdate(): ChangeDesc { | 104 | computeChangesSinceLastUpdate(): ChangeDesc { |
89 | return this.pendingUpdate?.composeDesc(this.dirtyChanges) || this.dirtyChanges; | 105 | return this.pendingUpdate?.composeDesc(this.dirtyChanges.desc) || this.dirtyChanges.desc; |
90 | } | 106 | } |
91 | 107 | ||
92 | private handleIdleUpdate() { | 108 | private handleIdleUpdate() { |
@@ -101,9 +117,8 @@ export class UpdateService { | |||
101 | this.idleUpdateTimer.reschedule(); | 117 | this.idleUpdateTimer.reschedule(); |
102 | } | 118 | } |
103 | 119 | ||
104 | private newEmptyChangeDesc() { | 120 | private newEmptyChangeSet() { |
105 | const changeSet = ChangeSet.of([], this.store.state.doc.length); | 121 | return ChangeSet.of([], this.store.state.doc.length); |
106 | return changeSet.desc; | ||
107 | } | 122 | } |
108 | 123 | ||
109 | async updateFullText(): Promise<void> { | 124 | async updateFullText(): Promise<void> { |
@@ -116,11 +131,8 @@ export class UpdateService { | |||
116 | serviceType: 'update', | 131 | serviceType: 'update', |
117 | fullText: this.store.state.doc.sliceString(0), | 132 | fullText: this.store.state.doc.sliceString(0), |
118 | }); | 133 | }); |
119 | if (isDocumentStateResult(result)) { | 134 | const { stateId } = documentStateResult.parse(result); |
120 | return [result.stateId, undefined]; | 135 | return [stateId, undefined]; |
121 | } | ||
122 | log.error('Unexpected full text update result:', result); | ||
123 | throw new Error('Full text update failed'); | ||
124 | } | 136 | } |
125 | 137 | ||
126 | /** | 138 | /** |
@@ -146,14 +158,14 @@ export class UpdateService { | |||
146 | requiredStateId: this.xtextStateId, | 158 | requiredStateId: this.xtextStateId, |
147 | ...delta, | 159 | ...delta, |
148 | }); | 160 | }); |
149 | if (isDocumentStateResult(result)) { | 161 | const parsedDocumentStateResult = documentStateResult.safeParse(result); |
150 | return [result.stateId, undefined]; | 162 | if (parsedDocumentStateResult.success) { |
163 | return [parsedDocumentStateResult.data.stateId, undefined]; | ||
151 | } | 164 | } |
152 | if (isInvalidStateIdConflictResult(result)) { | 165 | if (isConflictResult(result, 'invalidStateId')) { |
153 | return this.doFallbackToUpdateFullText(); | 166 | return this.doFallbackToUpdateFullText(); |
154 | } | 167 | } |
155 | log.error('Unexpected delta text update result:', result); | 168 | throw parsedDocumentStateResult.error; |
156 | throw new Error('Delta text update failed'); | ||
157 | }); | 169 | }); |
158 | } | 170 | } |
159 | 171 | ||
@@ -163,15 +175,15 @@ export class UpdateService { | |||
163 | } | 175 | } |
164 | log.warn('Delta update failed, performing full text update'); | 176 | log.warn('Delta update failed, performing full text update'); |
165 | this.xtextStateId = null; | 177 | this.xtextStateId = null; |
166 | this.pendingUpdate = this.pendingUpdate.composeDesc(this.dirtyChanges); | 178 | this.pendingUpdate = this.pendingUpdate.compose(this.dirtyChanges); |
167 | this.dirtyChanges = this.newEmptyChangeDesc(); | 179 | this.dirtyChanges = this.newEmptyChangeSet(); |
168 | return this.doUpdateFullText(); | 180 | return this.doUpdateFullText(); |
169 | } | 181 | } |
170 | 182 | ||
171 | async fetchContentAssist( | 183 | async fetchContentAssist( |
172 | params: Record<string, unknown>, | 184 | params: Record<string, unknown>, |
173 | signal: IAbortSignal, | 185 | signal: IAbortSignal, |
174 | ): Promise<IContentAssistEntry[]> { | 186 | ): Promise<ContentAssistEntry[]> { |
175 | await this.prepareForDeltaUpdate(); | 187 | await this.prepareForDeltaUpdate(); |
176 | if (signal.aborted) { | 188 | if (signal.aborted) { |
177 | return []; | 189 | return []; |
@@ -185,18 +197,20 @@ export class UpdateService { | |||
185 | requiredStateId: this.xtextStateId, | 197 | requiredStateId: this.xtextStateId, |
186 | ...delta, | 198 | ...delta, |
187 | }); | 199 | }); |
188 | if (isContentAssistResult(result)) { | 200 | const parsedContentAssistResult = contentAssistResult.safeParse(result); |
189 | return [result.stateId, result.entries]; | 201 | if (parsedContentAssistResult.success) { |
202 | const { stateId, entries: resultEntries } = parsedContentAssistResult.data; | ||
203 | return [stateId, resultEntries]; | ||
190 | } | 204 | } |
191 | if (isInvalidStateIdConflictResult(result)) { | 205 | if (isConflictResult(result, 'invalidStateId')) { |
206 | log.warn('Server state invalid during content assist'); | ||
192 | const [newStateId] = await this.doFallbackToUpdateFullText(); | 207 | const [newStateId] = await this.doFallbackToUpdateFullText(); |
193 | // We must finish this state update transaction to prepare for any push events | 208 | // We must finish this state update transaction to prepare for any push events |
194 | // before querying for content assist, so we just return `null` and will query | 209 | // before querying for content assist, so we just return `null` and will query |
195 | // the content assist service later. | 210 | // the content assist service later. |
196 | return [newStateId, null]; | 211 | return [newStateId, null]; |
197 | } | 212 | } |
198 | log.error('Unextpected content assist result with delta update', result); | 213 | throw parsedContentAssistResult.error; |
199 | throw new Error('Unexpexted content assist result with delta update'); | ||
200 | }); | 214 | }); |
201 | if (entries !== null) { | 215 | if (entries !== null) { |
202 | return entries; | 216 | return entries; |
@@ -214,11 +228,36 @@ export class UpdateService { | |||
214 | ...params, | 228 | ...params, |
215 | requiredStateId: expectedStateId, | 229 | requiredStateId: expectedStateId, |
216 | }); | 230 | }); |
217 | if (isContentAssistResult(result) && result.stateId === expectedStateId) { | 231 | const { stateId, entries } = contentAssistResult.parse(result); |
218 | return result.entries; | 232 | if (stateId !== expectedStateId) { |
233 | throw new Error(`Unexpected state id, expected: ${expectedStateId} got: ${stateId}`); | ||
219 | } | 234 | } |
220 | log.error('Unexpected content assist result', result); | 235 | return entries; |
221 | throw new Error('Unexpected content assist result'); | 236 | } |
237 | |||
238 | async formatText(): Promise<void> { | ||
239 | await this.update(); | ||
240 | let { from, to } = this.store.state.selection.main; | ||
241 | if (to <= from) { | ||
242 | from = 0; | ||
243 | to = this.store.state.doc.length; | ||
244 | } | ||
245 | log.debug('Formatting from', from, 'to', to); | ||
246 | await this.withUpdate(async () => { | ||
247 | const result = await this.webSocketClient.send({ | ||
248 | resource: this.resourceName, | ||
249 | serviceType: 'format', | ||
250 | selectionStart: from, | ||
251 | selectionEnd: to, | ||
252 | }); | ||
253 | const { stateId, formattedText } = formattingResult.parse(result); | ||
254 | this.applyBeforeDirtyChanges({ | ||
255 | from, | ||
256 | to, | ||
257 | insert: formattedText, | ||
258 | }); | ||
259 | return [stateId, null]; | ||
260 | }); | ||
222 | } | 261 | } |
223 | 262 | ||
224 | private computeDelta() { | 263 | private computeDelta() { |
@@ -242,6 +281,20 @@ export class UpdateService { | |||
242 | }; | 281 | }; |
243 | } | 282 | } |
244 | 283 | ||
284 | private applyBeforeDirtyChanges(changeSpec: ChangeSpec) { | ||
285 | const pendingChanges = this.pendingUpdate?.compose(this.dirtyChanges) || this.dirtyChanges; | ||
286 | const revertChanges = pendingChanges.invert(this.store.state.doc); | ||
287 | const applyBefore = ChangeSet.of(changeSpec, revertChanges.newLength); | ||
288 | const redoChanges = pendingChanges.map(applyBefore.desc); | ||
289 | const changeSet = revertChanges.compose(applyBefore).compose(redoChanges); | ||
290 | this.store.dispatch({ | ||
291 | changes: changeSet, | ||
292 | effects: [ | ||
293 | setDirtyChanges.of(redoChanges), | ||
294 | ], | ||
295 | }); | ||
296 | } | ||
297 | |||
245 | /** | 298 | /** |
246 | * Executes an asynchronous callback that updates the state on the server. | 299 | * Executes an asynchronous callback that updates the state on the server. |
247 | * | 300 | * |
@@ -268,7 +321,7 @@ export class UpdateService { | |||
268 | throw new Error('Another update is pending, will not perform update'); | 321 | throw new Error('Another update is pending, will not perform update'); |
269 | } | 322 | } |
270 | this.pendingUpdate = this.dirtyChanges; | 323 | this.pendingUpdate = this.dirtyChanges; |
271 | this.dirtyChanges = this.newEmptyChangeDesc(); | 324 | this.dirtyChanges = this.newEmptyChangeSet(); |
272 | let newStateId: string | null = null; | 325 | let newStateId: string | null = null; |
273 | try { | 326 | try { |
274 | let result: T; | 327 | let result: T; |
@@ -282,7 +335,7 @@ export class UpdateService { | |||
282 | if (this.pendingUpdate === null) { | 335 | if (this.pendingUpdate === null) { |
283 | log.error('pendingUpdate was cleared during update'); | 336 | log.error('pendingUpdate was cleared during update'); |
284 | } else { | 337 | } else { |
285 | this.dirtyChanges = this.pendingUpdate.composeDesc(this.dirtyChanges); | 338 | this.dirtyChanges = this.pendingUpdate.compose(this.dirtyChanges); |
286 | } | 339 | } |
287 | this.pendingUpdate = null; | 340 | this.pendingUpdate = null; |
288 | this.webSocketClient.forceReconnectOnError(); | 341 | this.webSocketClient.forceReconnectOnError(); |