diff options
author | Kristóf Marussy <kristof@marussy.com> | 2021-10-25 00:29:37 +0200 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2021-10-31 19:26:11 +0100 |
commit | dcbfeece5e559b60a615f0aa9b933b202d34bf8b (patch) | |
tree | afdacff7492284f5f8cc147c4b84e4ba5db259b3 /language-web/src/main/js/editor/XtextClient.ts | |
parent | test(web): more websocket integration tests (diff) | |
download | refinery-dcbfeece5e559b60a615f0aa9b933b202d34bf8b.tar.gz refinery-dcbfeece5e559b60a615f0aa9b933b202d34bf8b.tar.zst refinery-dcbfeece5e559b60a615f0aa9b933b202d34bf8b.zip |
feat(web): add xtext websocket client
Diffstat (limited to 'language-web/src/main/js/editor/XtextClient.ts')
-rw-r--r-- | language-web/src/main/js/editor/XtextClient.ts | 243 |
1 files changed, 243 insertions, 0 deletions
diff --git a/language-web/src/main/js/editor/XtextClient.ts b/language-web/src/main/js/editor/XtextClient.ts new file mode 100644 index 00000000..eeb67d72 --- /dev/null +++ b/language-web/src/main/js/editor/XtextClient.ts | |||
@@ -0,0 +1,243 @@ | |||
1 | import { Diagnostic, setDiagnostics } from '@codemirror/lint'; | ||
2 | import { | ||
3 | ChangeDesc, | ||
4 | ChangeSet, | ||
5 | EditorState, | ||
6 | Transaction, | ||
7 | } from '@codemirror/state'; | ||
8 | import { nanoid } from 'nanoid'; | ||
9 | |||
10 | import type { EditorStore } from './EditorStore'; | ||
11 | import { getLogger } from '../logging'; | ||
12 | import { | ||
13 | isDocumentStateResult, | ||
14 | isServiceConflictResult, | ||
15 | isValidationResult, | ||
16 | } from './xtextServiceResults'; | ||
17 | import { XtextWebSocketClient } from './XtextWebSocketClient'; | ||
18 | |||
19 | const UPDATE_TIMEOUT_MS = 300; | ||
20 | |||
21 | const log = getLogger('XtextClient'); | ||
22 | |||
23 | enum UpdateAction { | ||
24 | ForceReconnect, | ||
25 | |||
26 | FullTextUpdate, | ||
27 | } | ||
28 | |||
29 | export class XtextClient { | ||
30 | resourceName: string; | ||
31 | |||
32 | webSocketClient: XtextWebSocketClient; | ||
33 | |||
34 | xtextStateId: string | null = null; | ||
35 | |||
36 | pendingUpdate: ChangeDesc | null; | ||
37 | |||
38 | dirtyChanges: ChangeDesc; | ||
39 | |||
40 | updateTimeout: NodeJS.Timeout | null = null; | ||
41 | |||
42 | store: EditorStore; | ||
43 | |||
44 | constructor(store: EditorStore) { | ||
45 | this.resourceName = `${nanoid(7)}.problem`; | ||
46 | this.pendingUpdate = null; | ||
47 | this.store = store; | ||
48 | this.dirtyChanges = this.newEmptyChangeDesc(); | ||
49 | this.webSocketClient = new XtextWebSocketClient( | ||
50 | () => { | ||
51 | this.updateFullText().catch((error) => { | ||
52 | log.error('Unexpected error during initial update', error); | ||
53 | }); | ||
54 | }, | ||
55 | (resource, stateId, service, push) => { | ||
56 | this.onPush(resource, stateId, service, push).catch((error) => { | ||
57 | log.error('Unexected error during push message handling', error); | ||
58 | }); | ||
59 | }, | ||
60 | ); | ||
61 | } | ||
62 | |||
63 | onTransaction(transaction: Transaction): void { | ||
64 | const { changes } = transaction; | ||
65 | if (!changes.empty) { | ||
66 | this.dirtyChanges = this.dirtyChanges.composeDesc(changes.desc); | ||
67 | this.scheduleUpdate(); | ||
68 | } | ||
69 | } | ||
70 | |||
71 | private async onPush(resource: string, stateId: string, service: string, push: unknown) { | ||
72 | if (resource !== this.resourceName) { | ||
73 | log.error('Unknown resource name: expected:', this.resourceName, 'got:', resource); | ||
74 | return; | ||
75 | } | ||
76 | if (stateId !== this.xtextStateId) { | ||
77 | log.error('Unexpected xtext state id: expected:', this.xtextStateId, 'got:', resource); | ||
78 | await this.updateFullText(); | ||
79 | } | ||
80 | switch (service) { | ||
81 | case 'validate': | ||
82 | this.onValidate(push); | ||
83 | return; | ||
84 | case 'highlight': | ||
85 | // TODO | ||
86 | return; | ||
87 | default: | ||
88 | log.error('Unknown push service:', service); | ||
89 | break; | ||
90 | } | ||
91 | } | ||
92 | |||
93 | private onValidate(push: unknown) { | ||
94 | if (!isValidationResult(push)) { | ||
95 | log.error('Invalid validation result', push); | ||
96 | return; | ||
97 | } | ||
98 | const allChanges = this.computeChangesSinceLastUpdate(); | ||
99 | const diagnostics: Diagnostic[] = []; | ||
100 | push.issues.forEach((issue) => { | ||
101 | if (issue.severity === 'ignore') { | ||
102 | return; | ||
103 | } | ||
104 | diagnostics.push({ | ||
105 | from: allChanges.mapPos(issue.offset), | ||
106 | to: allChanges.mapPos(issue.offset + issue.length), | ||
107 | severity: issue.severity, | ||
108 | message: issue.description, | ||
109 | }); | ||
110 | }); | ||
111 | this.store.dispatch(setDiagnostics(this.store.state, diagnostics)); | ||
112 | } | ||
113 | |||
114 | private computeChangesSinceLastUpdate() { | ||
115 | if (this.pendingUpdate === null) { | ||
116 | return this.dirtyChanges; | ||
117 | } | ||
118 | return this.pendingUpdate.composeDesc(this.dirtyChanges); | ||
119 | } | ||
120 | |||
121 | private scheduleUpdate() { | ||
122 | if (this.updateTimeout !== null) { | ||
123 | clearTimeout(this.updateTimeout); | ||
124 | } | ||
125 | this.updateTimeout = setTimeout(() => { | ||
126 | this.updateTimeout = null; | ||
127 | if (!this.webSocketClient.isOpen || this.dirtyChanges.empty) { | ||
128 | return; | ||
129 | } | ||
130 | if (!this.pendingUpdate) { | ||
131 | this.updateDeltaText().catch((error) => { | ||
132 | log.error('Unexpected error during scheduled update', error); | ||
133 | }); | ||
134 | } | ||
135 | this.scheduleUpdate(); | ||
136 | }, UPDATE_TIMEOUT_MS); | ||
137 | } | ||
138 | |||
139 | private newEmptyChangeDesc() { | ||
140 | const changeSet = ChangeSet.of([], this.store.state.doc.length); | ||
141 | return changeSet.desc; | ||
142 | } | ||
143 | |||
144 | private async updateFullText() { | ||
145 | await this.withUpdate(async () => { | ||
146 | const result = await this.webSocketClient.send({ | ||
147 | resource: this.resourceName, | ||
148 | serviceType: 'update', | ||
149 | fullText: this.store.state.doc.sliceString(0), | ||
150 | }); | ||
151 | if (isDocumentStateResult(result)) { | ||
152 | return result.stateId; | ||
153 | } | ||
154 | if (isServiceConflictResult(result)) { | ||
155 | log.error('Full text update conflict:', result.conflict); | ||
156 | if (result.conflict === 'canceled') { | ||
157 | return UpdateAction.FullTextUpdate; | ||
158 | } | ||
159 | return UpdateAction.ForceReconnect; | ||
160 | } | ||
161 | log.error('Unexpected full text update result:', result); | ||
162 | return UpdateAction.ForceReconnect; | ||
163 | }); | ||
164 | } | ||
165 | |||
166 | private async updateDeltaText() { | ||
167 | if (this.xtextStateId === null) { | ||
168 | await this.updateFullText(); | ||
169 | return; | ||
170 | } | ||
171 | const delta = this.computeDelta(); | ||
172 | log.debug('Editor delta', delta); | ||
173 | await this.withUpdate(async () => { | ||
174 | const result = await this.webSocketClient.send({ | ||
175 | resource: this.resourceName, | ||
176 | serviceType: 'update', | ||
177 | requiredStateId: this.xtextStateId, | ||
178 | ...delta, | ||
179 | }); | ||
180 | if (isDocumentStateResult(result)) { | ||
181 | return result.stateId; | ||
182 | } | ||
183 | if (isServiceConflictResult(result)) { | ||
184 | log.error('Delta text update conflict:', result.conflict); | ||
185 | return UpdateAction.FullTextUpdate; | ||
186 | } | ||
187 | log.error('Unexpected delta text update result:', result); | ||
188 | return UpdateAction.ForceReconnect; | ||
189 | }); | ||
190 | } | ||
191 | |||
192 | private computeDelta() { | ||
193 | if (this.dirtyChanges.empty) { | ||
194 | return {}; | ||
195 | } | ||
196 | let minFromA = Number.MAX_SAFE_INTEGER; | ||
197 | let maxToA = 0; | ||
198 | let minFromB = Number.MAX_SAFE_INTEGER; | ||
199 | let maxToB = 0; | ||
200 | this.dirtyChanges.iterChangedRanges((fromA, toA, fromB, toB) => { | ||
201 | minFromA = Math.min(minFromA, fromA); | ||
202 | maxToA = Math.max(maxToA, toA); | ||
203 | minFromB = Math.min(minFromB, fromB); | ||
204 | maxToB = Math.max(maxToB, toB); | ||
205 | }); | ||
206 | return { | ||
207 | deltaOffset: minFromA, | ||
208 | deltaReplaceLength: maxToA - minFromA, | ||
209 | deltaText: this.store.state.doc.sliceString(minFromB, maxToB), | ||
210 | }; | ||
211 | } | ||
212 | |||
213 | private async withUpdate(callback: () => Promise<string | UpdateAction>) { | ||
214 | if (this.pendingUpdate !== null) { | ||
215 | log.error('Another update is pending, will not perform update'); | ||
216 | return; | ||
217 | } | ||
218 | this.pendingUpdate = this.dirtyChanges; | ||
219 | this.dirtyChanges = this.newEmptyChangeDesc(); | ||
220 | let newStateId: string | UpdateAction = UpdateAction.ForceReconnect; | ||
221 | try { | ||
222 | newStateId = await callback(); | ||
223 | } catch (error) { | ||
224 | log.error('Error while updating state', error); | ||
225 | } finally { | ||
226 | if (typeof newStateId === 'string') { | ||
227 | this.xtextStateId = newStateId; | ||
228 | this.pendingUpdate = null; | ||
229 | } else { | ||
230 | this.dirtyChanges = this.pendingUpdate.composeDesc(this.dirtyChanges); | ||
231 | this.pendingUpdate = null; | ||
232 | switch (newStateId) { | ||
233 | case UpdateAction.ForceReconnect: | ||
234 | this.webSocketClient.forceReconnectDueToError(); | ||
235 | break; | ||
236 | case UpdateAction.FullTextUpdate: | ||
237 | await this.updateFullText(); | ||
238 | break; | ||
239 | } | ||
240 | } | ||
241 | } | ||
242 | } | ||
243 | } | ||