aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2022-08-25 00:14:51 +0200
committerLibravatar Kristóf Marussy <kristof@marussy.com>2022-08-25 00:14:51 +0200
commit71491e03468795404751d2edfe58ce78714a5ed1 (patch)
tree14131145cdd704b606f3677e61981be6ab921241 /subprojects
parentfix(frontend): editor font thickness (diff)
downloadrefinery-71491e03468795404751d2edfe58ce78714a5ed1.tar.gz
refinery-71491e03468795404751d2edfe58ce78714a5ed1.tar.zst
refinery-71491e03468795404751d2edfe58ce78714a5ed1.zip
refactor(frontend): xtext update improvements
Diffstat (limited to 'subprojects')
-rw-r--r--subprojects/frontend/src/editor/findOccurrences.ts28
-rw-r--r--subprojects/frontend/src/utils/ConditionVariable.ts10
-rw-r--r--subprojects/frontend/src/utils/PendingTask.ts11
-rw-r--r--subprojects/frontend/src/utils/Timer.ts20
-rw-r--r--subprojects/frontend/src/xtext/ContentAssistService.ts39
-rw-r--r--subprojects/frontend/src/xtext/OccurrencesService.ts34
-rw-r--r--subprojects/frontend/src/xtext/UpdateService.ts193
-rw-r--r--subprojects/frontend/src/xtext/XtextWebSocketClient.ts71
8 files changed, 252 insertions, 154 deletions
diff --git a/subprojects/frontend/src/editor/findOccurrences.ts b/subprojects/frontend/src/editor/findOccurrences.ts
index d7aae8d1..08c078c2 100644
--- a/subprojects/frontend/src/editor/findOccurrences.ts
+++ b/subprojects/frontend/src/editor/findOccurrences.ts
@@ -1,4 +1,9 @@
1import { type Range, RangeSet, type TransactionSpec } from '@codemirror/state'; 1import {
2 type Range,
3 RangeSet,
4 type TransactionSpec,
5 type EditorState,
6} from '@codemirror/state';
2import { Decoration } from '@codemirror/view'; 7import { Decoration } from '@codemirror/view';
3 8
4import defineDecorationSetExtension from './defineDecorationSetExtension'; 9import defineDecorationSetExtension from './defineDecorationSetExtension';
@@ -34,4 +39,25 @@ export function setOccurrences(
34 return setOccurrencesInteral(rangeSet); 39 return setOccurrencesInteral(rangeSet);
35} 40}
36 41
42export function isCursorWithinOccurence(state: EditorState): boolean {
43 const occurrences = state.field(findOccurrences, false);
44 if (occurrences === undefined) {
45 return false;
46 }
47 const {
48 selection: {
49 main: { from, to },
50 },
51 } = state;
52 let found = false;
53 occurrences.between(from, to, (decorationFrom, decorationTo) => {
54 if (decorationFrom <= from && to <= decorationTo) {
55 found = true;
56 return false;
57 }
58 return undefined;
59 });
60 return found;
61}
62
37export default findOccurrences; 63export default findOccurrences;
diff --git a/subprojects/frontend/src/utils/ConditionVariable.ts b/subprojects/frontend/src/utils/ConditionVariable.ts
index c8fae9e8..1d3431f7 100644
--- a/subprojects/frontend/src/utils/ConditionVariable.ts
+++ b/subprojects/frontend/src/utils/ConditionVariable.ts
@@ -6,22 +6,22 @@ const log = getLogger('utils.ConditionVariable');
6export type Condition = () => boolean; 6export type Condition = () => boolean;
7 7
8export default class ConditionVariable { 8export default class ConditionVariable {
9 condition: Condition; 9 private readonly condition: Condition;
10 10
11 defaultTimeout: number; 11 private readonly defaultTimeout: number;
12 12
13 listeners: PendingTask<void>[] = []; 13 private listeners: PendingTask<void>[] = [];
14 14
15 constructor(condition: Condition, defaultTimeout = 0) { 15 constructor(condition: Condition, defaultTimeout = 0) {
16 this.condition = condition; 16 this.condition = condition;
17 this.defaultTimeout = defaultTimeout; 17 this.defaultTimeout = defaultTimeout;
18 } 18 }
19 19
20 async waitFor(timeoutMs: number | null = null): Promise<void> { 20 async waitFor(timeoutMs?: number | undefined): Promise<void> {
21 if (this.condition()) { 21 if (this.condition()) {
22 return; 22 return;
23 } 23 }
24 const timeoutOrDefault = timeoutMs || this.defaultTimeout; 24 const timeoutOrDefault = timeoutMs ?? this.defaultTimeout;
25 let nowMs = Date.now(); 25 let nowMs = Date.now();
26 const endMs = nowMs + timeoutOrDefault; 26 const endMs = nowMs + timeoutOrDefault;
27 while (!this.condition() && nowMs < endMs) { 27 while (!this.condition() && nowMs < endMs) {
diff --git a/subprojects/frontend/src/utils/PendingTask.ts b/subprojects/frontend/src/utils/PendingTask.ts
index 086993d4..3976bdf9 100644
--- a/subprojects/frontend/src/utils/PendingTask.ts
+++ b/subprojects/frontend/src/utils/PendingTask.ts
@@ -9,13 +9,13 @@ export default class PendingTask<T> {
9 9
10 private resolved = false; 10 private resolved = false;
11 11
12 private timeout: number | null; 12 private timeout: number | undefined;
13 13
14 constructor( 14 constructor(
15 resolveCallback: (value: T) => void, 15 resolveCallback: (value: T) => void,
16 rejectCallback: (reason?: unknown) => void, 16 rejectCallback: (reason?: unknown) => void,
17 timeoutMs?: number, 17 timeoutMs?: number | undefined,
18 timeoutCallback?: () => void, 18 timeoutCallback?: () => void | undefined,
19 ) { 19 ) {
20 this.resolveCallback = resolveCallback; 20 this.resolveCallback = resolveCallback;
21 this.rejectCallback = rejectCallback; 21 this.rejectCallback = rejectCallback;
@@ -28,8 +28,6 @@ export default class PendingTask<T> {
28 } 28 }
29 } 29 }
30 }, timeoutMs); 30 }, timeoutMs);
31 } else {
32 this.timeout = null;
33 } 31 }
34 } 32 }
35 33
@@ -53,8 +51,9 @@ export default class PendingTask<T> {
53 51
54 private markResolved() { 52 private markResolved() {
55 this.resolved = true; 53 this.resolved = true;
56 if (this.timeout !== null) { 54 if (this.timeout !== undefined) {
57 clearTimeout(this.timeout); 55 clearTimeout(this.timeout);
56 this.timeout = undefined;
58 } 57 }
59 } 58 }
60} 59}
diff --git a/subprojects/frontend/src/utils/Timer.ts b/subprojects/frontend/src/utils/Timer.ts
index 14e9eb81..4bb1bb9c 100644
--- a/subprojects/frontend/src/utils/Timer.ts
+++ b/subprojects/frontend/src/utils/Timer.ts
@@ -1,33 +1,33 @@
1export default class Timer { 1export default class Timer {
2 readonly callback: () => void; 2 private readonly callback: () => void;
3 3
4 readonly defaultTimeout: number; 4 private readonly defaultTimeout: number;
5 5
6 timeout: number | null = null; 6 private timeout: number | undefined;
7 7
8 constructor(callback: () => void, defaultTimeout = 0) { 8 constructor(callback: () => void, defaultTimeout = 0) {
9 this.callback = () => { 9 this.callback = () => {
10 this.timeout = null; 10 this.timeout = undefined;
11 callback(); 11 callback();
12 }; 12 };
13 this.defaultTimeout = defaultTimeout; 13 this.defaultTimeout = defaultTimeout;
14 } 14 }
15 15
16 schedule(timeout: number | null = null): void { 16 schedule(timeout?: number | undefined): void {
17 if (this.timeout === null) { 17 if (this.timeout === undefined) {
18 this.timeout = setTimeout(this.callback, timeout || this.defaultTimeout); 18 this.timeout = setTimeout(this.callback, timeout ?? this.defaultTimeout);
19 } 19 }
20 } 20 }
21 21
22 reschedule(timeout: number | null = null): void { 22 reschedule(timeout?: number | undefined): void {
23 this.cancel(); 23 this.cancel();
24 this.schedule(timeout); 24 this.schedule(timeout);
25 } 25 }
26 26
27 cancel(): void { 27 cancel(): void {
28 if (this.timeout !== null) { 28 if (this.timeout !== undefined) {
29 clearTimeout(this.timeout); 29 clearTimeout(this.timeout);
30 this.timeout = null; 30 this.timeout = undefined;
31 } 31 }
32 } 32 }
33} 33}
diff --git a/subprojects/frontend/src/xtext/ContentAssistService.ts b/subprojects/frontend/src/xtext/ContentAssistService.ts
index dce2a902..39042812 100644
--- a/subprojects/frontend/src/xtext/ContentAssistService.ts
+++ b/subprojects/frontend/src/xtext/ContentAssistService.ts
@@ -31,15 +31,12 @@ interface IFoundToken {
31 text: string; 31 text: string;
32} 32}
33 33
34function findToken({ pos, state }: CompletionContext): IFoundToken | null { 34function findToken({ pos, state }: CompletionContext): IFoundToken | undefined {
35 const token = syntaxTree(state).resolveInner(pos, -1); 35 const token = syntaxTree(state).resolveInner(pos, -1);
36 if (token === null) {
37 return null;
38 }
39 if (token.firstChild !== null) { 36 if (token.firstChild !== null) {
40 // We only autocomplete terminal nodes. If the current node is nonterminal, 37 // We only autocomplete terminal nodes. If the current node is nonterminal,
41 // returning `null` makes us autocomplete with the empty prefix instead. 38 // returning `undefined` makes us autocomplete with the empty prefix instead.
42 return null; 39 return undefined;
43 } 40 }
44 return { 41 return {
45 from: token.from, 42 from: token.from,
@@ -50,11 +47,13 @@ function findToken({ pos, state }: CompletionContext): IFoundToken | null {
50} 47}
51 48
52function shouldCompleteImplicitly( 49function shouldCompleteImplicitly(
53 token: IFoundToken | null, 50 token: IFoundToken | undefined,
54 context: CompletionContext, 51 context: CompletionContext,
55): boolean { 52): boolean {
56 return ( 53 return (
57 token !== null && token.implicitCompletion && context.pos - token.from >= 2 54 token !== undefined &&
55 token.implicitCompletion &&
56 context.pos - token.from >= 2
58 ); 57 );
59} 58}
60 59
@@ -107,7 +106,7 @@ function createCompletion(entry: ContentAssistEntry): Completion {
107export default class ContentAssistService { 106export default class ContentAssistService {
108 private readonly updateService: UpdateService; 107 private readonly updateService: UpdateService;
109 108
110 private lastCompletion: CompletionResult | null = null; 109 private lastCompletion: CompletionResult | undefined;
111 110
112 constructor(updateService: UpdateService) { 111 constructor(updateService: UpdateService) {
113 this.updateService = updateService; 112 this.updateService = updateService;
@@ -115,7 +114,7 @@ export default class ContentAssistService {
115 114
116 onTransaction(transaction: Transaction): void { 115 onTransaction(transaction: Transaction): void {
117 if (this.shouldInvalidateCachedCompletion(transaction)) { 116 if (this.shouldInvalidateCachedCompletion(transaction)) {
118 this.lastCompletion = null; 117 this.lastCompletion = undefined;
119 } 118 }
120 } 119 }
121 120
@@ -129,7 +128,7 @@ export default class ContentAssistService {
129 } 128 }
130 let range: { from: number; to: number }; 129 let range: { from: number; to: number };
131 let prefix = ''; 130 let prefix = '';
132 if (tokenBefore === null) { 131 if (tokenBefore === undefined) {
133 range = { 132 range = {
134 from: context.pos, 133 from: context.pos,
135 to: context.pos, 134 to: context.pos,
@@ -146,14 +145,18 @@ export default class ContentAssistService {
146 } 145 }
147 } 146 }
148 if (!context.explicit && this.shouldReturnCachedCompletion(tokenBefore)) { 147 if (!context.explicit && this.shouldReturnCachedCompletion(tokenBefore)) {
148 if (this.lastCompletion === undefined) {
149 throw new Error(
150 'There is no cached completion, but we want to return it',
151 );
152 }
149 log.trace('Returning cached completion result'); 153 log.trace('Returning cached completion result');
150 // Postcondition of `shouldReturnCachedCompletion`: `lastCompletion !== null`
151 return { 154 return {
152 ...(this.lastCompletion as CompletionResult), 155 ...this.lastCompletion,
153 ...range, 156 ...range,
154 }; 157 };
155 } 158 }
156 this.lastCompletion = null; 159 this.lastCompletion = undefined;
157 const entries = await this.updateService.fetchContentAssist( 160 const entries = await this.updateService.fetchContentAssist(
158 { 161 {
159 resource: this.updateService.resourceName, 162 resource: this.updateService.resourceName,
@@ -188,9 +191,9 @@ export default class ContentAssistService {
188 } 191 }
189 192
190 private shouldReturnCachedCompletion( 193 private shouldReturnCachedCompletion(
191 token: { from: number; to: number; text: string } | null, 194 token: { from: number; to: number; text: string } | undefined,
192 ): boolean { 195 ): boolean {
193 if (token === null || this.lastCompletion === null) { 196 if (token === undefined || this.lastCompletion === undefined) {
194 return false; 197 return false;
195 } 198 }
196 const { from, to, text } = token; 199 const { from, to, text } = token;
@@ -211,11 +214,11 @@ export default class ContentAssistService {
211 } 214 }
212 215
213 private shouldInvalidateCachedCompletion(transaction: Transaction): boolean { 216 private shouldInvalidateCachedCompletion(transaction: Transaction): boolean {
214 if (!transaction.docChanged || this.lastCompletion === null) { 217 if (!transaction.docChanged || this.lastCompletion === undefined) {
215 return false; 218 return false;
216 } 219 }
217 const { from: lastFrom, to: lastTo } = this.lastCompletion; 220 const { from: lastFrom, to: lastTo } = this.lastCompletion;
218 if (!lastTo) { 221 if (lastTo === undefined) {
219 return true; 222 return true;
220 } 223 }
221 const [transformedFrom, transformedTo] = this.mapRangeInclusive( 224 const [transformedFrom, transformedTo] = this.mapRangeInclusive(
diff --git a/subprojects/frontend/src/xtext/OccurrencesService.ts b/subprojects/frontend/src/xtext/OccurrencesService.ts
index 21fe8644..35913f43 100644
--- a/subprojects/frontend/src/xtext/OccurrencesService.ts
+++ b/subprojects/frontend/src/xtext/OccurrencesService.ts
@@ -1,7 +1,10 @@
1import { Transaction } from '@codemirror/state'; 1import { Transaction } from '@codemirror/state';
2 2
3import type EditorStore from '../editor/EditorStore'; 3import type EditorStore from '../editor/EditorStore';
4import type { IOccurrence } from '../editor/findOccurrences'; 4import {
5 type IOccurrence,
6 isCursorWithinOccurence,
7} from '../editor/findOccurrences';
5import Timer from '../utils/Timer'; 8import Timer from '../utils/Timer';
6import getLogger from '../utils/getLogger'; 9import getLogger from '../utils/getLogger';
7 10
@@ -15,10 +18,6 @@ import {
15 18
16const FIND_OCCURRENCES_TIMEOUT_MS = 1000; 19const FIND_OCCURRENCES_TIMEOUT_MS = 1000;
17 20
18// Must clear occurrences asynchronously from `onTransaction`,
19// because we must not emit a conflicting transaction when handling the pending transaction.
20const CLEAR_OCCURRENCES_TIMEOUT_MS = 10;
21
22const log = getLogger('xtext.OccurrencesService'); 21const log = getLogger('xtext.OccurrencesService');
23 22
24function transformOccurrences(regions: TextRegion[]): IOccurrence[] { 23function transformOccurrences(regions: TextRegion[]): IOccurrence[] {
@@ -49,7 +48,7 @@ export default class OccurrencesService {
49 48
50 private readonly clearOccurrencesTimer = new Timer(() => { 49 private readonly clearOccurrencesTimer = new Timer(() => {
51 this.clearOccurrences(); 50 this.clearOccurrences();
52 }, CLEAR_OCCURRENCES_TIMEOUT_MS); 51 });
53 52
54 constructor( 53 constructor(
55 store: EditorStore, 54 store: EditorStore,
@@ -63,12 +62,27 @@ export default class OccurrencesService {
63 62
64 onTransaction(transaction: Transaction): void { 63 onTransaction(transaction: Transaction): void {
65 if (transaction.docChanged) { 64 if (transaction.docChanged) {
65 // Must clear occurrences asynchronously from `onTransaction`,
66 // because we must not emit a conflicting transaction when handling the pending transaction.
66 this.clearOccurrencesTimer.schedule(); 67 this.clearOccurrencesTimer.schedule();
67 this.findOccurrencesTimer.reschedule(); 68 this.findOccurrencesTimer.reschedule();
69 return;
68 } 70 }
69 if (transaction.isUserEvent('select')) { 71 if (!transaction.isUserEvent('select')) {
70 this.findOccurrencesTimer.reschedule(); 72 return;
71 } 73 }
74 if (this.needsOccurrences) {
75 if (!isCursorWithinOccurence(this.store.state)) {
76 this.clearOccurrencesTimer.schedule();
77 this.findOccurrencesTimer.reschedule();
78 }
79 } else {
80 this.clearOccurrencesTimer.schedule();
81 }
82 }
83
84 private get needsOccurrences(): boolean {
85 return this.store.state.selection.main.empty;
72 } 86 }
73 87
74 private handleFindOccurrences() { 88 private handleFindOccurrences() {
@@ -80,6 +94,10 @@ export default class OccurrencesService {
80 } 94 }
81 95
82 private async updateOccurrences() { 96 private async updateOccurrences() {
97 if (!this.needsOccurrences) {
98 this.clearOccurrences();
99 return;
100 }
83 await this.updateService.update(); 101 await this.updateService.update();
84 const result = await this.webSocketClient.send({ 102 const result = await this.webSocketClient.send({
85 resource: this.updateService.resourceName, 103 resource: this.updateService.resourceName,
diff --git a/subprojects/frontend/src/xtext/UpdateService.ts b/subprojects/frontend/src/xtext/UpdateService.ts
index 2994b11b..f8b71160 100644
--- a/subprojects/frontend/src/xtext/UpdateService.ts
+++ b/subprojects/frontend/src/xtext/UpdateService.ts
@@ -27,24 +27,47 @@ const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000;
27 27
28const log = getLogger('xtext.UpdateService'); 28const log = getLogger('xtext.UpdateService');
29 29
30/**
31 * State effect used to override the dirty changes after a transaction.
32 *
33 * If this state effect is _not_ present in a transaction,
34 * the transaction will be appended to the current dirty changes.
35 *
36 * If this state effect is present, the current dirty changes will be replaced
37 * by the value of this effect.
38 */
30const setDirtyChanges = StateEffect.define<ChangeSet>(); 39const setDirtyChanges = StateEffect.define<ChangeSet>();
31 40
32export interface IAbortSignal { 41export interface IAbortSignal {
33 aborted: boolean; 42 aborted: boolean;
34} 43}
35 44
45interface StateUpdateResult<T> {
46 newStateId: string;
47
48 data: T;
49}
50
51interface Delta {
52 deltaOffset: number;
53
54 deltaReplaceLength: number;
55
56 deltaText: string;
57}
58
36export default class UpdateService { 59export default class UpdateService {
37 resourceName: string; 60 resourceName: string;
38 61
39 xtextStateId: string | null = null; 62 xtextStateId: string | undefined;
40 63
41 private readonly store: EditorStore; 64 private readonly store: EditorStore;
42 65
43 /** 66 /**
44 * The changes being synchronized to the server if a full or delta text update is running, 67 * The changes being synchronized to the server if a full or delta text update is running,
45 * `null` otherwise. 68 * `undefined` otherwise.
46 */ 69 */
47 private pendingUpdate: ChangeSet | null = null; 70 private pendingUpdate: ChangeSet | undefined;
48 71
49 /** 72 /**
50 * Local changes not yet sychronized to the server and not part of the running update, if any. 73 * Local changes not yet sychronized to the server and not part of the running update, if any.
@@ -54,7 +77,7 @@ export default class UpdateService {
54 private readonly webSocketClient: XtextWebSocketClient; 77 private readonly webSocketClient: XtextWebSocketClient;
55 78
56 private readonly updatedCondition = new ConditionVariable( 79 private readonly updatedCondition = new ConditionVariable(
57 () => this.pendingUpdate === null && this.xtextStateId !== null, 80 () => this.pendingUpdate === undefined && this.xtextStateId !== undefined,
58 WAIT_FOR_UPDATE_TIMEOUT_MS, 81 WAIT_FOR_UPDATE_TIMEOUT_MS,
59 ); 82 );
60 83
@@ -70,7 +93,7 @@ export default class UpdateService {
70 } 93 }
71 94
72 onReconnect(): void { 95 onReconnect(): void {
73 this.xtextStateId = null; 96 this.xtextStateId = undefined;
74 this.updateFullText().catch((error) => { 97 this.updateFullText().catch((error) => {
75 log.error('Unexpected error during initial update', error); 98 log.error('Unexpected error during initial update', error);
76 }); 99 });
@@ -82,7 +105,7 @@ export default class UpdateService {
82 ) as StateEffect<ChangeSet> | undefined; 105 ) as StateEffect<ChangeSet> | undefined;
83 if (setDirtyChangesEffect) { 106 if (setDirtyChangesEffect) {
84 const { value } = setDirtyChangesEffect; 107 const { value } = setDirtyChangesEffect;
85 if (this.pendingUpdate !== null) { 108 if (this.pendingUpdate !== undefined) {
86 this.pendingUpdate = ChangeSet.empty(value.length); 109 this.pendingUpdate = ChangeSet.empty(value.length);
87 } 110 }
88 this.dirtyChanges = value; 111 this.dirtyChanges = value;
@@ -100,20 +123,20 @@ export default class UpdateService {
100 * The result reflects any changes that happened since the `xtextStateId` 123 * The result reflects any changes that happened since the `xtextStateId`
101 * version was uploaded to the server. 124 * version was uploaded to the server.
102 * 125 *
103 * @return the summary of changes since the last update 126 * @returns the summary of changes since the last update
104 */ 127 */
105 computeChangesSinceLastUpdate(): ChangeDesc { 128 computeChangesSinceLastUpdate(): ChangeDesc {
106 return ( 129 return (
107 this.pendingUpdate?.composeDesc(this.dirtyChanges.desc) || 130 this.pendingUpdate?.composeDesc(this.dirtyChanges.desc) ??
108 this.dirtyChanges.desc 131 this.dirtyChanges.desc
109 ); 132 );
110 } 133 }
111 134
112 private handleIdleUpdate() { 135 private handleIdleUpdate(): void {
113 if (!this.webSocketClient.isOpen || this.dirtyChanges.empty) { 136 if (!this.webSocketClient.isOpen || this.dirtyChanges.empty) {
114 return; 137 return;
115 } 138 }
116 if (this.pendingUpdate === null) { 139 if (this.pendingUpdate === undefined) {
117 this.update().catch((error) => { 140 this.update().catch((error) => {
118 log.error('Unexpected error during scheduled update', error); 141 log.error('Unexpected error during scheduled update', error);
119 }); 142 });
@@ -121,7 +144,7 @@ export default class UpdateService {
121 this.idleUpdateTimer.reschedule(); 144 this.idleUpdateTimer.reschedule();
122 } 145 }
123 146
124 private newEmptyChangeSet() { 147 private newEmptyChangeSet(): ChangeSet {
125 return ChangeSet.of([], this.store.state.doc.length); 148 return ChangeSet.of([], this.store.state.doc.length);
126 } 149 }
127 150
@@ -129,14 +152,14 @@ export default class UpdateService {
129 await this.withUpdate(() => this.doUpdateFullText()); 152 await this.withUpdate(() => this.doUpdateFullText());
130 } 153 }
131 154
132 private async doUpdateFullText(): Promise<[string, void]> { 155 private async doUpdateFullText(): Promise<StateUpdateResult<void>> {
133 const result = await this.webSocketClient.send({ 156 const result = await this.webSocketClient.send({
134 resource: this.resourceName, 157 resource: this.resourceName,
135 serviceType: 'update', 158 serviceType: 'update',
136 fullText: this.store.state.doc.sliceString(0), 159 fullText: this.store.state.doc.sliceString(0),
137 }); 160 });
138 const { stateId } = DocumentStateResult.parse(result); 161 const { stateId } = DocumentStateResult.parse(result);
139 return [stateId, undefined]; 162 return { newStateId: stateId, data: undefined };
140 } 163 }
141 164
142 /** 165 /**
@@ -146,12 +169,12 @@ export default class UpdateService {
146 * Performs either an update with delta text or a full text update if needed. 169 * Performs either an update with delta text or a full text update if needed.
147 * If there are not local dirty changes, the promise resolves immediately. 170 * If there are not local dirty changes, the promise resolves immediately.
148 * 171 *
149 * @return a promise resolving when the update is completed 172 * @returns a promise resolving when the update is completed
150 */ 173 */
151 async update(): Promise<void> { 174 async update(): Promise<void> {
152 await this.prepareForDeltaUpdate(); 175 await this.prepareForDeltaUpdate();
153 const delta = this.computeDelta(); 176 const delta = this.computeDelta();
154 if (delta === null) { 177 if (delta === undefined) {
155 return; 178 return;
156 } 179 }
157 log.trace('Editor delta', delta); 180 log.trace('Editor delta', delta);
@@ -164,7 +187,10 @@ export default class UpdateService {
164 }); 187 });
165 const parsedDocumentStateResult = DocumentStateResult.safeParse(result); 188 const parsedDocumentStateResult = DocumentStateResult.safeParse(result);
166 if (parsedDocumentStateResult.success) { 189 if (parsedDocumentStateResult.success) {
167 return [parsedDocumentStateResult.data.stateId, undefined]; 190 return {
191 newStateId: parsedDocumentStateResult.data.stateId,
192 data: undefined,
193 };
168 } 194 }
169 if (isConflictResult(result, 'invalidStateId')) { 195 if (isConflictResult(result, 'invalidStateId')) {
170 return this.doFallbackToUpdateFullText(); 196 return this.doFallbackToUpdateFullText();
@@ -173,12 +199,12 @@ export default class UpdateService {
173 }); 199 });
174 } 200 }
175 201
176 private doFallbackToUpdateFullText() { 202 private doFallbackToUpdateFullText(): Promise<StateUpdateResult<void>> {
177 if (this.pendingUpdate === null) { 203 if (this.pendingUpdate === undefined) {
178 throw new Error('Only a pending update can be extended'); 204 throw new Error('Only a pending update can be extended');
179 } 205 }
180 log.warn('Delta update failed, performing full text update'); 206 log.warn('Delta update failed, performing full text update');
181 this.xtextStateId = null; 207 this.xtextStateId = undefined;
182 this.pendingUpdate = this.pendingUpdate.compose(this.dirtyChanges); 208 this.pendingUpdate = this.pendingUpdate.compose(this.dirtyChanges);
183 this.dirtyChanges = this.newEmptyChangeSet(); 209 this.dirtyChanges = this.newEmptyChangeSet();
184 return this.doUpdateFullText(); 210 return this.doUpdateFullText();
@@ -193,56 +219,69 @@ export default class UpdateService {
193 return []; 219 return [];
194 } 220 }
195 const delta = this.computeDelta(); 221 const delta = this.computeDelta();
196 if (delta !== null) { 222 if (delta !== undefined) {
197 log.trace('Editor delta', delta); 223 log.trace('Editor delta', delta);
198 const entries = await this.withUpdate(async () => { 224 // Try to fetch while also performing a delta update.
199 const result = await this.webSocketClient.send({ 225 const fetchUpdateEntries = await this.withUpdate(() =>
200 ...params, 226 this.doFetchContentAssistWithDelta(params, delta),
201 requiredStateId: this.xtextStateId, 227 );
202 ...delta, 228 if (fetchUpdateEntries !== undefined) {
203 }); 229 return fetchUpdateEntries;
204 const parsedContentAssistResult = ContentAssistResult.safeParse(result);
205 if (parsedContentAssistResult.success) {
206 const { stateId, entries: resultEntries } =
207 parsedContentAssistResult.data;
208 return [stateId, resultEntries];
209 }
210 if (isConflictResult(result, 'invalidStateId')) {
211 log.warn('Server state invalid during content assist');
212 const [newStateId] = await this.doFallbackToUpdateFullText();
213 // We must finish this state update transaction to prepare for any push events
214 // before querying for content assist, so we just return `null` and will query
215 // the content assist service later.
216 return [newStateId, null];
217 }
218 throw parsedContentAssistResult.error;
219 });
220 if (entries !== null) {
221 return entries;
222 } 230 }
223 if (signal.aborted) { 231 if (signal.aborted) {
224 return []; 232 return [];
225 } 233 }
226 } 234 }
227 // Poscondition of `prepareForDeltaUpdate`: `xtextStateId !== null` 235 if (this.xtextStateId === undefined) {
228 return this.doFetchContentAssist(params, this.xtextStateId as string); 236 throw new Error('failed to obtain Xtext state id');
237 }
238 return this.doFetchContentAssistFetchOnly(params, this.xtextStateId);
229 } 239 }
230 240
231 private async doFetchContentAssist( 241 private async doFetchContentAssistWithDelta(
232 params: Record<string, unknown>, 242 params: Record<string, unknown>,
233 expectedStateId: string, 243 delta: Delta,
234 ) { 244 ): Promise<StateUpdateResult<ContentAssistEntry[] | undefined>> {
235 const result = await this.webSocketClient.send({ 245 const fetchUpdateResult = await this.webSocketClient.send({
246 ...params,
247 requiredStateId: this.xtextStateId,
248 ...delta,
249 });
250 const parsedContentAssistResult =
251 ContentAssistResult.safeParse(fetchUpdateResult);
252 if (parsedContentAssistResult.success) {
253 const { stateId, entries: resultEntries } =
254 parsedContentAssistResult.data;
255 return { newStateId: stateId, data: resultEntries };
256 }
257 if (isConflictResult(fetchUpdateResult, 'invalidStateId')) {
258 log.warn('Server state invalid during content assist');
259 const { newStateId } = await this.doFallbackToUpdateFullText();
260 // We must finish this state update transaction to prepare for any push events
261 // before querying for content assist, so we just return `undefined` and will query
262 // the content assist service later.
263 return { newStateId, data: undefined };
264 }
265 throw parsedContentAssistResult.error;
266 }
267
268 private async doFetchContentAssistFetchOnly(
269 params: Record<string, unknown>,
270 requiredStateId: string,
271 ): Promise<ContentAssistEntry[]> {
272 // Fallback to fetching without a delta update.
273 const fetchOnlyResult = await this.webSocketClient.send({
236 ...params, 274 ...params,
237 requiredStateId: expectedStateId, 275 requiredStateId: this.xtextStateId,
238 }); 276 });
239 const { stateId, entries } = ContentAssistResult.parse(result); 277 const { stateId, entries: fetchOnlyEntries } =
240 if (stateId !== expectedStateId) { 278 ContentAssistResult.parse(fetchOnlyResult);
279 if (stateId !== requiredStateId) {
241 throw new Error( 280 throw new Error(
242 `Unexpected state id, expected: ${expectedStateId} got: ${stateId}`, 281 `Unexpected state id, expected: ${requiredStateId} got: ${stateId}`,
243 ); 282 );
244 } 283 }
245 return entries; 284 return fetchOnlyEntries;
246 } 285 }
247 286
248 async formatText(): Promise<void> { 287 async formatText(): Promise<void> {
@@ -253,7 +292,7 @@ export default class UpdateService {
253 to = this.store.state.doc.length; 292 to = this.store.state.doc.length;
254 } 293 }
255 log.debug('Formatting from', from, 'to', to); 294 log.debug('Formatting from', from, 'to', to);
256 await this.withUpdate(async () => { 295 await this.withUpdate<void>(async () => {
257 const result = await this.webSocketClient.send({ 296 const result = await this.webSocketClient.send({
258 resource: this.resourceName, 297 resource: this.resourceName,
259 serviceType: 'format', 298 serviceType: 'format',
@@ -266,13 +305,13 @@ export default class UpdateService {
266 to, 305 to,
267 insert: formattedText, 306 insert: formattedText,
268 }); 307 });
269 return [stateId, null]; 308 return { newStateId: stateId, data: undefined };
270 }); 309 });
271 } 310 }
272 311
273 private computeDelta() { 312 private computeDelta(): Delta | undefined {
274 if (this.dirtyChanges.empty) { 313 if (this.dirtyChanges.empty) {
275 return null; 314 return undefined;
276 } 315 }
277 let minFromA = Number.MAX_SAFE_INTEGER; 316 let minFromA = Number.MAX_SAFE_INTEGER;
278 let maxToA = 0; 317 let maxToA = 0;
@@ -291,15 +330,17 @@ export default class UpdateService {
291 }; 330 };
292 } 331 }
293 332
294 private applyBeforeDirtyChanges(changeSpec: ChangeSpec) { 333 private applyBeforeDirtyChanges(changeSpec: ChangeSpec): void {
295 const pendingChanges = 334 const pendingChanges =
296 this.pendingUpdate?.compose(this.dirtyChanges) || this.dirtyChanges; 335 this.pendingUpdate?.compose(this.dirtyChanges) ?? this.dirtyChanges;
297 const revertChanges = pendingChanges.invert(this.store.state.doc); 336 const revertChanges = pendingChanges.invert(this.store.state.doc);
298 const applyBefore = ChangeSet.of(changeSpec, revertChanges.newLength); 337 const applyBefore = ChangeSet.of(changeSpec, revertChanges.newLength);
299 const redoChanges = pendingChanges.map(applyBefore.desc); 338 const redoChanges = pendingChanges.map(applyBefore.desc);
300 const changeSet = revertChanges.compose(applyBefore).compose(redoChanges); 339 const changeSet = revertChanges.compose(applyBefore).compose(redoChanges);
301 this.store.dispatch({ 340 this.store.dispatch({
302 changes: changeSet, 341 changes: changeSet,
342 // Keep the current set of dirty changes (but update them according the re-formatting)
343 // and to not add the formatting the dirty changes.
303 effects: [setDirtyChanges.of(redoChanges)], 344 effects: [setDirtyChanges.of(redoChanges)],
304 }); 345 });
305 } 346 }
@@ -318,37 +359,35 @@ export default class UpdateService {
318 * to ensure that the local `stateId` is updated likewise to be able to handle 359 * to ensure that the local `stateId` is updated likewise to be able to handle
319 * push messages referring to the new `stateId` from the server. 360 * push messages referring to the new `stateId` from the server.
320 * If additional work is needed to compute the second value in some cases, 361 * If additional work is needed to compute the second value in some cases,
321 * use `T | null` instead of `T` as a return type and signal the need for additional 362 * use `T | undefined` instead of `T` as a return type and signal the need for additional
322 * computations by returning `null`. Thus additional computations can be performed 363 * computations by returning `undefined`. Thus additional computations can be performed
323 * outside of the critical section. 364 * outside of the critical section.
324 * 365 *
325 * @param callback the asynchronous callback that updates the server state 366 * @param callback the asynchronous callback that updates the server state
326 * @return a promise resolving to the second value returned by `callback` 367 * @returns a promise resolving to the second value returned by `callback`
327 */ 368 */
328 private async withUpdate<T>( 369 private async withUpdate<T>(
329 callback: () => Promise<[string, T]>, 370 callback: () => Promise<StateUpdateResult<T>>,
330 ): Promise<T> { 371 ): Promise<T> {
331 if (this.pendingUpdate !== null) { 372 if (this.pendingUpdate !== undefined) {
332 throw new Error('Another update is pending, will not perform update'); 373 throw new Error('Another update is pending, will not perform update');
333 } 374 }
334 this.pendingUpdate = this.dirtyChanges; 375 this.pendingUpdate = this.dirtyChanges;
335 this.dirtyChanges = this.newEmptyChangeSet(); 376 this.dirtyChanges = this.newEmptyChangeSet();
336 let newStateId: string | null = null;
337 try { 377 try {
338 let result: T; 378 const { newStateId, data } = await callback();
339 [newStateId, result] = await callback();
340 this.xtextStateId = newStateId; 379 this.xtextStateId = newStateId;
341 this.pendingUpdate = null; 380 this.pendingUpdate = undefined;
342 this.updatedCondition.notifyAll(); 381 this.updatedCondition.notifyAll();
343 return result; 382 return data;
344 } catch (e) { 383 } catch (e) {
345 log.error('Error while update', e); 384 log.error('Error while update', e);
346 if (this.pendingUpdate === null) { 385 if (this.pendingUpdate === undefined) {
347 log.error('pendingUpdate was cleared during update'); 386 log.error('pendingUpdate was cleared during update');
348 } else { 387 } else {
349 this.dirtyChanges = this.pendingUpdate.compose(this.dirtyChanges); 388 this.dirtyChanges = this.pendingUpdate.compose(this.dirtyChanges);
350 } 389 }
351 this.pendingUpdate = null; 390 this.pendingUpdate = undefined;
352 this.webSocketClient.forceReconnectOnError(); 391 this.webSocketClient.forceReconnectOnError();
353 this.updatedCondition.rejectAll(e); 392 this.updatedCondition.rejectAll(e);
354 throw e; 393 throw e;
@@ -357,16 +396,16 @@ export default class UpdateService {
357 396
358 /** 397 /**
359 * Ensures that there is some state available on the server (`xtextStateId`) 398 * Ensures that there is some state available on the server (`xtextStateId`)
360 * and that there is not pending update. 399 * and that there is no pending update.
361 * 400 *
362 * After this function resolves, a delta text update is possible. 401 * After this function resolves, a delta text update is possible.
363 * 402 *
364 * @return a promise resolving when there is a valid state id but no pending update 403 * @returns a promise resolving when there is a valid state id but no pending update
365 */ 404 */
366 private async prepareForDeltaUpdate() { 405 private async prepareForDeltaUpdate(): Promise<void> {
367 // If no update is pending, but the full text hasn't been uploaded to the server yet, 406 // If no update is pending, but the full text hasn't been uploaded to the server yet,
368 // we must start a full text upload. 407 // we must start a full text upload.
369 if (this.pendingUpdate === null && this.xtextStateId === null) { 408 if (this.pendingUpdate === undefined && this.xtextStateId === undefined) {
370 await this.updateFullText(); 409 await this.updateFullText();
371 } 410 }
372 await this.updatedCondition.waitFor(); 411 await this.updatedCondition.waitFor();
diff --git a/subprojects/frontend/src/xtext/XtextWebSocketClient.ts b/subprojects/frontend/src/xtext/XtextWebSocketClient.ts
index ceb1f3fd..60bf6ba9 100644
--- a/subprojects/frontend/src/xtext/XtextWebSocketClient.ts
+++ b/subprojects/frontend/src/xtext/XtextWebSocketClient.ts
@@ -17,6 +17,8 @@ const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1';
17 17
18const WEBSOCKET_CLOSE_OK = 1000; 18const WEBSOCKET_CLOSE_OK = 1000;
19 19
20const WEBSOCKET_CLOSE_GOING_AWAY = 1001;
21
20const RECONNECT_DELAY_MS = [200, 1000, 5000, 30_000]; 22const RECONNECT_DELAY_MS = [200, 1000, 5000, 30_000];
21 23
22const MAX_RECONNECT_DELAY_MS = 24const MAX_RECONNECT_DELAY_MS =
@@ -44,9 +46,9 @@ enum State {
44 Opening, 46 Opening,
45 TabVisible, 47 TabVisible,
46 TabHiddenIdle, 48 TabHiddenIdle,
47 TabHiddenWaiting, 49 TabHiddenWaitingToClose,
48 Error, 50 Error,
49 TimedOut, 51 ClosedDueToInactivity,
50} 52}
51 53
52export default class XtextWebSocketClient { 54export default class XtextWebSocketClient {
@@ -86,14 +88,16 @@ export default class XtextWebSocketClient {
86 } 88 }
87 89
88 private get isLogicallyClosed(): boolean { 90 private get isLogicallyClosed(): boolean {
89 return this.state === State.Error || this.state === State.TimedOut; 91 return (
92 this.state === State.Error || this.state === State.ClosedDueToInactivity
93 );
90 } 94 }
91 95
92 get isOpen(): boolean { 96 get isOpen(): boolean {
93 return ( 97 return (
94 this.state === State.TabVisible || 98 this.state === State.TabVisible ||
95 this.state === State.TabHiddenIdle || 99 this.state === State.TabHiddenIdle ||
96 this.state === State.TabHiddenWaiting 100 this.state === State.TabHiddenWaitingToClose
97 ); 101 );
98 } 102 }
99 103
@@ -134,11 +138,15 @@ export default class XtextWebSocketClient {
134 this.handleMessage(event.data); 138 this.handleMessage(event.data);
135 }); 139 });
136 this.connection.addEventListener('close', (event) => { 140 this.connection.addEventListener('close', (event) => {
137 if ( 141 const closedOnRequest =
138 this.isLogicallyClosed && 142 this.isLogicallyClosed &&
139 event.code === WEBSOCKET_CLOSE_OK && 143 event.code === WEBSOCKET_CLOSE_OK &&
140 this.pendingRequests.size === 0 144 this.pendingRequests.size === 0;
141 ) { 145 const closedOnNavigation = event.code === WEBSOCKET_CLOSE_GOING_AWAY;
146 if (closedOnNavigation) {
147 this.state = State.ClosedDueToInactivity;
148 }
149 if (closedOnRequest || closedOnNavigation) {
142 log.info('Websocket closed'); 150 log.info('Websocket closed');
143 return; 151 return;
144 } 152 }
@@ -157,12 +165,12 @@ export default class XtextWebSocketClient {
157 this.idleTimer.cancel(); 165 this.idleTimer.cancel();
158 if ( 166 if (
159 this.state === State.TabHiddenIdle || 167 this.state === State.TabHiddenIdle ||
160 this.state === State.TabHiddenWaiting 168 this.state === State.TabHiddenWaitingToClose
161 ) { 169 ) {
162 this.handleTabVisibleConnected(); 170 this.handleTabVisibleConnected();
163 return; 171 return;
164 } 172 }
165 if (this.state === State.TimedOut) { 173 if (this.state === State.ClosedDueToInactivity) {
166 this.reconnect(); 174 this.reconnect();
167 } 175 }
168 } 176 }
@@ -181,19 +189,19 @@ export default class XtextWebSocketClient {
181 private handleIdleTimeout() { 189 private handleIdleTimeout() {
182 log.trace('Waiting for pending tasks before disconnect'); 190 log.trace('Waiting for pending tasks before disconnect');
183 if (this.state === State.TabHiddenIdle) { 191 if (this.state === State.TabHiddenIdle) {
184 this.state = State.TabHiddenWaiting; 192 this.state = State.TabHiddenWaitingToClose;
185 this.handleWaitingForDisconnect(); 193 this.handleWaitingForDisconnect();
186 } 194 }
187 } 195 }
188 196
189 private handleWaitingForDisconnect() { 197 private handleWaitingForDisconnect() {
190 if (this.state !== State.TabHiddenWaiting) { 198 if (this.state !== State.TabHiddenWaitingToClose) {
191 return; 199 return;
192 } 200 }
193 const pending = this.pendingRequests.size; 201 const pending = this.pendingRequests.size;
194 if (pending === 0) { 202 if (pending === 0) {
195 log.info('Closing idle websocket'); 203 log.info('Closing idle websocket');
196 this.state = State.TimedOut; 204 this.state = State.ClosedDueToInactivity;
197 this.closeConnection(1000, 'idle timeout'); 205 this.closeConnection(1000, 'idle timeout');
198 return; 206 return;
199 } 207 }
@@ -334,17 +342,31 @@ export default class XtextWebSocketClient {
334 if (this.isLogicallyClosed) { 342 if (this.isLogicallyClosed) {
335 return; 343 return;
336 } 344 }
337 this.abortPendingRequests();
338 this.closeConnection(1000, 'reconnecting due to error');
339 log.error('Reconnecting after delay due to error');
340 this.handleErrorState();
341 }
342
343 private abortPendingRequests() {
344 this.pendingRequests.forEach((request) => { 345 this.pendingRequests.forEach((request) => {
345 request.reject(new Error('Websocket disconnect')); 346 request.reject(new Error('Websocket disconnect'));
346 }); 347 });
347 this.pendingRequests.clear(); 348 this.pendingRequests.clear();
349 this.closeConnection(1000, 'reconnecting due to error');
350 if (this.state === State.Error) {
351 // We are already handling this error condition.
352 return;
353 }
354 if (
355 this.state === State.TabHiddenIdle ||
356 this.state === State.TabHiddenWaitingToClose
357 ) {
358 log.error('Will reconned due to error once the tab becomes visible');
359 this.idleTimer.cancel();
360 this.state = State.ClosedDueToInactivity;
361 return;
362 }
363 log.error('Reconnecting after delay due to error');
364 this.state = State.Error;
365 this.reconnectTryCount += 1;
366 const delay =
367 RECONNECT_DELAY_MS[this.reconnectTryCount - 1] ?? MAX_RECONNECT_DELAY_MS;
368 log.info('Reconnecting in', delay, 'ms');
369 this.reconnectTimer.schedule(delay);
348 } 370 }
349 371
350 private closeConnection(code: number, reason: string) { 372 private closeConnection(code: number, reason: string) {
@@ -355,22 +377,13 @@ export default class XtextWebSocketClient {
355 } 377 }
356 } 378 }
357 379
358 private handleErrorState() {
359 this.state = State.Error;
360 this.reconnectTryCount += 1;
361 const delay =
362 RECONNECT_DELAY_MS[this.reconnectTryCount - 1] || MAX_RECONNECT_DELAY_MS;
363 log.info('Reconnecting in', delay, 'ms');
364 this.reconnectTimer.schedule(delay);
365 }
366
367 private handleReconnect() { 380 private handleReconnect() {
368 if (this.state !== State.Error) { 381 if (this.state !== State.Error) {
369 log.error('Unexpected reconnect in', this.state); 382 log.error('Unexpected reconnect in', this.state);
370 return; 383 return;
371 } 384 }
372 if (document.visibilityState === 'hidden') { 385 if (document.visibilityState === 'hidden') {
373 this.state = State.TimedOut; 386 this.state = State.ClosedDueToInactivity;
374 } else { 387 } else {
375 this.reconnect(); 388 this.reconnect();
376 } 389 }