aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend/src/xtext/UpdateService.ts
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2021-12-13 02:07:04 +0100
committerLibravatar Kristóf Marussy <kristof@marussy.com>2021-12-14 02:14:23 +0100
commita96c52b21e7e590bbdd70b80896780a446fa2e8b (patch)
tree663619baa254577bb2f5342192e80bca692ad91d /subprojects/frontend/src/xtext/UpdateService.ts
parentbuild: move modules into subproject directory (diff)
downloadrefinery-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.ts363
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 @@
1import {
2 ChangeDesc,
3 ChangeSet,
4 ChangeSpec,
5 StateEffect,
6 Transaction,
7} from '@codemirror/state';
8import { nanoid } from 'nanoid';
9
10import type { EditorStore } from '../editor/EditorStore';
11import type { XtextWebSocketClient } from './XtextWebSocketClient';
12import { ConditionVariable } from '../utils/ConditionVariable';
13import { getLogger } from '../utils/logger';
14import { Timer } from '../utils/Timer';
15import {
16 ContentAssistEntry,
17 contentAssistResult,
18 documentStateResult,
19 formattingResult,
20 isConflictResult,
21} from './xtextServiceResults';
22
23const UPDATE_TIMEOUT_MS = 500;
24
25const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000;
26
27const log = getLogger('xtext.UpdateService');
28
29const setDirtyChanges = StateEffect.define<ChangeSet>();
30
31export interface IAbortSignal {
32 aborted: boolean;
33}
34
35export 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}