diff options
author | Kristóf Marussy <kristof@marussy.com> | 2021-11-16 16:47:58 +0100 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2021-11-16 16:47:58 +0100 |
commit | 11cbae607b5a6f59f5a019b7eb525da689d2bb30 (patch) | |
tree | c40e4af57988becdcf7d363a2fb7107f668b8ba0 /language-web/src | |
parent | chore(web): json validation with zod (diff) | |
download | refinery-11cbae607b5a6f59f5a019b7eb525da689d2bb30.tar.gz refinery-11cbae607b5a6f59f5a019b7eb525da689d2bb30.tar.zst refinery-11cbae607b5a6f59f5a019b7eb525da689d2bb30.zip |
feat(web): xtext formatter client
Uses the xtext formatted on the server to format the document.
Also adds the capability to take (delta) changes from the server and
apply them before any pending local changes, then replay the changes.
This means that the server-side formatter is effectively acting as a
second user who is editing the document.
Diffstat (limited to 'language-web/src')
-rw-r--r-- | language-web/src/main/js/editor/EditorButtons.tsx | 9 | ||||
-rw-r--r-- | language-web/src/main/js/editor/EditorStore.ts | 6 | ||||
-rw-r--r-- | language-web/src/main/js/xtext/UpdateService.ts | 79 | ||||
-rw-r--r-- | language-web/src/main/js/xtext/XtextClient.ts | 6 | ||||
-rw-r--r-- | language-web/src/main/js/xtext/xtextServiceResults.ts | 7 |
5 files changed, 93 insertions, 14 deletions
diff --git a/language-web/src/main/js/editor/EditorButtons.tsx b/language-web/src/main/js/editor/EditorButtons.tsx index 09ce33dd..150aa00d 100644 --- a/language-web/src/main/js/editor/EditorButtons.tsx +++ b/language-web/src/main/js/editor/EditorButtons.tsx | |||
@@ -7,6 +7,7 @@ import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; | |||
7 | import CheckIcon from '@mui/icons-material/Check'; | 7 | import CheckIcon from '@mui/icons-material/Check'; |
8 | import ErrorIcon from '@mui/icons-material/Error'; | 8 | import ErrorIcon from '@mui/icons-material/Error'; |
9 | import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; | 9 | import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; |
10 | import FormatPaint from '@mui/icons-material/FormatPaint'; | ||
10 | import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; | 11 | import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; |
11 | import RedoIcon from '@mui/icons-material/Redo'; | 12 | import RedoIcon from '@mui/icons-material/Redo'; |
12 | import SearchIcon from '@mui/icons-material/Search'; | 13 | import SearchIcon from '@mui/icons-material/Search'; |
@@ -47,7 +48,6 @@ export const EditorButtons = observer(() => { | |||
47 | disabled={!editorStore.canUndo} | 48 | disabled={!editorStore.canUndo} |
48 | onClick={() => editorStore.undo()} | 49 | onClick={() => editorStore.undo()} |
49 | aria-label="Undo" | 50 | aria-label="Undo" |
50 | value="undo" | ||
51 | > | 51 | > |
52 | <UndoIcon fontSize="small" /> | 52 | <UndoIcon fontSize="small" /> |
53 | </IconButton> | 53 | </IconButton> |
@@ -55,7 +55,6 @@ export const EditorButtons = observer(() => { | |||
55 | disabled={!editorStore.canRedo} | 55 | disabled={!editorStore.canRedo} |
56 | onClick={() => editorStore.redo()} | 56 | onClick={() => editorStore.redo()} |
57 | aria-label="Redo" | 57 | aria-label="Redo" |
58 | value="redo" | ||
59 | > | 58 | > |
60 | <RedoIcon fontSize="small" /> | 59 | <RedoIcon fontSize="small" /> |
61 | </IconButton> | 60 | </IconButton> |
@@ -88,6 +87,12 @@ export const EditorButtons = observer(() => { | |||
88 | {getLintIcon(editorStore.highestDiagnosticLevel)} | 87 | {getLintIcon(editorStore.highestDiagnosticLevel)} |
89 | </ToggleButton> | 88 | </ToggleButton> |
90 | </ToggleButtonGroup> | 89 | </ToggleButtonGroup> |
90 | <IconButton | ||
91 | onClick={() => editorStore.formatText()} | ||
92 | aria-label="Automatic format" | ||
93 | > | ||
94 | <FormatPaint fontSize="small" /> | ||
95 | </IconButton> | ||
91 | </Stack> | 96 | </Stack> |
92 | ); | 97 | ); |
93 | }); | 98 | }); |
diff --git a/language-web/src/main/js/editor/EditorStore.ts b/language-web/src/main/js/editor/EditorStore.ts index 059233f4..5760de28 100644 --- a/language-web/src/main/js/editor/EditorStore.ts +++ b/language-web/src/main/js/editor/EditorStore.ts | |||
@@ -115,6 +115,7 @@ export class EditorStore { | |||
115 | lineNumbers(), | 115 | lineNumbers(), |
116 | foldGutter(), | 116 | foldGutter(), |
117 | keymap.of([ | 117 | keymap.of([ |
118 | { key: 'Mod-Shift-f', run: () => this.formatText() }, | ||
118 | ...closeBracketsKeymap, | 119 | ...closeBracketsKeymap, |
119 | ...commentKeymap, | 120 | ...commentKeymap, |
120 | ...completionKeymap, | 121 | ...completionKeymap, |
@@ -280,4 +281,9 @@ export class EditorStore { | |||
280 | toggleLintPanel(): void { | 281 | toggleLintPanel(): void { |
281 | this.setLintPanelOpen(!this.showLintPanel); | 282 | this.setLintPanelOpen(!this.showLintPanel); |
282 | } | 283 | } |
284 | |||
285 | formatText(): boolean { | ||
286 | this.client.formatText(); | ||
287 | return true; | ||
288 | } | ||
283 | } | 289 | } |
diff --git a/language-web/src/main/js/xtext/UpdateService.ts b/language-web/src/main/js/xtext/UpdateService.ts index fa48c5ab..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'; |
@@ -14,6 +16,7 @@ import { | |||
14 | ContentAssistEntry, | 16 | ContentAssistEntry, |
15 | contentAssistResult, | 17 | contentAssistResult, |
16 | documentStateResult, | 18 | documentStateResult, |
19 | formattingResult, | ||
17 | isConflictResult, | 20 | isConflictResult, |
18 | } from './xtextServiceResults'; | 21 | } from './xtextServiceResults'; |
19 | 22 | ||
@@ -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> { |
@@ -160,8 +175,8 @@ export class UpdateService { | |||
160 | } | 175 | } |
161 | log.warn('Delta update failed, performing full text update'); | 176 | log.warn('Delta update failed, performing full text update'); |
162 | this.xtextStateId = null; | 177 | this.xtextStateId = null; |
163 | this.pendingUpdate = this.pendingUpdate.composeDesc(this.dirtyChanges); | 178 | this.pendingUpdate = this.pendingUpdate.compose(this.dirtyChanges); |
164 | this.dirtyChanges = this.newEmptyChangeDesc(); | 179 | this.dirtyChanges = this.newEmptyChangeSet(); |
165 | return this.doUpdateFullText(); | 180 | return this.doUpdateFullText(); |
166 | } | 181 | } |
167 | 182 | ||
@@ -188,6 +203,7 @@ export class UpdateService { | |||
188 | return [stateId, resultEntries]; | 203 | return [stateId, resultEntries]; |
189 | } | 204 | } |
190 | if (isConflictResult(result, 'invalidStateId')) { | 205 | if (isConflictResult(result, 'invalidStateId')) { |
206 | log.warn('Server state invalid during content assist'); | ||
191 | const [newStateId] = await this.doFallbackToUpdateFullText(); | 207 | const [newStateId] = await this.doFallbackToUpdateFullText(); |
192 | // 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 |
193 | // 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 |
@@ -219,6 +235,31 @@ export class UpdateService { | |||
219 | return entries; | 235 | return entries; |
220 | } | 236 | } |
221 | 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 | |||
222 | private computeDelta() { | 263 | private computeDelta() { |
223 | if (this.dirtyChanges.empty) { | 264 | if (this.dirtyChanges.empty) { |
224 | return null; | 265 | return null; |
@@ -240,6 +281,20 @@ export class UpdateService { | |||
240 | }; | 281 | }; |
241 | } | 282 | } |
242 | 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 | |||
243 | /** | 298 | /** |
244 | * Executes an asynchronous callback that updates the state on the server. | 299 | * Executes an asynchronous callback that updates the state on the server. |
245 | * | 300 | * |
@@ -266,7 +321,7 @@ export class UpdateService { | |||
266 | throw new Error('Another update is pending, will not perform update'); | 321 | throw new Error('Another update is pending, will not perform update'); |
267 | } | 322 | } |
268 | this.pendingUpdate = this.dirtyChanges; | 323 | this.pendingUpdate = this.dirtyChanges; |
269 | this.dirtyChanges = this.newEmptyChangeDesc(); | 324 | this.dirtyChanges = this.newEmptyChangeSet(); |
270 | let newStateId: string | null = null; | 325 | let newStateId: string | null = null; |
271 | try { | 326 | try { |
272 | let result: T; | 327 | let result: T; |
@@ -280,7 +335,7 @@ export class UpdateService { | |||
280 | if (this.pendingUpdate === null) { | 335 | if (this.pendingUpdate === null) { |
281 | log.error('pendingUpdate was cleared during update'); | 336 | log.error('pendingUpdate was cleared during update'); |
282 | } else { | 337 | } else { |
283 | this.dirtyChanges = this.pendingUpdate.composeDesc(this.dirtyChanges); | 338 | this.dirtyChanges = this.pendingUpdate.compose(this.dirtyChanges); |
284 | } | 339 | } |
285 | this.pendingUpdate = null; | 340 | this.pendingUpdate = null; |
286 | this.webSocketClient.forceReconnectOnError(); | 341 | this.webSocketClient.forceReconnectOnError(); |
diff --git a/language-web/src/main/js/xtext/XtextClient.ts b/language-web/src/main/js/xtext/XtextClient.ts index 3922b230..0898e725 100644 --- a/language-web/src/main/js/xtext/XtextClient.ts +++ b/language-web/src/main/js/xtext/XtextClient.ts | |||
@@ -77,4 +77,10 @@ export class XtextClient { | |||
77 | contentAssist(context: CompletionContext): Promise<CompletionResult> { | 77 | contentAssist(context: CompletionContext): Promise<CompletionResult> { |
78 | return this.contentAssistService.contentAssist(context); | 78 | return this.contentAssistService.contentAssist(context); |
79 | } | 79 | } |
80 | |||
81 | formatText(): void { | ||
82 | this.updateService.formatText().catch((e) => { | ||
83 | log.error('Error while formatting text', e); | ||
84 | }); | ||
85 | } | ||
80 | } | 86 | } |
diff --git a/language-web/src/main/js/xtext/xtextServiceResults.ts b/language-web/src/main/js/xtext/xtextServiceResults.ts index b6867e2f..f79b059c 100644 --- a/language-web/src/main/js/xtext/xtextServiceResults.ts +++ b/language-web/src/main/js/xtext/xtextServiceResults.ts | |||
@@ -103,3 +103,10 @@ export const occurrencesResult = documentStateResult.extend({ | |||
103 | }); | 103 | }); |
104 | 104 | ||
105 | export type OccurrencesResult = z.infer<typeof occurrencesResult>; | 105 | export type OccurrencesResult = z.infer<typeof occurrencesResult>; |
106 | |||
107 | export const formattingResult = documentStateResult.extend({ | ||
108 | formattedText: z.string(), | ||
109 | replaceRegion: textRegion, | ||
110 | }); | ||
111 | |||
112 | export type FormattingResult = z.infer<typeof formattingResult>; | ||