diff options
author | Kristóf Marussy <kristof@marussy.com> | 2021-12-13 02:07:04 +0100 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2021-12-14 02:14:23 +0100 |
commit | a96c52b21e7e590bbdd70b80896780a446fa2e8b (patch) | |
tree | 663619baa254577bb2f5342192e80bca692ad91d /subprojects/frontend/src/xtext/UpdateService.ts | |
parent | build: move modules into subproject directory (diff) | |
download | refinery-a96c52b21e7e590bbdd70b80896780a446fa2e8b.tar.gz refinery-a96c52b21e7e590bbdd70b80896780a446fa2e8b.tar.zst refinery-a96c52b21e7e590bbdd70b80896780a446fa2e8b.zip |
build: separate module for frontend
This allows us to simplify the webpack configuration and the gradle
build scripts.
Diffstat (limited to 'subprojects/frontend/src/xtext/UpdateService.ts')
-rw-r--r-- | subprojects/frontend/src/xtext/UpdateService.ts | 363 |
1 files changed, 363 insertions, 0 deletions
diff --git a/subprojects/frontend/src/xtext/UpdateService.ts b/subprojects/frontend/src/xtext/UpdateService.ts new file mode 100644 index 00000000..e78944a9 --- /dev/null +++ b/subprojects/frontend/src/xtext/UpdateService.ts | |||
@@ -0,0 +1,363 @@ | |||
1 | import { | ||
2 | ChangeDesc, | ||
3 | ChangeSet, | ||
4 | ChangeSpec, | ||
5 | StateEffect, | ||
6 | Transaction, | ||
7 | } from '@codemirror/state'; | ||
8 | import { nanoid } from 'nanoid'; | ||
9 | |||
10 | import type { EditorStore } from '../editor/EditorStore'; | ||
11 | import type { XtextWebSocketClient } from './XtextWebSocketClient'; | ||
12 | import { ConditionVariable } from '../utils/ConditionVariable'; | ||
13 | import { getLogger } from '../utils/logger'; | ||
14 | import { Timer } from '../utils/Timer'; | ||
15 | import { | ||
16 | ContentAssistEntry, | ||
17 | contentAssistResult, | ||
18 | documentStateResult, | ||
19 | formattingResult, | ||
20 | isConflictResult, | ||
21 | } from './xtextServiceResults'; | ||
22 | |||
23 | const UPDATE_TIMEOUT_MS = 500; | ||
24 | |||
25 | const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; | ||
26 | |||
27 | const log = getLogger('xtext.UpdateService'); | ||
28 | |||
29 | const setDirtyChanges = StateEffect.define<ChangeSet>(); | ||
30 | |||
31 | export interface IAbortSignal { | ||
32 | aborted: boolean; | ||
33 | } | ||
34 | |||
35 | export class UpdateService { | ||
36 | resourceName: string; | ||
37 | |||
38 | xtextStateId: string | null = null; | ||
39 | |||
40 | private readonly store: EditorStore; | ||
41 | |||
42 | /** | ||
43 | * The changes being synchronized to the server if a full or delta text update is running, | ||
44 | * `null` otherwise. | ||
45 | */ | ||
46 | private pendingUpdate: ChangeSet | null = null; | ||
47 | |||
48 | /** | ||
49 | * Local changes not yet sychronized to the server and not part of the running update, if any. | ||
50 | */ | ||
51 | private dirtyChanges: ChangeSet; | ||
52 | |||
53 | private readonly webSocketClient: XtextWebSocketClient; | ||
54 | |||
55 | private readonly updatedCondition = new ConditionVariable( | ||
56 | () => this.pendingUpdate === null && this.xtextStateId !== null, | ||
57 | WAIT_FOR_UPDATE_TIMEOUT_MS, | ||
58 | ); | ||
59 | |||
60 | private readonly idleUpdateTimer = new Timer(() => { | ||
61 | this.handleIdleUpdate(); | ||
62 | }, UPDATE_TIMEOUT_MS); | ||
63 | |||
64 | constructor(store: EditorStore, webSocketClient: XtextWebSocketClient) { | ||
65 | this.resourceName = `${nanoid(7)}.problem`; | ||
66 | this.store = store; | ||
67 | this.dirtyChanges = this.newEmptyChangeSet(); | ||
68 | this.webSocketClient = webSocketClient; | ||
69 | } | ||
70 | |||
71 | onReconnect(): void { | ||
72 | this.xtextStateId = null; | ||
73 | this.updateFullText().catch((error) => { | ||
74 | log.error('Unexpected error during initial update', error); | ||
75 | }); | ||
76 | } | ||
77 | |||
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 | } | ||
90 | if (transaction.docChanged) { | ||
91 | this.dirtyChanges = this.dirtyChanges.compose(transaction.changes); | ||
92 | this.idleUpdateTimer.reschedule(); | ||
93 | } | ||
94 | } | ||
95 | |||
96 | /** | ||
97 | * Computes the summary of any changes happened since the last complete update. | ||
98 | * | ||
99 | * The result reflects any changes that happened since the `xtextStateId` | ||
100 | * version was uploaded to the server. | ||
101 | * | ||
102 | * @return the summary of changes since the last update | ||
103 | */ | ||
104 | computeChangesSinceLastUpdate(): ChangeDesc { | ||
105 | return this.pendingUpdate?.composeDesc(this.dirtyChanges.desc) || this.dirtyChanges.desc; | ||
106 | } | ||
107 | |||
108 | private handleIdleUpdate() { | ||
109 | if (!this.webSocketClient.isOpen || this.dirtyChanges.empty) { | ||
110 | return; | ||
111 | } | ||
112 | if (this.pendingUpdate === null) { | ||
113 | this.update().catch((error) => { | ||
114 | log.error('Unexpected error during scheduled update', error); | ||
115 | }); | ||
116 | } | ||
117 | this.idleUpdateTimer.reschedule(); | ||
118 | } | ||
119 | |||
120 | private newEmptyChangeSet() { | ||
121 | return ChangeSet.of([], this.store.state.doc.length); | ||
122 | } | ||
123 | |||
124 | async updateFullText(): Promise<void> { | ||
125 | await this.withUpdate(() => this.doUpdateFullText()); | ||
126 | } | ||
127 | |||
128 | private async doUpdateFullText(): Promise<[string, void]> { | ||
129 | const result = await this.webSocketClient.send({ | ||
130 | resource: this.resourceName, | ||
131 | serviceType: 'update', | ||
132 | fullText: this.store.state.doc.sliceString(0), | ||
133 | }); | ||
134 | const { stateId } = documentStateResult.parse(result); | ||
135 | return [stateId, undefined]; | ||
136 | } | ||
137 | |||
138 | /** | ||
139 | * Makes sure that the document state on the server reflects recent | ||
140 | * local changes. | ||
141 | * | ||
142 | * Performs either an update with delta text or a full text update if needed. | ||
143 | * If there are not local dirty changes, the promise resolves immediately. | ||
144 | * | ||
145 | * @return a promise resolving when the update is completed | ||
146 | */ | ||
147 | async update(): Promise<void> { | ||
148 | await this.prepareForDeltaUpdate(); | ||
149 | const delta = this.computeDelta(); | ||
150 | if (delta === null) { | ||
151 | return; | ||
152 | } | ||
153 | log.trace('Editor delta', delta); | ||
154 | await this.withUpdate(async () => { | ||
155 | const result = await this.webSocketClient.send({ | ||
156 | resource: this.resourceName, | ||
157 | serviceType: 'update', | ||
158 | requiredStateId: this.xtextStateId, | ||
159 | ...delta, | ||
160 | }); | ||
161 | const parsedDocumentStateResult = documentStateResult.safeParse(result); | ||
162 | if (parsedDocumentStateResult.success) { | ||
163 | return [parsedDocumentStateResult.data.stateId, undefined]; | ||
164 | } | ||
165 | if (isConflictResult(result, 'invalidStateId')) { | ||
166 | return this.doFallbackToUpdateFullText(); | ||
167 | } | ||
168 | throw parsedDocumentStateResult.error; | ||
169 | }); | ||
170 | } | ||
171 | |||
172 | private doFallbackToUpdateFullText() { | ||
173 | if (this.pendingUpdate === null) { | ||
174 | throw new Error('Only a pending update can be extended'); | ||
175 | } | ||
176 | log.warn('Delta update failed, performing full text update'); | ||
177 | this.xtextStateId = null; | ||
178 | this.pendingUpdate = this.pendingUpdate.compose(this.dirtyChanges); | ||
179 | this.dirtyChanges = this.newEmptyChangeSet(); | ||
180 | return this.doUpdateFullText(); | ||
181 | } | ||
182 | |||
183 | async fetchContentAssist( | ||
184 | params: Record<string, unknown>, | ||
185 | signal: IAbortSignal, | ||
186 | ): Promise<ContentAssistEntry[]> { | ||
187 | await this.prepareForDeltaUpdate(); | ||
188 | if (signal.aborted) { | ||
189 | return []; | ||
190 | } | ||
191 | const delta = this.computeDelta(); | ||
192 | if (delta !== null) { | ||
193 | log.trace('Editor delta', delta); | ||
194 | const entries = await this.withUpdate(async () => { | ||
195 | const result = await this.webSocketClient.send({ | ||
196 | ...params, | ||
197 | requiredStateId: this.xtextStateId, | ||
198 | ...delta, | ||
199 | }); | ||
200 | const parsedContentAssistResult = contentAssistResult.safeParse(result); | ||
201 | if (parsedContentAssistResult.success) { | ||
202 | const { stateId, entries: resultEntries } = parsedContentAssistResult.data; | ||
203 | return [stateId, resultEntries]; | ||
204 | } | ||
205 | if (isConflictResult(result, 'invalidStateId')) { | ||
206 | log.warn('Server state invalid during content assist'); | ||
207 | const [newStateId] = await this.doFallbackToUpdateFullText(); | ||
208 | // We must finish this state update transaction to prepare for any push events | ||
209 | // before querying for content assist, so we just return `null` and will query | ||
210 | // the content assist service later. | ||
211 | return [newStateId, null]; | ||
212 | } | ||
213 | throw parsedContentAssistResult.error; | ||
214 | }); | ||
215 | if (entries !== null) { | ||
216 | return entries; | ||
217 | } | ||
218 | if (signal.aborted) { | ||
219 | return []; | ||
220 | } | ||
221 | } | ||
222 | // Poscondition of `prepareForDeltaUpdate`: `xtextStateId !== null` | ||
223 | return this.doFetchContentAssist(params, this.xtextStateId as string); | ||
224 | } | ||
225 | |||
226 | private async doFetchContentAssist(params: Record<string, unknown>, expectedStateId: string) { | ||
227 | const result = await this.webSocketClient.send({ | ||
228 | ...params, | ||
229 | requiredStateId: expectedStateId, | ||
230 | }); | ||
231 | const { stateId, entries } = contentAssistResult.parse(result); | ||
232 | if (stateId !== expectedStateId) { | ||
233 | throw new Error(`Unexpected state id, expected: ${expectedStateId} got: ${stateId}`); | ||
234 | } | ||
235 | return entries; | ||
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 | }); | ||
261 | } | ||
262 | |||
263 | private computeDelta() { | ||
264 | if (this.dirtyChanges.empty) { | ||
265 | return null; | ||
266 | } | ||
267 | let minFromA = Number.MAX_SAFE_INTEGER; | ||
268 | let maxToA = 0; | ||
269 | let minFromB = Number.MAX_SAFE_INTEGER; | ||
270 | let maxToB = 0; | ||
271 | this.dirtyChanges.iterChangedRanges((fromA, toA, fromB, toB) => { | ||
272 | minFromA = Math.min(minFromA, fromA); | ||
273 | maxToA = Math.max(maxToA, toA); | ||
274 | minFromB = Math.min(minFromB, fromB); | ||
275 | maxToB = Math.max(maxToB, toB); | ||
276 | }); | ||
277 | return { | ||
278 | deltaOffset: minFromA, | ||
279 | deltaReplaceLength: maxToA - minFromA, | ||
280 | deltaText: this.store.state.doc.sliceString(minFromB, maxToB), | ||
281 | }; | ||
282 | } | ||
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 | |||
298 | /** | ||
299 | * Executes an asynchronous callback that updates the state on the server. | ||
300 | * | ||
301 | * Ensures that updates happen sequentially and manages `pendingUpdate` | ||
302 | * and `dirtyChanges` to reflect changes being synchronized to the server | ||
303 | * and not yet synchronized to the server, respectively. | ||
304 | * | ||
305 | * Optionally, `callback` may return a second value that is retured by this function. | ||
306 | * | ||
307 | * Once the remote procedure call to update the server state finishes | ||
308 | * and returns the new `stateId`, `callback` must return _immediately_ | ||
309 | * to ensure that the local `stateId` is updated likewise to be able to handle | ||
310 | * push messages referring to the new `stateId` from the server. | ||
311 | * If additional work is needed to compute the second value in some cases, | ||
312 | * use `T | null` instead of `T` as a return type and signal the need for additional | ||
313 | * computations by returning `null`. Thus additional computations can be performed | ||
314 | * outside of the critical section. | ||
315 | * | ||
316 | * @param callback the asynchronous callback that updates the server state | ||
317 | * @return a promise resolving to the second value returned by `callback` | ||
318 | */ | ||
319 | private async withUpdate<T>(callback: () => Promise<[string, T]>): Promise<T> { | ||
320 | if (this.pendingUpdate !== null) { | ||
321 | throw new Error('Another update is pending, will not perform update'); | ||
322 | } | ||
323 | this.pendingUpdate = this.dirtyChanges; | ||
324 | this.dirtyChanges = this.newEmptyChangeSet(); | ||
325 | let newStateId: string | null = null; | ||
326 | try { | ||
327 | let result: T; | ||
328 | [newStateId, result] = await callback(); | ||
329 | this.xtextStateId = newStateId; | ||
330 | this.pendingUpdate = null; | ||
331 | this.updatedCondition.notifyAll(); | ||
332 | return result; | ||
333 | } catch (e) { | ||
334 | log.error('Error while update', e); | ||
335 | if (this.pendingUpdate === null) { | ||
336 | log.error('pendingUpdate was cleared during update'); | ||
337 | } else { | ||
338 | this.dirtyChanges = this.pendingUpdate.compose(this.dirtyChanges); | ||
339 | } | ||
340 | this.pendingUpdate = null; | ||
341 | this.webSocketClient.forceReconnectOnError(); | ||
342 | this.updatedCondition.rejectAll(e); | ||
343 | throw e; | ||
344 | } | ||
345 | } | ||
346 | |||
347 | /** | ||
348 | * Ensures that there is some state available on the server (`xtextStateId`) | ||
349 | * and that there is not pending update. | ||
350 | * | ||
351 | * After this function resolves, a delta text update is possible. | ||
352 | * | ||
353 | * @return a promise resolving when there is a valid state id but no pending update | ||
354 | */ | ||
355 | private async prepareForDeltaUpdate() { | ||
356 | // If no update is pending, but the full text hasn't been uploaded to the server yet, | ||
357 | // we must start a full text upload. | ||
358 | if (this.pendingUpdate === null && this.xtextStateId === null) { | ||
359 | await this.updateFullText(); | ||
360 | } | ||
361 | await this.updatedCondition.waitFor(); | ||
362 | } | ||
363 | } | ||