From a5f53cb2711fc4f2bbcbe5e383d4f2b62ecc06a6 Mon Sep 17 00:00:00 2001 From: Alexey Date: Tue, 27 Feb 2024 17:35:12 +0300 Subject: [PATCH] CB-4740 fix(plugin-codemirror6): value equal check (#2414) * CB-4740 fix(plugin-codemirror6): value equal check * CB-4740 fix(plugin-codemirror6): incoming value equal comparison * CB-4740 fix(plugin-codemirror6): initial cursor position restoration * CB-4740 fix: sql editor mode detection --------- Co-authored-by: Daria Marutkina <125263541+dariamarutkina@users.noreply.github.com> --- .../plugin-codemirror6/src/IEditorRef.ts | 2 - .../src/IReactCodemirrorProps.ts | 2 +- .../src/ReactCodemirror.tsx | 48 +++++++++++++------ .../src/validateCursorBoundaries.ts | 15 ++++++ .../SQLCodeEditorPanel/SQLCodeEditorPanel.tsx | 24 +--------- .../SQLCodeEditorPanelService.ts | 8 +--- .../useSQLCodeEditorPanel.ts | 1 - .../src/SqlDataSource/BaseSqlDataSource.ts | 25 +++++++++- .../src/SqlDataSource/ISqlDataSource.ts | 7 +++ .../src/SqlEditor/ISQLEditorData.ts | 9 +--- .../src/SqlEditor/useSqlEditor.ts | 29 +++++------ 11 files changed, 96 insertions(+), 74 deletions(-) create mode 100644 webapp/packages/plugin-codemirror6/src/validateCursorBoundaries.ts diff --git a/webapp/packages/plugin-codemirror6/src/IEditorRef.ts b/webapp/packages/plugin-codemirror6/src/IEditorRef.ts index 1aeebf887c..dc8d72f870 100644 --- a/webapp/packages/plugin-codemirror6/src/IEditorRef.ts +++ b/webapp/packages/plugin-codemirror6/src/IEditorRef.ts @@ -6,10 +6,8 @@ * you may not use this file except in compliance with the License. */ import type { EditorView } from '@codemirror/view'; -import type { SelectionRange } from '@codemirror/state'; export interface IEditorRef { container: HTMLDivElement | null; view: EditorView | null; - selection: SelectionRange | null; } diff --git a/webapp/packages/plugin-codemirror6/src/IReactCodemirrorProps.ts b/webapp/packages/plugin-codemirror6/src/IReactCodemirrorProps.ts index 930730697f..519d3aed3b 100644 --- a/webapp/packages/plugin-codemirror6/src/IReactCodemirrorProps.ts +++ b/webapp/packages/plugin-codemirror6/src/IReactCodemirrorProps.ts @@ -9,7 +9,7 @@ import type { Compartment, Extension, SelectionRange } from '@codemirror/state'; import type { ViewUpdate } from '@codemirror/view'; /** Currently we support only main selection range */ -interface ISelection { +export interface ISelection { anchor: number; head?: number; } diff --git a/webapp/packages/plugin-codemirror6/src/ReactCodemirror.tsx b/webapp/packages/plugin-codemirror6/src/ReactCodemirror.tsx index 6fe6a16eaf..985d973804 100644 --- a/webapp/packages/plugin-codemirror6/src/ReactCodemirror.tsx +++ b/webapp/packages/plugin-codemirror6/src/ReactCodemirror.tsx @@ -6,7 +6,7 @@ * you may not use this file except in compliance with the License. */ import { MergeView } from '@codemirror/merge'; -import { Annotation, Compartment, Extension, StateEffect, TransactionSpec } from '@codemirror/state'; +import { Annotation, Compartment, EditorState, Extension, StateEffect, TransactionSpec } from '@codemirror/state'; import { EditorView, ViewUpdate } from '@codemirror/view'; import { observer } from 'mobx-react-lite'; import { forwardRef, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react'; @@ -17,6 +17,7 @@ import type { IEditorRef } from './IEditorRef'; import type { IReactCodeMirrorProps } from './IReactCodemirrorProps'; import { type IReactCodemirrorContext, ReactCodemirrorContext } from './ReactCodemirrorContext'; import { useCodemirrorExtensions } from './useCodemirrorExtensions'; +import { validateCursorBoundaries } from './validateCursorBoundaries'; const External = Annotation.define(); @@ -55,15 +56,11 @@ export const ReactCodemirror = observer( const [view, setView] = useState(null); const [incomingView, setIncomingView] = useState(null); const callbackRef = useObjectRef({ onChange, onCursorChange, onUpdate }); - const [selection, setSelection] = useState(view?.state.selection.main ?? null); useLayoutEffect(() => { if (container) { const updateListener = EditorView.updateListener.of((update: ViewUpdate) => { const remote = update.transactions.some(tr => tr.annotation(External)); - if (update.selectionSet) { - setSelection(update.state.selection.main); - } if (update.docChanged && !remote) { const doc = update.state.doc; @@ -89,9 +86,15 @@ export const ReactCodemirror = observer( effects.push(compartment.of(extension)); } + const tempState = EditorState.create({ + doc: value, + }); + if (incomingValue !== undefined) { merge = new MergeView({ a: { + doc: value, + selection: cursor && validateCursorBoundaries(cursor, tempState.doc.length), extensions: [updateListener, ...effects], }, b: { @@ -104,11 +107,19 @@ export const ReactCodemirror = observer( incomingView = merge.b; } else { editorView = new EditorView({ + state: EditorState.create({ + doc: value, + selection: cursor && validateCursorBoundaries(cursor, tempState.doc.length), + extensions: [updateListener, ...effects], + }), parent: container, - extensions: [updateListener, ...effects], }); } + editorView.dispatch({ + scrollIntoView: true, + }); + if (incomingView) { setIncomingView(incomingView); } @@ -168,9 +179,13 @@ export const ReactCodemirror = observer( let isCursorInDoc = cursor && cursor.anchor > 0 && cursor.anchor < view.state.doc.length; - if (value !== undefined && value !== view.state.doc.toString()) { - transaction.changes = { from: 0, to: view.state.doc.length, insert: value }; - isCursorInDoc = cursor && cursor.anchor > 0 && cursor.anchor < value.length; + if (value !== undefined) { + const newText = view.state.toText(value); + + if (!newText.eq(view.state.doc)) { + transaction.changes = { from: 0, to: view.state.doc.length, insert: newText }; + isCursorInDoc = cursor && cursor.anchor > 0 && cursor.anchor < newText.length; + } } if (cursor && isCursorInDoc && (view.state.selection.main.anchor !== cursor.anchor || view.state.selection.main.head !== cursor.head)) { @@ -184,10 +199,14 @@ export const ReactCodemirror = observer( }); useLayoutEffect(() => { - if (incomingValue !== undefined && incomingView && incomingValue !== incomingView.state.doc.toString()) { - incomingView.dispatch({ - changes: { from: 0, to: incomingView.state.doc.length, insert: incomingValue }, - }); + if (incomingValue !== undefined && incomingView) { + const newValue = incomingView.state.toText(incomingValue); + + if (!newValue.eq(incomingView.state.doc)) { + incomingView.dispatch({ + changes: { from: 0, to: incomingView.state.doc.length, insert: newValue }, + }); + } } }, [incomingValue, incomingView]); @@ -202,9 +221,8 @@ export const ReactCodemirror = observer( () => ({ container, view, - selection, }), - [container, view, selection], + [container, view], ); const context = useMemo( diff --git a/webapp/packages/plugin-codemirror6/src/validateCursorBoundaries.ts b/webapp/packages/plugin-codemirror6/src/validateCursorBoundaries.ts new file mode 100644 index 0000000000..352a0b08d0 --- /dev/null +++ b/webapp/packages/plugin-codemirror6/src/validateCursorBoundaries.ts @@ -0,0 +1,15 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import type { ISelection } from './IReactCodemirrorProps'; + +export function validateCursorBoundaries(selection: ISelection, documentLength: number): ISelection { + return { + anchor: Math.min(selection.anchor, documentLength), + head: selection.head === undefined ? undefined : Math.min(selection.head, documentLength), + }; +} diff --git a/webapp/packages/plugin-sql-editor-new/src/SQLEditor/SQLCodeEditorPanel/SQLCodeEditorPanel.tsx b/webapp/packages/plugin-sql-editor-new/src/SQLEditor/SQLCodeEditorPanel/SQLCodeEditorPanel.tsx index dda25ca030..ce4ff3f9d6 100644 --- a/webapp/packages/plugin-sql-editor-new/src/SQLEditor/SQLCodeEditorPanel/SQLCodeEditorPanel.tsx +++ b/webapp/packages/plugin-sql-editor-new/src/SQLEditor/SQLCodeEditorPanel/SQLCodeEditorPanel.tsx @@ -6,13 +6,13 @@ * you may not use this file except in compliance with the License. */ import { observer } from 'mobx-react-lite'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { MenuBarSmallItem, useExecutor, useS, useTranslate } from '@cloudbeaver/core-blocks'; import { useService } from '@cloudbeaver/core-di'; import { NotificationService } from '@cloudbeaver/core-events'; import { DATA_CONTEXT_NAV_NODE, getNodesFromContext, NavNodeManagerService } from '@cloudbeaver/core-navigation-tree'; -import { TabContainerPanelComponent, useDNDBox, useTabLocalState } from '@cloudbeaver/core-ui'; +import { TabContainerPanelComponent, useDNDBox } from '@cloudbeaver/core-ui'; import { closeCompletion, IEditorRef, Prec, ReactCodemirrorPanel, useCodemirrorExtensions } from '@cloudbeaver/plugin-codemirror6'; import type { ISqlEditorModeProps } from '@cloudbeaver/plugin-sql-editor'; @@ -26,36 +26,16 @@ import style from './SQLCodeEditorPanel.m.css'; import { SqlEditorInfoBar } from './SqlEditorInfoBar'; import { useSQLCodeEditorPanel } from './useSQLCodeEditorPanel'; -interface ILocalSQLCodeEditorPanelState { - selection: { from: number; to: number }; -} - export const SQLCodeEditorPanel: TabContainerPanelComponent = observer(function SQLCodeEditorPanel({ data }) { const notificationService = useService(NotificationService); const navNodeManagerService = useService(NavNodeManagerService); const translate = useTranslate(); - const localState = useTabLocalState(() => ({ selection: { from: 0, to: 0 } })); const styles = useS(style); const [editorRef, setEditorRef] = useState(null); const editor = useSQLCodeEditor(editorRef); - useEffect(() => { - editorRef?.view?.dispatch({ - selection: { anchor: Math.min(localState.selection.to, data.value.length), head: Math.min(localState.selection.to, data.value.length) }, - scrollIntoView: true, - }); - }, [editorRef?.view, localState, data]); - - useEffect(() => { - if (!editorRef?.selection) { - return; - } - - localState.selection = { ...editorRef?.selection }; - }, [editorRef?.selection]); - const panel = useSQLCodeEditorPanel(data, editor); const extensions = useCodemirrorExtensions(undefined, [ACTIVE_QUERY_EXTENSION, Prec.lowest(QUERY_STATUS_GUTTER_EXTENSION)]); const autocompletion = useSqlDialectAutocompletion(data); diff --git a/webapp/packages/plugin-sql-editor-new/src/SQLEditor/SQLCodeEditorPanel/SQLCodeEditorPanelService.ts b/webapp/packages/plugin-sql-editor-new/src/SQLEditor/SQLCodeEditorPanel/SQLCodeEditorPanelService.ts index 270e4ca7d3..fe3a087017 100644 --- a/webapp/packages/plugin-sql-editor-new/src/SQLEditor/SQLCodeEditorPanel/SQLCodeEditorPanelService.ts +++ b/webapp/packages/plugin-sql-editor-new/src/SQLEditor/SQLCodeEditorPanel/SQLCodeEditorPanelService.ts @@ -5,15 +5,11 @@ * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ -import React from 'react'; - +import { importLazyComponent } from '@cloudbeaver/core-blocks'; import { injectable } from '@cloudbeaver/core-di'; import { ESqlDataSourceFeatures, SqlEditorModeService } from '@cloudbeaver/plugin-sql-editor'; -const SQLCodeEditorPanel = React.lazy(async () => { - const { SQLCodeEditorPanel } = await import('./SQLCodeEditorPanel'); - return { default: SQLCodeEditorPanel }; -}); +const SQLCodeEditorPanel = importLazyComponent(() => import('./SQLCodeEditorPanel').then(module => module.SQLCodeEditorPanel)); @injectable() export class SQLCodeEditorPanelService { diff --git a/webapp/packages/plugin-sql-editor-new/src/SQLEditor/SQLCodeEditorPanel/useSQLCodeEditorPanel.ts b/webapp/packages/plugin-sql-editor-new/src/SQLEditor/SQLCodeEditorPanel/useSQLCodeEditorPanel.ts index 43ff3ac336..313ad44b18 100644 --- a/webapp/packages/plugin-sql-editor-new/src/SQLEditor/SQLCodeEditorPanel/useSQLCodeEditorPanel.ts +++ b/webapp/packages/plugin-sql-editor-new/src/SQLEditor/SQLCodeEditorPanel/useSQLCodeEditorPanel.ts @@ -10,7 +10,6 @@ import { useCallback } from 'react'; import { useExecutor, useObservableRef } from '@cloudbeaver/core-blocks'; import { throttle } from '@cloudbeaver/core-utils'; -import type { Transaction, ViewUpdate } from '@cloudbeaver/plugin-codemirror6'; import type { ISQLEditorData } from '@cloudbeaver/plugin-sql-editor'; import type { IEditor } from '../SQLCodeEditor/useSQLCodeEditor'; diff --git a/webapp/packages/plugin-sql-editor/src/SqlDataSource/BaseSqlDataSource.ts b/webapp/packages/plugin-sql-editor/src/SqlDataSource/BaseSqlDataSource.ts index 763323df8b..bcf54266d4 100644 --- a/webapp/packages/plugin-sql-editor/src/SqlDataSource/BaseSqlDataSource.ts +++ b/webapp/packages/plugin-sql-editor/src/SqlDataSource/BaseSqlDataSource.ts @@ -14,7 +14,7 @@ import type { IDatabaseDataModel, IDatabaseResultSet } from '@cloudbeaver/plugin import type { IDataQueryOptions } from '../QueryDataSource'; import { ESqlDataSourceFeatures } from './ESqlDataSourceFeatures'; -import type { ISetScriptData, ISqlDataSource, ISqlDataSourceKey } from './ISqlDataSource'; +import type { ISetScriptData, ISqlDataSource, ISqlDataSourceKey, ISqlEditorCursor } from './ISqlDataSource'; import type { ISqlDataSourceHistory } from './SqlDataSourceHistory/ISqlDataSourceHistory'; import { SqlDataSourceHistory } from './SqlDataSourceHistory/SqlDataSourceHistory'; @@ -37,6 +37,10 @@ export abstract class BaseSqlDataSource implements ISqlDataSource { incomingExecutionContext: IConnectionExecutionContextInfo | undefined | null; exception?: Error | Error[] | null | undefined; + get cursor(): ISqlEditorCursor { + return this.innerCursorState; + } + get isIncomingChanges(): boolean { return this.incomingScript !== undefined || this.incomingExecutionContext !== null; } @@ -81,6 +85,7 @@ export abstract class BaseSqlDataSource implements ISqlDataSource { protected outdated: boolean; protected editing: boolean; + protected innerCursorState: ISqlEditorCursor; constructor(icon = '/icons/sql_script_m.svg') { this.icon = icon; @@ -91,6 +96,7 @@ export abstract class BaseSqlDataSource implements ISqlDataSource { this.message = undefined; this.outdated = true; this.editing = true; + this.innerCursorState = { begin: 0, end: 0 }; this.history = new SqlDataSourceHistory(); this.onUpdate = new SyncExecutor(); this.onSetScript = new SyncExecutor(); @@ -107,7 +113,7 @@ export abstract class BaseSqlDataSource implements ISqlDataSource { this.history.onNavigate.addHandler(value => this.setScript(value, SOURCE_HISTORY)); - makeObservable(this, { + makeObservable(this, { isSaved: computed, isIncomingChanges: computed, isAutoSaveEnabled: computed, @@ -130,6 +136,7 @@ export abstract class BaseSqlDataSource implements ISqlDataSource { outdated: observable.ref, message: observable.ref, editing: observable.ref, + innerCursorState: observable.ref, incomingScript: observable.ref, incomingExecutionContext: observable.ref, }); @@ -225,6 +232,20 @@ export abstract class BaseSqlDataSource implements ISqlDataSource { return this.features.includes(feature); } + setCursor(begin: number, end = begin): void { + if (begin > end) { + throw new Error('Cursor begin can not be greater than the end of it'); + } + + const scriptLength = this.script.length; + + this.innerCursorState = Object.freeze({ + begin: Math.min(begin, scriptLength), + end: Math.min(end, scriptLength), + }); + this.onUpdate.execute(); + } + setEditing(state: boolean): void { this.editing = state; } diff --git a/webapp/packages/plugin-sql-editor/src/SqlDataSource/ISqlDataSource.ts b/webapp/packages/plugin-sql-editor/src/SqlDataSource/ISqlDataSource.ts index 555c23b736..efa60817e7 100644 --- a/webapp/packages/plugin-sql-editor/src/SqlDataSource/ISqlDataSource.ts +++ b/webapp/packages/plugin-sql-editor/src/SqlDataSource/ISqlDataSource.ts @@ -23,6 +23,11 @@ export interface ISetScriptData { source?: string; } +export interface ISqlEditorCursor { + readonly begin: number; + readonly end: number; +} + export interface ISqlDataSource extends ILoadableState { readonly name: string | null; readonly icon?: string; @@ -33,6 +38,7 @@ export interface ISqlDataSource extends ILoadableState { readonly projectId: string | null; readonly script: string; + readonly cursor: ISqlEditorCursor; readonly incomingScript?: string; readonly history: ISqlDataSourceHistory; @@ -65,6 +71,7 @@ export interface ISqlDataSource extends ILoadableState { setName(name: string | null): void; setProject(projectId: string | null): void; setScript(script: string, source?: string): void; + setCursor(begin: number, end?: number): void; setEditing(state: boolean): void; setExecutionContext(executionContext?: IConnectionExecutionContextInfo): void; setIncomingExecutionContext(executionContext?: IConnectionExecutionContextInfo): void; diff --git a/webapp/packages/plugin-sql-editor/src/SqlEditor/ISQLEditorData.ts b/webapp/packages/plugin-sql-editor/src/SqlEditor/ISQLEditorData.ts index 3d44e1bb22..830ce84b91 100644 --- a/webapp/packages/plugin-sql-editor/src/SqlEditor/ISQLEditorData.ts +++ b/webapp/packages/plugin-sql-editor/src/SqlEditor/ISQLEditorData.ts @@ -8,7 +8,7 @@ import type { ISyncExecutor } from '@cloudbeaver/core-executor'; import type { SqlDialectInfo } from '@cloudbeaver/core-sdk'; -import type { ISqlDataSource } from '../SqlDataSource/ISqlDataSource'; +import type { ISqlDataSource, ISqlEditorCursor } from '../SqlDataSource/ISqlDataSource'; import type { SQLProposal } from '../SqlEditorService'; import type { ISQLScriptSegment, SQLParser } from '../SQLParser'; import type { ISQLEditorMode } from './SQLEditorModeContext'; @@ -18,13 +18,8 @@ export interface ISegmentExecutionData { type: 'start' | 'end' | 'error'; } -export interface ICursor { - readonly begin: number; - readonly end: number; -} - export interface ISQLEditorData { - readonly cursor: ICursor; + readonly cursor: ISqlEditorCursor; activeSegmentMode: ISQLEditorMode; readonly parser: SQLParser; readonly dialect: SqlDialectInfo | undefined; diff --git a/webapp/packages/plugin-sql-editor/src/SqlEditor/useSqlEditor.ts b/webapp/packages/plugin-sql-editor/src/SqlEditor/useSqlEditor.ts index 768084a133..23361e73ff 100644 --- a/webapp/packages/plugin-sql-editor/src/SqlEditor/useSqlEditor.ts +++ b/webapp/packages/plugin-sql-editor/src/SqlEditor/useSqlEditor.ts @@ -5,7 +5,7 @@ * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ -import { action, autorun, computed, IReactionDisposer, observable, untracked } from 'mobx'; +import { action, autorun, computed, IReactionDisposer, observable, runInAction, untracked } from 'mobx'; import { useEffect } from 'react'; import { ConfirmationDialog, useExecutor, useObservableRef } from '@cloudbeaver/core-blocks'; @@ -19,7 +19,7 @@ import { createLastPromiseGetter, LastPromiseGetter, throttleAsync } from '@clou import type { ISqlEditorTabState } from '../ISqlEditorTabState'; import { ESqlDataSourceFeatures } from '../SqlDataSource/ESqlDataSourceFeatures'; -import type { ISqlDataSource } from '../SqlDataSource/ISqlDataSource'; +import type { ISqlDataSource, ISqlEditorCursor } from '../SqlDataSource/ISqlDataSource'; import { SqlDataSourceService } from '../SqlDataSource/SqlDataSourceService'; import { SqlDialectInfoService } from '../SqlDialectInfoService'; import { SqlEditorService } from '../SqlEditorService'; @@ -28,7 +28,7 @@ import { SqlExecutionPlanService } from '../SqlResultTabs/ExecutionPlan/SqlExecu import { OUTPUT_LOGS_TAB_ID } from '../SqlResultTabs/OutputLogs/OUTPUT_LOGS_TAB_ID'; import { SqlQueryService } from '../SqlResultTabs/SqlQueryService'; import { SqlResultTabsService } from '../SqlResultTabs/SqlResultTabsService'; -import type { ICursor, ISQLEditorData } from './ISQLEditorData'; +import type { ISQLEditorData } from './ISQLEditorData'; import { SQLEditorModeContext } from './SQLEditorModeContext'; interface ISQLEditorDataPrivate extends ISQLEditorData { @@ -44,7 +44,7 @@ interface ISQLEditorDataPrivate extends ISQLEditorData { readonly getLastAutocomplete: LastPromiseGetter; readonly parseScript: LastPromiseGetter; - cursor: ICursor; + cursor: ISqlEditorCursor; readonlyState: boolean; executingScript: boolean; state: ISqlEditorTabState; @@ -126,6 +126,10 @@ export function useSqlEditor(state: ISqlEditorTabState): ISQLEditorData { return this.dataSource?.isIncomingChanges ?? false; }, + get cursor(): ISqlEditorCursor { + return this.dataSource?.cursor ?? { begin: 0, end: 0 }; + }, + get value(): string { return this.dataSource?.script ?? ''; }, @@ -140,7 +144,6 @@ export function useSqlEditor(state: ISqlEditorTabState): ISQLEditorData { onUpdate: new SyncExecutor(), parser: new SQLParser(), - cursor: { begin: 0, end: 0 }, readonlyState: false, executingScript: false, reactionDisposer: null, @@ -177,17 +180,7 @@ export function useSqlEditor(state: ISqlEditorTabState): ISQLEditorData { }, setCursor(begin: number, end = begin): void { - if (begin > end) { - throw new Error('Cursor begin can not be greater than the end of it'); - } - - const scriptLength = this.value.length; - - this.cursor = { - begin: Math.min(begin, scriptLength), - end: Math.min(end, scriptLength), - }; - this.onUpdate.execute(); + this.dataSource?.setCursor(begin, end); }, getLastAutocomplete: createLastPromiseGetter(), @@ -483,9 +476,9 @@ export function useSqlEditor(state: ISqlEditorTabState): ISQLEditorData { isDisabled: computed, value: computed, readonly: computed, + cursor: computed, activeSegmentMode: observable.ref, hintsLimitIsMet: observable.ref, - cursor: observable.ref, readonlyState: observable, executingScript: observable, }, @@ -545,7 +538,7 @@ export function useSqlEditor(state: ISqlEditorTabState): ISQLEditorData { const contexts = data.onMode.execute(data); const activeSegmentMode = contexts.getContext(SQLEditorModeContext); - action(() => { + runInAction(() => { data.activeSegmentMode = activeSegmentMode; }); });