From 11cbae607b5a6f59f5a019b7eb525da689d2bb30 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Tue, 16 Nov 2021 16:47:58 +0100 Subject: 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. --- language-web/src/main/js/editor/EditorButtons.tsx | 9 ++- language-web/src/main/js/editor/EditorStore.ts | 6 ++ language-web/src/main/js/xtext/UpdateService.ts | 79 ++++++++++++++++++---- language-web/src/main/js/xtext/XtextClient.ts | 6 ++ .../src/main/js/xtext/xtextServiceResults.ts | 7 ++ 5 files changed, 93 insertions(+), 14 deletions(-) (limited to 'language-web') 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'; import CheckIcon from '@mui/icons-material/Check'; import ErrorIcon from '@mui/icons-material/Error'; import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; +import FormatPaint from '@mui/icons-material/FormatPaint'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import RedoIcon from '@mui/icons-material/Redo'; import SearchIcon from '@mui/icons-material/Search'; @@ -47,7 +48,6 @@ export const EditorButtons = observer(() => { disabled={!editorStore.canUndo} onClick={() => editorStore.undo()} aria-label="Undo" - value="undo" > @@ -55,7 +55,6 @@ export const EditorButtons = observer(() => { disabled={!editorStore.canRedo} onClick={() => editorStore.redo()} aria-label="Redo" - value="redo" > @@ -88,6 +87,12 @@ export const EditorButtons = observer(() => { {getLintIcon(editorStore.highestDiagnosticLevel)} + editorStore.formatText()} + aria-label="Automatic format" + > + + ); }); 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 { lineNumbers(), foldGutter(), keymap.of([ + { key: 'Mod-Shift-f', run: () => this.formatText() }, ...closeBracketsKeymap, ...commentKeymap, ...completionKeymap, @@ -280,4 +281,9 @@ export class EditorStore { toggleLintPanel(): void { this.setLintPanelOpen(!this.showLintPanel); } + + formatText(): boolean { + this.client.formatText(); + return true; + } } 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 @@ import { ChangeDesc, ChangeSet, + ChangeSpec, + StateEffect, Transaction, } from '@codemirror/state'; import { nanoid } from 'nanoid'; @@ -14,6 +16,7 @@ import { ContentAssistEntry, contentAssistResult, documentStateResult, + formattingResult, isConflictResult, } from './xtextServiceResults'; @@ -23,6 +26,8 @@ const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; const log = getLogger('xtext.UpdateService'); +const setDirtyChanges = StateEffect.define(); + export interface IAbortSignal { aborted: boolean; } @@ -38,12 +43,12 @@ export class UpdateService { * The changes being synchronized to the server if a full or delta text update is running, * `null` otherwise. */ - private pendingUpdate: ChangeDesc | null = null; + private pendingUpdate: ChangeSet | null = null; /** * Local changes not yet sychronized to the server and not part of the running update, if any. */ - private dirtyChanges: ChangeDesc; + private dirtyChanges: ChangeSet; private readonly webSocketClient: XtextWebSocketClient; @@ -59,7 +64,7 @@ export class UpdateService { constructor(store: EditorStore, webSocketClient: XtextWebSocketClient) { this.resourceName = `${nanoid(7)}.problem`; this.store = store; - this.dirtyChanges = this.newEmptyChangeDesc(); + this.dirtyChanges = this.newEmptyChangeSet(); this.webSocketClient = webSocketClient; } @@ -71,8 +76,19 @@ export class UpdateService { } onTransaction(transaction: Transaction): void { + const setDirtyChangesEffect = transaction.effects.find( + (effect) => effect.is(setDirtyChanges), + ) as StateEffect | undefined; + if (setDirtyChangesEffect) { + const { value } = setDirtyChangesEffect; + if (this.pendingUpdate !== null) { + this.pendingUpdate = ChangeSet.empty(value.length); + } + this.dirtyChanges = value; + return; + } if (transaction.docChanged) { - this.dirtyChanges = this.dirtyChanges.composeDesc(transaction.changes.desc); + this.dirtyChanges = this.dirtyChanges.compose(transaction.changes); this.idleUpdateTimer.reschedule(); } } @@ -86,7 +102,7 @@ export class UpdateService { * @return the summary of changes since the last update */ computeChangesSinceLastUpdate(): ChangeDesc { - return this.pendingUpdate?.composeDesc(this.dirtyChanges) || this.dirtyChanges; + return this.pendingUpdate?.composeDesc(this.dirtyChanges.desc) || this.dirtyChanges.desc; } private handleIdleUpdate() { @@ -101,9 +117,8 @@ export class UpdateService { this.idleUpdateTimer.reschedule(); } - private newEmptyChangeDesc() { - const changeSet = ChangeSet.of([], this.store.state.doc.length); - return changeSet.desc; + private newEmptyChangeSet() { + return ChangeSet.of([], this.store.state.doc.length); } async updateFullText(): Promise { @@ -160,8 +175,8 @@ export class UpdateService { } log.warn('Delta update failed, performing full text update'); this.xtextStateId = null; - this.pendingUpdate = this.pendingUpdate.composeDesc(this.dirtyChanges); - this.dirtyChanges = this.newEmptyChangeDesc(); + this.pendingUpdate = this.pendingUpdate.compose(this.dirtyChanges); + this.dirtyChanges = this.newEmptyChangeSet(); return this.doUpdateFullText(); } @@ -188,6 +203,7 @@ export class UpdateService { return [stateId, resultEntries]; } if (isConflictResult(result, 'invalidStateId')) { + log.warn('Server state invalid during content assist'); const [newStateId] = await this.doFallbackToUpdateFullText(); // We must finish this state update transaction to prepare for any push events // before querying for content assist, so we just return `null` and will query @@ -219,6 +235,31 @@ export class UpdateService { return entries; } + async formatText(): Promise { + await this.update(); + let { from, to } = this.store.state.selection.main; + if (to <= from) { + from = 0; + to = this.store.state.doc.length; + } + log.debug('Formatting from', from, 'to', to); + await this.withUpdate(async () => { + const result = await this.webSocketClient.send({ + resource: this.resourceName, + serviceType: 'format', + selectionStart: from, + selectionEnd: to, + }); + const { stateId, formattedText } = formattingResult.parse(result); + this.applyBeforeDirtyChanges({ + from, + to, + insert: formattedText, + }); + return [stateId, null]; + }); + } + private computeDelta() { if (this.dirtyChanges.empty) { return null; @@ -240,6 +281,20 @@ export class UpdateService { }; } + private applyBeforeDirtyChanges(changeSpec: ChangeSpec) { + const pendingChanges = this.pendingUpdate?.compose(this.dirtyChanges) || this.dirtyChanges; + const revertChanges = pendingChanges.invert(this.store.state.doc); + const applyBefore = ChangeSet.of(changeSpec, revertChanges.newLength); + const redoChanges = pendingChanges.map(applyBefore.desc); + const changeSet = revertChanges.compose(applyBefore).compose(redoChanges); + this.store.dispatch({ + changes: changeSet, + effects: [ + setDirtyChanges.of(redoChanges), + ], + }); + } + /** * Executes an asynchronous callback that updates the state on the server. * @@ -266,7 +321,7 @@ export class UpdateService { throw new Error('Another update is pending, will not perform update'); } this.pendingUpdate = this.dirtyChanges; - this.dirtyChanges = this.newEmptyChangeDesc(); + this.dirtyChanges = this.newEmptyChangeSet(); let newStateId: string | null = null; try { let result: T; @@ -280,7 +335,7 @@ export class UpdateService { if (this.pendingUpdate === null) { log.error('pendingUpdate was cleared during update'); } else { - this.dirtyChanges = this.pendingUpdate.composeDesc(this.dirtyChanges); + this.dirtyChanges = this.pendingUpdate.compose(this.dirtyChanges); } this.pendingUpdate = null; 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 { contentAssist(context: CompletionContext): Promise { return this.contentAssistService.contentAssist(context); } + + formatText(): void { + this.updateService.formatText().catch((e) => { + log.error('Error while formatting text', e); + }); + } } 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({ }); export type OccurrencesResult = z.infer; + +export const formattingResult = documentStateResult.extend({ + formattedText: z.string(), + replaceRegion: textRegion, +}); + +export type FormattingResult = z.infer; -- cgit v1.2.3-54-g00ecf