Feat CB-3030 conflict resolving (#1814)

* CB-3030 feat: conflict resolving

* CB-3030 fix: display os specific key instead of mod

* CB-3030 fix: sql editor status

* CB-3030 fix: styles and data validation

* CB-3030 fix: sql editor styles

---------

Co-authored-by: dariamarutkina <125263541+dariamarutkina@users.noreply.github.com>
This commit is contained in:
Alexey
2023-07-17 22:10:15 +03:00
committed by GitHub
co-authored by dariamarutkina
parent fc592908c2
commit 78c19187de
60 changed files with 3507 additions and 3412 deletions
@@ -24,6 +24,7 @@ export const BASE_CONTAINERS_STYLES = css`
flex-direction: row;
align-content: baseline;
position: relative;
box-sizing: border-box;
&[hideEmpty]:empty {
display: none;
@@ -43,15 +43,15 @@ export const styles = css`
}
`;
interface Props extends Omit<ButtonProps, 'style'> {
name?: string;
interface Props extends Omit<ButtonProps, 'style' | 'icon'> {
icon?: string;
viewBox?: string;
style?: ComponentStyle;
onClick?: React.MouseEventHandler<HTMLDivElement>;
}
export const MenuBarSmallItem: React.FC<React.PropsWithChildren<Props>> = function MenuBarSmallItem({
name,
icon,
viewBox,
children,
className,
@@ -63,7 +63,7 @@ export const MenuBarSmallItem: React.FC<React.PropsWithChildren<Props>> = functi
// TODO: use button for icon-box (maybe)
return styled(useStyles(styles, style))(
<icon-box className={className} tabIndex={0} onClick={onClick}>
{name && <IconButton name={name} viewBox={viewBox} {...rest} />}
{icon && <IconButton name={icon} viewBox={viewBox} {...rest} />}
{children && <icon-label>{children}</icon-label>}
</icon-box>,
);
@@ -27,6 +27,7 @@ export default [
['ui_custom', 'Custom'],
['ui_configure', 'Configure'],
['ui_apply', 'Apply'],
['ui_ignore', 'Ignore'],
['ui_unexpected_error', 'Unexpected error'],
['ui_error', 'Error'],
['ui_error_message', 'Error:'],
@@ -25,6 +25,7 @@ export default [
['ui_custom', 'Custom'],
['ui_configure', 'Configure'],
['ui_apply', 'Applica'],
['ui_ignore', 'Ignore'],
['ui_error', 'Errore'],
['ui_error_message', 'Errore:'],
['ui_error_close', 'Chiudi'],
@@ -24,6 +24,7 @@ export default [
['ui_custom', 'Настроить'],
['ui_configure', 'Настроить'],
['ui_apply', 'Применить'],
['ui_ignore', 'Пропустить'],
['ui_error', 'Ошибка'],
['ui_error_message', 'Ошибка:'],
['ui_error_close', 'Закрыть'],
@@ -25,6 +25,7 @@ export default [
['ui_custom', 'Custom'],
['ui_configure', '配置'],
['ui_apply', '应用'],
['ui_ignore', 'Ignore'],
['ui_error', '错误'],
['ui_error_message', '错误:'],
['ui_error_close', '关闭'],
+2 -2
View File
@@ -22,7 +22,7 @@
"@cloudbeaver/core-utils": "~0.1.0",
"@cloudbeaver/core-extensions": "~0.1.0",
"@cloudbeaver/core-theming": "~0.1.0",
"react-hotkeys-hook": "~3.4.7"
"react-hotkeys-hook": "~4.4.1"
},
"peerDependencies": {
"mobx": "~6.x.x",
@@ -30,4 +30,4 @@
"react": "~18.x.x",
"reshadow": "~0.x.x"
}
}
}
@@ -9,7 +9,6 @@ import { createKeyBinding } from '../createKeyBinding';
export const KEY_BINDING_REDO = createKeyBinding({
id: 'redo',
keys: ['ctrl+y', 'ctrl+shift+z'],
keysMac: ['shift+cmd+z', 'cmd+y'],
keys: ['mod+y', 'mod+shift+z'],
preventDefault: true,
});
@@ -5,12 +5,10 @@
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { createKeyBinding } from '../createKeyBinding';
export const KEY_BINDING_SAVE = createKeyBinding({
id: 'save',
keysWin: ['ctrl+s'],
keysMac: ['cmd+s'],
keys: ['mod+s'],
preventDefault: true,
});
@@ -9,7 +9,6 @@ import { createKeyBinding } from '../createKeyBinding';
export const KEY_BINDING_UNDO = createKeyBinding({
id: 'undo',
keys: 'ctrl+z',
keysMac: 'cmd+z',
keys: 'mod+z',
preventDefault: true,
});
@@ -12,12 +12,14 @@ import styled, { css } from 'reshadow';
import { useFocus } from '@cloudbeaver/core-blocks';
import { useService } from '@cloudbeaver/core-di';
import { isObjectsEqual } from '@cloudbeaver/core-utils';
import { ActionService } from '../Action/ActionService';
import type { IActionItem } from '../Action/IActionItem';
import { getCommonAndOSSpecificKeys } from '../Action/KeyBinding/getCommonAndOSSpecificKeys';
import { CaptureViewContext } from './CaptureViewContext';
import type { IView } from './IView';
import { parseHotkey } from './parseHotkey';
import { useActiveView } from './useActiveView';
import { useViewContext } from './useViewContext';
@@ -44,14 +46,7 @@ export const CaptureView = observer<React.PropsWithChildren<Props>>(function Cap
.filter(action => action?.binding && !action.isDisabled())
.filter(Boolean) as IActionItem[];
let keys = actionItems
.map(item => getCommonAndOSSpecificKeys(item.binding?.binding))
.flat()
.join(', ');
if (keys === '') {
keys = '*';
}
const keys = actionItems.map(item => getCommonAndOSSpecificKeys(item.binding?.binding)).flat();
useHotkeys(
keys,
@@ -62,18 +57,31 @@ export const CaptureView = observer<React.PropsWithChildren<Props>>(function Cap
const action = actionItems.find(action => {
const commonAndSpecificKeys = getCommonAndOSSpecificKeys(action.binding?.binding);
return commonAndSpecificKeys.includes(handler.key);
});
return commonAndSpecificKeys.some(key => {
const hotkey = parseHotkey(key);
if (action?.binding?.binding.preventDefault) {
event.preventDefault();
}
return isObjectsEqual(hotkey, handler);
});
});
action?.activate(true);
},
{
enabled: keys !== '*',
enableOnTags: ['INPUT', 'SELECT', 'TEXTAREA'],
enabled: keys.length > 0,
enableOnFormTags: ['INPUT', 'SELECT', 'TEXTAREA'],
preventDefault(event, handler) {
const action = actionItems.find(action => {
const commonAndSpecificKeys = getCommonAndOSSpecificKeys(action.binding?.binding);
return commonAndSpecificKeys.some(key => {
const hotkey = parseHotkey(key);
return isObjectsEqual(hotkey, handler);
});
});
return action?.binding?.binding.preventDefault === true;
},
enableOnContentEditable: true,
},
);
@@ -0,0 +1,70 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 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 { Hotkey, KeyboardModifiers } from 'react-hotkeys-hook/dist/types';
const reservedModifierKeywords = ['shift', 'alt', 'meta', 'mod', 'ctrl'];
const mappedKeys: Record<string, string> = {
esc: 'escape',
return: 'enter',
'.': 'period',
',': 'comma',
'-': 'slash',
' ': 'space',
'`': 'backquote',
'#': 'backslash',
'+': 'bracketright',
ShiftLeft: 'shift',
ShiftRight: 'shift',
AltLeft: 'alt',
AltRight: 'alt',
MetaLeft: 'meta',
MetaRight: 'meta',
OSLeft: 'meta',
OSRight: 'meta',
ControlLeft: 'ctrl',
ControlRight: 'ctrl',
};
export function mapKey(key: string): string {
return (mappedKeys[key] || key)
.trim()
.toLowerCase()
.replace(/key|digit|numpad|arrow/, '');
}
export function isHotkeyModifier(key: string) {
return reservedModifierKeywords.includes(key);
}
export function parseKeysHookInput(keys: string, splitKey = ','): string[] {
return keys.split(splitKey);
}
export function parseHotkey(hotkey: string, combinationKey = '+', description?: string): Hotkey {
const keys = hotkey
.toLocaleLowerCase()
.split(combinationKey)
.map(k => mapKey(k));
const modifiers: KeyboardModifiers = {
alt: keys.includes('alt'),
ctrl: keys.includes('ctrl') || keys.includes('control'),
shift: keys.includes('shift'),
meta: keys.includes('meta'),
mod: keys.includes('mod'),
};
const singleCharKeys = keys.filter(k => !reservedModifierKeywords.includes(k));
return {
...modifiers,
keys: singleCharKeys,
description,
};
}
@@ -17,12 +17,25 @@
"dependencies": {
"@cloudbeaver/core-blocks": "~0.1.0",
"@cloudbeaver/core-utils": "~0.1.0",
"@codemirror/lang-html": "^6.4.4",
"@codemirror/lang-javascript": "^6.1.9",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-sql": "^6.5.0",
"@codemirror/lang-xml": "^6.0.2",
"codemirror": "^6.0.1"
"@codemirror/lang-html": "6.4.5",
"@codemirror/lang-javascript": "6.1.9",
"@codemirror/lang-json": "6.0.1",
"@codemirror/lang-sql": "6.5.2",
"@codemirror/lang-xml": "6.0.2",
"@codemirror/merge": "6.1.1",
"@codemirror/commands": "6.2.4",
"@codemirror/autocomplete": "6.8.1",
"@codemirror/search": "6.5.0",
"@codemirror/language": "6.8.0",
"@codemirror/state": "6.2.1",
"@codemirror/view": "6.14.1"
},
"peerDependencies": {}
"peerDependencies": {
"react": "~18.x.x",
"react-dom": "~18.x.x",
"mobx": "~6.x.x",
"mobx-react-lite": "~3.x.x",
"reshadow": "~0.x.x",
"@testing-library/jest-dom": "~5.x.x"
}
}
@@ -11,22 +11,22 @@ import styled from 'reshadow';
import { clsx } from '@cloudbeaver/core-utils';
import type { IDefaultExtensions } from './getDefaultExtensions';
import type { IEditorProps } from './IEditorProps';
import type { IEditorRef } from './IEditorRef';
import { ReactCodemirror } from './ReactCodemirror';
import { EDITOR_BASE_STYLES } from './theme';
import { useEditorDefaultExtensions } from './useDefaultExtensions';
import { useCodemirrorExtensions } from './useCodemirrorExtensions';
import { type IDefaultExtensions, useEditorDefaultExtensions } from './useEditorDefaultExtensions';
export const Editor = observer<IEditorProps & IDefaultExtensions, IEditorRef>(
forwardRef(function Editor({ lineNumbers, extensions = [], ...rest }, ref) {
forwardRef(function Editor({ lineNumbers, extensions, ...rest }, ref) {
extensions = useCodemirrorExtensions(extensions);
const defaultExtensions = useEditorDefaultExtensions({ lineNumbers });
const combinedExtensions = [...defaultExtensions];
combinedExtensions.push(...extensions);
extensions.set(...defaultExtensions);
return styled(EDITOR_BASE_STYLES)(
<wrapper className={clsx('editor', rest.className)}>
<ReactCodemirror {...rest} ref={ref} extensions={combinedExtensions} />
<ReactCodemirror {...rest} ref={ref} extensions={extensions} />
</wrapper>,
);
}),
@@ -9,9 +9,9 @@ import { forwardRef } from 'react';
import { ComplexLoader, createComplexLoader } from '@cloudbeaver/core-blocks';
import type { IDefaultExtensions } from './getDefaultExtensions';
import type { IEditorProps } from './IEditorProps';
import type { IEditorRef } from './IEditorRef';
import type { IDefaultExtensions } from './useEditorDefaultExtensions';
const loader = createComplexLoader(async function loader() {
const { Editor } = await import('./Editor');
@@ -5,14 +5,15 @@
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import type { Extension } from '@codemirror/state';
import type { Compartment, Extension } from '@codemirror/state';
import type { ViewUpdate } from '@codemirror/view';
export interface IReactCodeMirrorProps {
export interface IReactCodeMirrorProps extends React.PropsWithChildren {
/** in case of using editor in editing mode its better for performance to use getValue instead */
value?: string;
incomingValue?: string;
getValue?: () => string;
extensions?: Extension[];
extensions?: Map<Compartment, Extension>;
readonly?: boolean;
autoFocus?: boolean;
onChange?: (value: string, update: ViewUpdate) => void;
@@ -5,7 +5,8 @@
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { Annotation, EditorState, StateEffect } from '@codemirror/state';
import { MergeView } from '@codemirror/merge';
import { Annotation, Compartment, Extension, StateEffect } from '@codemirror/state';
import { EditorView, ViewUpdate } from '@codemirror/view';
import { observer } from 'mobx-react-lite';
import { forwardRef, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react';
@@ -14,81 +15,124 @@ import { useObjectRef } from '@cloudbeaver/core-blocks';
import type { IEditorRef } from './IEditorRef';
import type { IReactCodeMirrorProps } from './IReactCodemirrorProps';
import { type IReactCodemirrorContext, ReactCodemirrorContext } from './ReactCodemirrorContext';
import { useCodemirrorExtensions } from './useCodemirrorExtensions';
const External = Annotation.define<boolean>();
const defaultTheme = EditorView.theme({
'&': {
width: '100%',
height: '100%',
},
});
export const ReactCodemirror = observer<IReactCodeMirrorProps, IEditorRef>(
forwardRef(function ReactCodemirror({ getValue, value, extensions, readonly, autoFocus, onChange, onUpdate }, ref) {
forwardRef(function ReactCodemirror(
{ children, getValue, value, incomingValue, extensions = new Map<Compartment, Extension>(), readonly, autoFocus, onChange, onUpdate },
ref,
) {
value = value ?? getValue?.();
const currentExtensions = useRef<Map<Compartment, Extension>>(new Map());
const readOnlyFacet = useMemo(() => EditorView.editable.of(!readonly), [readonly]);
extensions = useCodemirrorExtensions(extensions, readOnlyFacet);
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const [view, setView] = useState<EditorView | null>(null);
const lastValueUpdate = useRef<string | undefined>(undefined);
const ext = [defaultTheme];
const [incomingView, setIncomingView] = useState<EditorView | null>(null);
const callbackRef = useObjectRef({ onChange, onUpdate });
if (readonly) {
// can lead to missing state updates
ext.push(EditorState.readOnly.of(true));
// ext.push(EditorView.editable.of(false));
}
if (extensions) {
ext.push(extensions);
}
const updateListener = useMemo(
() =>
EditorView.updateListener.of((update: ViewUpdate) => {
useLayoutEffect(() => {
if (container) {
const updateListener = EditorView.updateListener.of((update: ViewUpdate) => {
const remote = update.transactions.some(tr => tr.annotation(External));
if (update.docChanged && !remote) {
const doc = update.state.doc;
const value = doc.toString();
lastValueUpdate.current = value;
callbackRef.onChange?.(value, update);
}
callbackRef.onUpdate?.(update);
}),
[],
);
ext.push(updateListener);
useLayoutEffect(() => {
if (container) {
const ev = new EditorView({
parent: container,
});
setView(ev);
let editorView: EditorView;
let incomingView: EditorView | undefined;
let merge: MergeView | undefined;
ev.dom.addEventListener('keydown', event => {
const effects: Extension[] = [];
for (const [compartment, extension] of extensions) {
effects.push(compartment.of(extension));
}
if (incomingValue !== undefined) {
merge = new MergeView({
a: {
extensions: [updateListener, ...effects],
},
b: {
extensions: [EditorView.editable.of(false), ...effects],
},
parent: container,
});
editorView = merge.a;
incomingView = merge.b;
} else {
editorView = new EditorView({
parent: container,
extensions: [updateListener, ...effects],
});
}
if (incomingView) {
setIncomingView(incomingView);
}
setView(editorView);
currentExtensions.current = extensions;
editorView.dom.addEventListener('keydown', event => {
const newEvent = new KeyboardEvent('keydown', event);
document.dispatchEvent(newEvent);
});
return () => {
ev.destroy();
editorView.destroy();
merge?.destroy();
setView(null);
setIncomingView(null);
};
}
return () => {};
}, [container]);
}, [container, incomingValue !== undefined]);
useLayoutEffect(() => {
if (view && value !== lastValueUpdate.current) {
lastValueUpdate.current = value;
if (!view) {
return;
}
const effects: StateEffect<any>[] = [];
for (const [compartment, extension] of extensions) {
if (currentExtensions.current.get(compartment) !== extension) {
if (compartment.get(view.state) === undefined) {
effects.push(StateEffect.appendConfig.of(compartment.of(extension)));
} else {
effects.push(compartment.reconfigure(extension));
}
}
}
for (const compartment of currentExtensions.current.keys()) {
if (!extensions.has(compartment)) {
effects.push(compartment.reconfigure([]));
}
}
view.dispatch({ effects });
if (incomingView) {
incomingView.dispatch({ effects });
}
currentExtensions.current = extensions;
});
useLayoutEffect(() => {
if (value !== undefined && view && value !== view.state.doc.toString()) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
annotations: [External.of(true)],
@@ -97,10 +141,12 @@ export const ReactCodemirror = observer<IReactCodeMirrorProps, IEditorRef>(
}, [value, view]);
useLayoutEffect(() => {
if (view) {
view.dispatch({ effects: StateEffect.reconfigure.of(ext) });
if (incomingValue !== undefined && incomingView && incomingValue !== incomingView.state.doc.toString()) {
incomingView.dispatch({
changes: { from: 0, to: incomingView.state.doc.length, insert: incomingValue },
});
}
});
}, [incomingValue, incomingView]);
useLayoutEffect(() => {
if (!readonly && autoFocus && view) {
@@ -117,6 +163,20 @@ export const ReactCodemirror = observer<IReactCodeMirrorProps, IEditorRef>(
[container, view],
);
return <div ref={setContainer} className="ReactCodemirror" />;
const context = useMemo<IReactCodemirrorContext>(
() => ({
view,
incomingView,
}),
[view, incomingView],
);
return (
<ReactCodemirrorContext.Provider value={context}>
<div ref={setContainer} className="ReactCodemirror">
{children}
</div>
</ReactCodemirrorContext.Provider>
);
}),
);
@@ -0,0 +1,16 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 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 { EditorView } from '@codemirror/view';
import { createContext } from 'react';
export interface IReactCodemirrorContext {
view: EditorView | null;
incomingView: EditorView | null;
}
export const ReactCodemirrorContext = createContext<IReactCodemirrorContext | null>(null);
@@ -0,0 +1,56 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 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 { Compartment, StateEffect } from '@codemirror/state';
import { showPanel } from '@codemirror/view';
import { useContext, useLayoutEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { ReactCodemirrorContext } from './ReactCodemirrorContext';
interface Props extends React.PropsWithChildren {
className?: string;
incomingView?: boolean;
top?: boolean;
}
export const ReactCodemirrorPanel: React.FC<Props> = function ReactCodemirrorPanel({ className, children, incomingView, top }) {
const dom = useMemo(() => document.createElement('div'), []);
const compartment = useMemo(() => new Compartment(), []);
const context = useContext(ReactCodemirrorContext);
const view = incomingView ? context?.incomingView : context?.view;
useLayoutEffect(() => {
if (view) {
view.dispatch({
effects: [StateEffect.appendConfig.of(compartment.of(showPanel.of(() => ({ top, dom }))))],
});
return () => {
view.dispatch({
effects: compartment.reconfigure([]),
});
};
}
return undefined;
}, [view, top]);
useLayoutEffect(() => {
if (className) {
const classes = className.split(' ');
dom.classList.add(...classes);
return () => {
dom.classList.remove(...classes);
};
}
return undefined;
}, [className]);
return createPortal(children, dom);
};
@@ -1,81 +0,0 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 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 { defaultKeymap, indentWithTab } from '@codemirror/commands';
import { bracketMatching, foldGutter, indentOnInput, syntaxHighlighting } from '@codemirror/language';
import { highlightSelectionMatches } from '@codemirror/search';
import type { Extension } from '@codemirror/state';
import {
crosshairCursor,
dropCursor,
highlightActiveLine,
highlightActiveLineGutter,
highlightSpecialChars,
keymap,
lineNumbers,
rectangularSelection,
tooltips,
} from '@codemirror/view';
import { classHighlighter } from '@lezer/highlight';
import { clsx, GlobalConstants } from '@cloudbeaver/core-utils';
// @TODO allow to configure bindings outside of the component
const DEFAULT_KEY_MAP = defaultKeymap.filter(binding => binding.mac !== 'Ctrl-f' && binding.key !== 'Mod-Enter');
DEFAULT_KEY_MAP.push(indentWithTab);
export interface IDefaultExtensions {
lineNumbers?: boolean;
}
/** Provides the necessary extensions to establish a basic editor */
export function getDefaultExtensions(options?: IDefaultExtensions): Record<string, Extension> {
let extensions: Record<string, Extension> = {
tooltips: tooltips({
parent: document.body,
}),
highlightSpecialChars: highlightSpecialChars(),
highlightSelectionMatches: highlightSelectionMatches(),
syntaxHighlighting: syntaxHighlighting(classHighlighter),
bracketMatching: bracketMatching(),
dropCursor: dropCursor(),
crosshairCursor: crosshairCursor(),
foldGutter: foldGutter({
markerDOM: (open: boolean) => {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttributeNS(null, 'viewBox', '0 0 15 8');
svg.style.maxWidth = '100%';
svg.style.maxHeight = '100%';
const use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
use.setAttributeNS('http://www.w3.org/1999/xlink', 'href', GlobalConstants.absoluteUrl('/icons/icons.svg#angle'));
svg.appendChild(use);
const element = document.createElement('div');
element.appendChild(svg);
element.className = clsx('cm-gutterElement-icon', open ? 'cm-foldGutter-open' : 'cm-foldGutter-folded');
return element;
},
}),
highlightActiveLineGutter: highlightActiveLineGutter(),
highlightActiveLine: highlightActiveLine(),
indentOnInput: indentOnInput(),
rectangularSelection: rectangularSelection(),
keymap: keymap.of(DEFAULT_KEY_MAP),
};
if (options?.lineNumbers) {
extensions = {
lineNumbers: lineNumbers(),
...extensions,
};
}
return extensions;
}
@@ -1,10 +1,12 @@
import { createComplexLoader } from '@cloudbeaver/core-blocks';
export * from './ReactCodemirrorPanel';
export * from './EditorLoader';
export * from './IEditorProps';
export * from './IEditorRef';
export type { IDefaultExtensions } from './getDefaultExtensions';
export * from './useEditorDefaultExtensions';
export * from './useEditorAutocompletion';
export * from './useCodemirrorExtensions';
export * from '@codemirror/view';
export * from '@codemirror/state';
@@ -7,12 +7,27 @@
*/
@mixin base-code-editor() {
.editor,
.cm-editor,
.cm-mergeView,
.cm-mergeViewEditors,
.cm-scroller,
.ReactCodemirror {
width: 100%;
height: 100% !important;
}
.editor,
.ReactCodemirror {
.ReactCodemirror,
.cm-mergeView,
.cm-mergeViewEditors,
.cm-editor {
@include mdc-typography('body2');
width: 100%;
height: 100%;
}
.cm-mergeViewEditor:first-child {
border-right: 1px solid;
@include mdc-theme-prop(border-color, background, false);
}
.editor {
@@ -37,22 +52,26 @@
border-left: 1.2px solid $cursor;
}
.editor .cm-gutters {
.editor .cm-gutters,
.cm-panel {
@include mdc-theme-prop(background-color, surface, false);
@include mdc-theme-prop(color, text-hint-on-light, false);
@include mdc-theme-prop(border-color, background, false);
}
.editor .cm-activeLine {
background: none;
box-shadow: 0 0 0 2px $mdc-theme-sub-secondary;
}
.editor .cm-activeLineGutter {
background: none;
@include mdc-theme-prop(color, on-surface, false);
}
.ͼ2 .cm-activeLine:not(.cm-changedLine, .cm-deletedLine),
.ͼ2 .cm-activeLineGutter:not(.cm-changedLineGutter, .cm-deletedLineGutter) {
background-color: unset;
}
.editor .active-query {
/* disable because look too ugly */
/*
@@ -62,21 +81,10 @@
}
.editor .cm-content ::selection {
@include mdc-theme-prop(background, primary, false);
color: white;
}
.editor .cm-line::selection,
.editor .cm-line> :global(span)::selection,
.editor .cm-line> :global(span)> :global(span)::selection {
@include mdc-theme-prop(background, primary, false);
}
.editor .cm-line::-moz-selection,
.editor .cm-line> :global(span)::-moz-selection,
.editor .cm-line> :global(span)> :global(span)::-moz-selection {
@include mdc-theme-prop(background, primary, false);
}
.editor .cm-lineNumbers .cm-gutterElement {
min-width: 3ch;
box-sizing: content-box;
@@ -197,4 +205,4 @@
.editor .tok-type {
color: $variable-3;
}
}
}
@@ -0,0 +1,16 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 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 { Compartment, Extension } from '@codemirror/state';
import { useState } from 'react';
export function useCodemirrorExtensions(extensions?: Map<Compartment, Extension>, staticExtensions?: Extension): Map<Compartment, Extension> {
const [compartment] = useState(() => new Compartment());
const map = new Map(extensions?.entries());
map.set(compartment, staticExtensions || []);
return map;
}
@@ -1,52 +0,0 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 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 { Extension } from '@codemirror/state';
import { useState } from 'react';
import { createComplexLoader, useComplexLoader } from '@cloudbeaver/core-blocks';
import type { IDefaultExtensions } from './getDefaultExtensions';
// chunk splitting
const getDefaultExtensionsLoader = createComplexLoader(() => import('./getDefaultExtensions').then(module => module.getDefaultExtensions));
export function useEditorDefaultExtensions(options?: IDefaultExtensions) {
const getDefaultExtensions = useComplexLoader(getDefaultExtensionsLoader);
const [extensions, setExtensions] = useState<Record<string, Extension>>({});
const newExtensions = getDefaultExtensions(options);
const diff: Record<string, 'add' | 'remove'> = {};
for (const key in extensions) {
if (!(key in newExtensions)) {
diff[key] = 'remove';
}
}
for (const key in newExtensions) {
if (!(key in extensions)) {
diff[key] = 'add';
}
}
if (Object.keys(diff).length > 0) {
const nextExtensions = { ...extensions };
for (const key in diff) {
if (diff[key] === 'add') {
nextExtensions[key] = newExtensions[key];
} else {
delete nextExtensions[key];
}
}
setExtensions(nextExtensions);
}
return Object.values(extensions);
}
@@ -6,28 +6,30 @@
* you may not use this file except in compliance with the License.
*/
import { autocompletion, startCompletion } from '@codemirror/autocomplete';
import type { Extension } from '@codemirror/state';
import { Compartment, type Extension } from '@codemirror/state';
import { keymap } from '@codemirror/view';
import { useMemo, useState } from 'react';
import { useMemo } from 'react';
export type CompletionConfig = Parameters<typeof autocompletion>[0];
export function useEditorAutocompletion(config?: CompletionConfig): Extension[] {
const [autocompleteKeyMap] = useState(() =>
keymap.of([
{ key: 'Alt-Space', run: startCompletion, preventDefault: true },
{ key: 'Shift-Ctrl-Space', run: startCompletion, preventDefault: true },
]),
);
const EDITOR_AUTOCOMPLETION_COMPARTMENT = new Compartment();
const EDITOR_AUTOCOMPLETION_KEYMAP = keymap.of([
{ key: 'Alt-Space', run: startCompletion, preventDefault: true },
{ key: 'Shift-Ctrl-Space', run: startCompletion, preventDefault: true },
]);
export function useEditorAutocompletion(config?: CompletionConfig): [Compartment, Extension] {
const autocompletionExtension = useMemo(
() =>
() => [
EDITOR_AUTOCOMPLETION_KEYMAP,
autocompletion({
...config,
closeOnBlur: false,
}),
],
[config],
);
return [autocompletionExtension, autocompleteKeyMap];
return [EDITOR_AUTOCOMPLETION_COMPARTMENT, autocompletionExtension];
}
@@ -0,0 +1,88 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 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 { defaultKeymap, indentWithTab } from '@codemirror/commands';
import { bracketMatching, foldGutter, indentOnInput, syntaxHighlighting } from '@codemirror/language';
import { highlightSelectionMatches } from '@codemirror/search';
import { Compartment, Extension } from '@codemirror/state';
import {
crosshairCursor,
dropCursor,
highlightActiveLine,
highlightActiveLineGutter,
highlightSpecialChars,
keymap,
lineNumbers,
rectangularSelection,
tooltips,
} from '@codemirror/view';
import { classHighlighter } from '@lezer/highlight';
import { useMemo } from 'react';
import { clsx, GlobalConstants } from '@cloudbeaver/core-utils';
// @TODO allow to configure bindings outside of the component
const DEFAULT_KEY_MAP = defaultKeymap.filter(binding => binding.mac !== 'Ctrl-f' && binding.key !== 'Mod-Enter');
DEFAULT_KEY_MAP.push(indentWithTab);
DEFAULT_KEY_MAP.push({
key: 'Mod-s',
run: () => true,
});
export interface IDefaultExtensions {
lineNumbers?: boolean;
}
const DEFAULT_EXTENSIONS_COMPARTMENT = new Compartment();
/** Provides the necessary extensions to establish a basic editor */
export function useEditorDefaultExtensions(options?: IDefaultExtensions): [Compartment, Extension] {
return useMemo(() => {
const extensions = [];
if (options?.lineNumbers) {
extensions.push(lineNumbers());
}
extensions.push(
tooltips({
parent: document.body,
}),
highlightSpecialChars(),
highlightSelectionMatches(),
syntaxHighlighting(classHighlighter),
bracketMatching(),
dropCursor(),
crosshairCursor(),
foldGutter({
markerDOM: (open: boolean) => {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttributeNS(null, 'viewBox', '0 0 15 8');
svg.style.maxWidth = '100%';
svg.style.maxHeight = '100%';
const use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
use.setAttributeNS('http://www.w3.org/1999/xlink', 'href', GlobalConstants.absoluteUrl('/icons/icons.svg#angle'));
svg.appendChild(use);
const element = document.createElement('div');
element.appendChild(svg);
element.className = clsx('cm-gutterElement-icon', open ? 'cm-foldGutter-open' : 'cm-foldGutter-folded');
return element;
},
}),
highlightActiveLineGutter(),
highlightActiveLine(),
indentOnInput(),
rectangularSelection(),
keymap.of(DEFAULT_KEY_MAP),
);
return [DEFAULT_EXTENSIONS_COMPARTMENT, extensions];
}, [options?.lineNumbers]);
}
@@ -16,7 +16,7 @@ import { NotificationService } from '@cloudbeaver/core-events';
import { QuotasService } from '@cloudbeaver/core-root';
import { BASE_TAB_STYLES, TabContainerPanelComponent, TabList, TabsState, UNDERLINE_TAB_STYLES } from '@cloudbeaver/core-ui';
import { bytesToSize } from '@cloudbeaver/core-utils';
import { EditorLoader } from '@cloudbeaver/plugin-codemirror6';
import { EditorLoader, useCodemirrorExtensions } from '@cloudbeaver/plugin-codemirror6';
import type { IResultSetElementKey } from '../../DatabaseDataModel/Actions/ResultSet/IResultSetDataKey';
import { isResultSetContentValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetContentValue';
@@ -176,6 +176,7 @@ export const TextValuePresentation: TabContainerPanelComponent<IDataValuePanelPr
const autoFormat = !!firstSelectedCell && !editor.isElementEdited(firstSelectedCell);
const canSave = !!firstSelectedCell && content.isDownloadable(firstSelectedCell);
const typeExtension = useMemo(() => getTypeExtension(state.currentContentType) ?? [], [state.currentContentType]);
const extensions = useCodemirrorExtensions(undefined, typeExtension);
const value = autoFormat ? formatter.format(state.currentContentType, stringValue) : stringValue;
@@ -191,13 +192,7 @@ export const TextValuePresentation: TabContainerPanelComponent<IDataValuePanelPr
<TabList style={[BASE_TAB_STYLES, styles, UNDERLINE_TAB_STYLES]} />
</TabsState>
</actions>
<EditorLoader
key={readonly ? '1' : '0'}
value={value}
readonly={readonly}
extensions={[typeExtension]}
onChange={value => handleChange(value)}
/>
<EditorLoader key={readonly ? '1' : '0'} value={value} readonly={readonly} extensions={extensions} onChange={value => handleChange(value)} />
{valueTruncated && <QuotaPlaceholder limit={limit} size={valueSize} />}
{canSave && (
<tools-container>
@@ -16,6 +16,7 @@ import {
} from '@cloudbeaver/core-connections';
import { MenuBar } from '@cloudbeaver/core-ui';
import { useMenu } from '@cloudbeaver/core-view';
import { useCodemirrorExtensions } from '@cloudbeaver/plugin-codemirror6';
import type { NavNodeTransformViewComponent } from '@cloudbeaver/plugin-navigation-tree';
import { SQLCodeEditorLoader, useSqlDialectExtension } from '@cloudbeaver/plugin-sql-editor-new';
@@ -36,13 +37,15 @@ export const DDLViewerTabPanel: NavNodeTransformViewComponent = observer(functio
const connectionParam = connection ? createConnectionParam(connection) : null;
const connectionDialectResource = useResource(DDLViewerTabPanel, ConnectionDialectResource, connectionParam);
const sqlDialect = useSqlDialectExtension(connectionDialectResource.data);
const extensions = useCodemirrorExtensions();
extensions.set(...sqlDialect);
menu.context.set(DATA_CONTEXT_DDL_VIEWER_NODE, nodeId);
menu.context.set(DATA_CONTEXT_DDL_VIEWER_VALUE, ddlResource.data);
return (
<div className={s(styles, { wrapper: true })}>
<SQLCodeEditorLoader className={s(styles, { sqlCodeEditorLoader: true })} value={ddlResource.data ?? ''} extensions={[sqlDialect]} readonly />
<SQLCodeEditorLoader className={s(styles, { sqlCodeEditorLoader: true })} value={ddlResource.data ?? ''} extensions={extensions} readonly />
<MenuBar className={s(styles, { menuBar: true })} menu={menu} />
</div>
);
@@ -16,6 +16,7 @@ import {
} from '@cloudbeaver/core-connections';
import { MenuBar } from '@cloudbeaver/core-ui';
import { useMenu } from '@cloudbeaver/core-view';
import { useCodemirrorExtensions } from '@cloudbeaver/plugin-codemirror6';
import type { NavNodeTransformViewComponent } from '@cloudbeaver/plugin-navigation-tree';
import { SQLCodeEditorLoader, useSqlDialectExtension } from '@cloudbeaver/plugin-sql-editor-new';
@@ -36,6 +37,8 @@ export const ExtendedDDLViewerTabPanel: NavNodeTransformViewComponent = observer
const connectionParam = connection ? createConnectionParam(connection) : null;
const connectionDialectResource = useResource(ExtendedDDLViewerTabPanel, ConnectionDialectResource, connectionParam);
const sqlDialect = useSqlDialectExtension(connectionDialectResource.data);
const extensions = useCodemirrorExtensions();
extensions.set(...sqlDialect);
menu.context.set(DATA_CONTEXT_DDL_VIEWER_NODE, nodeId);
menu.context.set(DATA_CONTEXT_DDL_VIEWER_VALUE, extendedDDLResource.data);
@@ -45,7 +48,7 @@ export const ExtendedDDLViewerTabPanel: NavNodeTransformViewComponent = observer
<SQLCodeEditorLoader
className={s(styles, { sqlCodeEditorLoader: true })}
value={extendedDDLResource.data ?? ''}
extensions={[sqlDialect]}
extensions={extensions}
readonly
/>
<MenuBar className={s(styles, { menuBar: true })} menu={menu} />
@@ -5,6 +5,7 @@
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { getOS, OperatingSystem } from '@cloudbeaver/core-utils';
import { getCommonAndOSSpecificKeys, IKeyBinding, KEY_BINDING_OPEN_IN_TAB, KEY_BINDING_REDO, KEY_BINDING_UNDO } from '@cloudbeaver/core-view';
import { KEY_BINDING_COLLAPSE_ALL, KEY_BINDING_ENABLE_FILTER, KEY_BINDING_LINK_OBJECT } from '@cloudbeaver/plugin-navigation-tree';
import {
@@ -101,5 +102,17 @@ export const NAVIGATION_TREE_SHORTCUTS: IShortcut[] = [
function transformKeys(keyBinding: IKeyBinding): string[] {
const keys = getCommonAndOSSpecificKeys(keyBinding);
return keys.map(key => key.toLocaleUpperCase().replace(/\+/gi, ' + '));
return keys.map(key => transformModToDisplayKey(key.toLocaleUpperCase().replace(/\+/gi, ' + ')));
}
function transformModToDisplayKey(key: string): string {
const OS = getOS();
if (OS === OperatingSystem.windowsOS) {
return key.replace('MOD', 'CTRL');
}
if (OS === OperatingSystem.macOS) {
return key.replace('MOD', 'CMD');
}
return key;
}
@@ -47,8 +47,12 @@ export const DataSynchronizationNotification = observer<NotificationComponentPro
notification.close(false);
}
function ignoreChanges() {
notification.close(false);
}
return styled(styles)(
<SnackbarWrapper onClose={() => notification.close(false)}>
<SnackbarWrapper onClose={ignoreChanges}>
<SnackbarStatus status={ENotificationType.Info} />
<SnackbarContent>
<SnackbarBody title={translate(notification.title)}>
@@ -62,7 +66,10 @@ export const DataSynchronizationNotification = observer<NotificationComponentPro
</message>
</SnackbarBody>
<SnackbarFooter timestamp={notification.timestamp}>
<Button type="button" mod={['outlined']} onClick={applyChanges}>
<Button type="button" mod={['outlined']} onClick={ignoreChanges}>
{translate('ui_ignore')}
</Button>
<Button type="button" mod={['unelevated']} onClick={applyChanges}>
{translate('ui_apply')}
</Button>
</SnackbarFooter>
@@ -6,8 +6,13 @@
* you may not use this file except in compliance with the License.
*/
import type { IConnectionExecutionContextInfo } from '@cloudbeaver/core-connections';
import type { ISqlDataSourceHistoryState } from '@cloudbeaver/plugin-sql-editor';
export interface IResourceSqlDataSourceState {
resourceKey?: string;
script: string;
baseScript: string;
executionContext?: IConnectionExecutionContextInfo;
baseExecutionContext?: IConnectionExecutionContextInfo;
history: ISqlDataSourceHistoryState;
}
@@ -16,7 +16,7 @@ import { CachedMapAllKey } from '@cloudbeaver/core-sdk';
import { createPath, getPathName } from '@cloudbeaver/core-utils';
import { ACTION_SAVE, ActionService, DATA_CONTEXT_MENU, KEY_BINDING_SAVE, KeyBindingService, MenuService } from '@cloudbeaver/core-view';
import { NavigationTabsService } from '@cloudbeaver/plugin-navigation-tabs';
import { getResourceKeyFromNodeId, NavResourceNodeService } from '@cloudbeaver/plugin-navigation-tree-rm';
import { getResourceKeyFromNodeId } from '@cloudbeaver/plugin-navigation-tree-rm';
import { ResourceManagerService } from '@cloudbeaver/plugin-resource-manager';
import { ResourceManagerScriptsService, SaveScriptDialog, SCRIPTS_TYPE_ID } from '@cloudbeaver/plugin-resource-manager-scripts';
import {
@@ -28,7 +28,6 @@ import {
MemorySqlDataSource,
SQL_EDITOR_TOOLS_MENU,
SqlDataSourceService,
SqlEditorService,
SqlEditorSettingsService,
} from '@cloudbeaver/plugin-sql-editor';
import { isSQLEditorTab, SqlEditorNavigatorService } from '@cloudbeaver/plugin-sql-editor-navigation-tab';
@@ -41,7 +40,6 @@ import { SqlEditorTabResourceService } from './SqlEditorTabResourceService';
export class PluginBootstrap extends Bootstrap {
constructor(
private readonly navNodeManagerService: NavNodeManagerService,
private readonly navResourceNodeService: NavResourceNodeService,
private readonly navNodeInfoResource: NavNodeInfoResource,
private readonly navigationTabsService: NavigationTabsService,
private readonly notificationService: NotificationService,
@@ -55,7 +53,6 @@ export class PluginBootstrap extends Bootstrap {
private readonly menuService: MenuService,
private readonly sqlDataSourceService: SqlDataSourceService,
private readonly sqlEditorSettingsService: SqlEditorSettingsService,
private readonly sqlEditorService: SqlEditorService,
private readonly resourceManagerResource: ResourceManagerResource,
private readonly resourceManagerScriptsService: ResourceManagerScriptsService,
private readonly keyBindingService: KeyBindingService,
@@ -70,33 +67,24 @@ export class PluginBootstrap extends Bootstrap {
this.actionService.addHandler({
id: 'scripts-base-handler',
isActionApplicable: (context, action): boolean => {
if (action === ACTION_SAVE_AS_SCRIPT || action === ACTION_SAVE) {
const state = context.tryGet(DATA_CONTEXT_SQL_EDITOR_STATE);
const state = context.tryGet(DATA_CONTEXT_SQL_EDITOR_STATE);
if (!state) {
return false;
}
if (!state) {
return false;
}
if (!this.projectsService.activeProjects.some(project => project.canEditResources)) {
return false;
}
if (!this.projectsService.activeProjects.some(project => project.canEditResources)) {
return false;
}
const dataSource = this.sqlDataSourceService.get(state.editorId);
const dataSource = this.sqlDataSourceService.get(state.editorId);
if (action === ACTION_SAVE_AS_SCRIPT) {
return dataSource instanceof MemorySqlDataSource || dataSource instanceof LocalStorageSqlDataSource;
}
if (action === ACTION_SAVE) {
return dataSource instanceof ResourceSqlDataSource;
}
if (action === ACTION_SAVE_AS_SCRIPT) {
return dataSource instanceof MemorySqlDataSource || dataSource instanceof LocalStorageSqlDataSource;
}
return false;
},
isHidden: (context, action) => {
if (action === ACTION_SAVE) {
return this.sqlEditorService.autoSave;
return dataSource?.isAutoSaveEnabled === false;
}
return false;
@@ -196,7 +184,7 @@ export class PluginBootstrap extends Bootstrap {
return true;
}
return source.isLoading() || source.isSaved();
return source.isLoading() || source.isSaved;
}
return false;
@@ -17,7 +17,7 @@ import { TaskScheduler } from '@cloudbeaver/core-executor';
import type { ProjectInfoResource } from '@cloudbeaver/core-projects';
import { getRmResourceKey, ResourceManagerResource } from '@cloudbeaver/core-resource-manager';
import { isResourceAlias, ResourceKey, ResourceKeyUtils } from '@cloudbeaver/core-sdk';
import { createPath, debounce, getPathName, getPathParent, isArraysEqual, isObjectsEqual, isValuesEqual } from '@cloudbeaver/core-utils';
import { debounce, getPathName, isArraysEqual, isObjectsEqual, isValuesEqual } from '@cloudbeaver/core-utils';
import { SCRIPTS_TYPE_ID } from '@cloudbeaver/plugin-resource-manager-scripts';
import { BaseSqlDataSource, ESqlDataSourceFeatures, SqlEditorService } from '@cloudbeaver/plugin-sql-editor';
@@ -28,7 +28,7 @@ interface IResourceInfo {
}
interface IResourceActions {
rename(dataSource: ResourceSqlDataSource, key: string, newKey: string): Promise<string>;
rename(dataSource: ResourceSqlDataSource, key: string, name: string): Promise<string>;
read(dataSource: ResourceSqlDataSource, key: string): Promise<string>;
write(dataSource: ResourceSqlDataSource, key: string, value: string): Promise<void>;
getProperties(dataSource: ResourceSqlDataSource, key: string): Promise<IConnectionExecutionContextInfo | undefined>;
@@ -55,7 +55,15 @@ export class ResourceSqlDataSource extends BaseSqlDataSource {
}
get script(): string {
return this._script;
return this.state.script;
}
get baseScript(): string {
return this.state.baseScript;
}
get baseExecutionContext(): IConnectionExecutionContextInfo | undefined {
return this.state.baseExecutionContext;
}
get projectId(): string | null {
@@ -92,11 +100,14 @@ export class ResourceSqlDataSource extends BaseSqlDataSource {
return [ESqlDataSourceFeatures.script, ESqlDataSourceFeatures.query, ESqlDataSourceFeatures.executable, ESqlDataSourceFeatures.setName];
}
private _script: string;
get isAutoSaveEnabled(): boolean {
return this.sqlEditorService.autoSave;
}
private actions?: IResourceActions;
private info?: IResourceInfo;
private lastAction: (() => Promise<void>) | undefined;
private readonly state: IResourceSqlDataSourceState;
private state!: IResourceSqlDataSourceState;
private loaded: boolean;
private readonly scheduler: TaskScheduler;
@@ -110,8 +121,7 @@ export class ResourceSqlDataSource extends BaseSqlDataSource {
state: IResourceSqlDataSourceState,
) {
super();
this.state = state;
this._script = '';
this.bindState(state);
this.lastAction = undefined;
this.loaded = false;
this.resourceUseKeyId = null;
@@ -122,18 +132,15 @@ export class ResourceSqlDataSource extends BaseSqlDataSource {
resourceManagerResource.onDataOutdated.addHandler(this.syncResource);
makeObservable<this, '_script' | 'lastAction' | 'loaded'>(this, {
makeObservable<this, 'lastAction' | 'loaded'>(this, {
script: computed,
executionContext: computed,
resourceKey: computed,
features: computed<ESqlDataSourceFeatures[]>({
equals: isArraysEqual,
}),
_script: observable,
lastAction: observable.ref,
loaded: observable,
setExecutionContext: action,
setScript: action,
setResourceKey: action,
});
}
@@ -154,6 +161,20 @@ export class ResourceSqlDataSource extends BaseSqlDataSource {
return this.scheduler.executing;
}
canRename(name: string | null): boolean {
if (this.isReadonly()) {
return false;
}
if (!name) {
return false;
}
name = name.trim();
return !!this.actions && !!this.resourceKey && name.length > 0;
}
setResourceKey(resourceKey: string | undefined): void {
if (this.state.resourceKey && this.resourceUseKeyId) {
this.resourceManagerResource.free(toJS(this.state.resourceKey), this.resourceUseKeyId);
@@ -161,7 +182,8 @@ export class ResourceSqlDataSource extends BaseSqlDataSource {
}
this.state.resourceKey = toJS(resourceKey);
this.reloadResource();
this.loaded = false;
this.markOutdated();
this.onUpdate.execute();
}
@@ -174,78 +196,45 @@ export class ResourceSqlDataSource extends BaseSqlDataSource {
}
setName(name: string | null): void {
this.rename(name);
name = name?.trim() ?? null;
if (!name || name === this.name) {
return;
}
const previousName = this.name;
super.setName(name);
this.rename(name).catch(() => {
super.setName(previousName);
});
}
setProject(projectId: string | null): void {
super.setProject(projectId);
}
canRename(name: string | null): boolean {
if (this.isReadonly()) {
return false;
}
if (!name) {
return false;
}
name = name.trim();
return !!this.actions && !!this.resourceKey && this.saved && name.length > 0;
}
setScript(script: string): void {
const previous = this._script;
const previous = this.state.script;
if (previous === script) {
return;
}
this._script = script;
this.state.script = script;
super.setScript(script);
if (this.sqlEditorService.autoSave && previous !== script) {
if (this.isAutoSaveEnabled) {
this.debouncedWrite();
}
}
dispose(): void {
super.dispose();
this.resourceManagerResource.onItemUpdate.removeHandler(this.syncResource);
if (this.state.resourceKey && this.resourceUseKeyId) {
this.resourceManagerResource.free(this.state.resourceKey, this.resourceUseKeyId);
this.resourceUseKeyId = null;
}
}
setExecutionContext(executionContext: IConnectionExecutionContextInfo | undefined): void {
executionContext = JSON.parse(JSON.stringify(toJS(executionContext) ?? {}));
syncResource(key: ResourceKey<string>) {
if (isResourceAlias(key)) {
return;
}
const resourceKey = this.resourceKey;
if (resourceKey && ResourceKeyUtils.some(key, key => resourceKey.startsWith(key))) {
this.reloadResource();
}
}
reloadResource(): void {
this.markOutdated();
this.saved = true;
this.loaded = false;
}
async load(): Promise<void> {
if (this.state.resourceKey && !this.resourceUseKeyId) {
this.resourceUseKeyId = this.resourceManagerResource.use(this.state.resourceKey);
}
await this.read();
}
setExecutionContext(executionContext?: IConnectionExecutionContextInfo): void {
if (this.resourceKey && executionContext?.projectId && getRmResourceKey(this.resourceKey).projectId !== executionContext.projectId) {
throw new Error('Resource SQL Data Source and Execution context projects don\t match');
}
if (!isObjectsEqual(toJS(this.state.executionContext), toJS(executionContext))) {
if (!isObjectsEqual(toJS(this.state.executionContext), executionContext)) {
const initNew =
!isValuesEqual(executionContext?.connectionId, this.executionContext?.connectionId, undefined) ||
!isValuesEqual(executionContext?.defaultCatalog, this.executionContext?.defaultCatalog, undefined) ||
@@ -263,130 +252,182 @@ export class ResourceSqlDataSource extends BaseSqlDataSource {
}
}
this.state.executionContext = toJS(executionContext);
this.state.executionContext = executionContext;
super.setExecutionContext(executionContext);
if (this.sqlEditorService.autoSave) {
if (this.isAutoSaveEnabled) {
this.debouncedSaveProperties();
}
}
}
async rename(name: string | null) {
await this.write();
await this.scheduler.schedule(undefined, async () => {
if (!this.actions || !this.resourceKey || !this.projectId || !this.saved || !name?.trim()) {
return;
}
name = this.projectInfoResource.getNameWithExtension(this.projectId, SCRIPTS_TYPE_ID, name);
this.lastAction = this.rename.bind(this, name);
this.message = 'plugin_sql_editor_navigation_tab_script_state_renaming';
try {
this.exception = null;
this.setResourceKey(await this.actions.rename(this, this.resourceKey, createPath(getPathParent(this.resourceKey), name)));
} catch (exception: any) {
this.exception = exception;
} finally {
this.message = undefined;
}
});
}
async read() {
await this.scheduler.schedule(undefined, async () => {
if (!this.actions || !this.resourceKey || !this.isOutdated() || !this.saved) {
return;
}
this.lastAction = this.read.bind(this);
this.message = 'plugin_sql_editor_navigation_tab_script_state_reading';
try {
this.exception = null;
this._script = await this.actions.read(this, this.resourceKey);
const executionContext = await this.actions.getProperties(this, this.resourceKey);
runInAction(() => {
if (executionContext) {
this.setExecutionContext(executionContext);
} else {
this.setExecutionContext();
}
});
this.markUpdated();
this.loaded = true;
super.setScript(this.script);
this.saved = true;
} catch (exception: any) {
this.exception = exception;
} finally {
this.message = undefined;
}
});
}
async write() {
await this.scheduler.schedule(undefined, async () => {
if (!this.actions || !this.resourceKey || this.saved) {
return;
}
this.lastAction = this.write.bind(this);
this.message = 'plugin_sql_editor_navigation_tab_script_state_saving';
try {
this.exception = null;
await this.actions.write(this, this.resourceKey, this.script);
this.saved = true;
this.markUpdated();
} catch (exception: any) {
this.exception = exception;
} finally {
this.message = undefined;
}
});
async load(): Promise<void> {
if (this.state.resourceKey && !this.resourceUseKeyId) {
this.resourceUseKeyId = this.resourceManagerResource.use(this.state.resourceKey);
}
await this.read();
}
async save(): Promise<void> {
await this.write();
await this.saveProperties();
try {
await this.write();
await this.saveProperties();
super.save();
} catch (exception: any) {
this.exception = exception;
}
}
private async saveProperties() {
dispose(): void {
super.dispose();
this.resourceManagerResource.onItemUpdate.removeHandler(this.syncResource);
if (this.state.resourceKey && this.resourceUseKeyId) {
this.resourceManagerResource.free(this.state.resourceKey, this.resourceUseKeyId);
this.resourceUseKeyId = null;
}
}
bindState(state: IResourceSqlDataSourceState): void {
this.state = state;
this.outdated = true;
this.history.restore(state.history);
}
private syncResource(key: ResourceKey<string>) {
if (isResourceAlias(key)) {
return;
}
const resourceKey = this.resourceKey;
if (resourceKey && ResourceKeyUtils.some(key, key => resourceKey.startsWith(key))) {
this.markOutdated();
}
}
private async rename(name: string) {
await this.save();
await this.scheduler.schedule(undefined, async () => {
if (!this.actions || !this.resourceKey) {
return;
}
this.lastAction = this.rename.bind(this, name);
try {
if (!this.isSaved) {
throw new Error('Please save changes before renaming');
}
this.exception = null;
this.message = 'plugin_sql_editor_navigation_tab_script_state_renaming';
this.setResourceKey(await this.actions.rename(this, this.resourceKey, name));
} catch (exception: any) {
this.exception = exception;
throw exception;
} finally {
this.message = undefined;
}
});
}
private async read() {
await this.scheduler.schedule(undefined, async () => {
if (!this.actions || !this.resourceKey || !this.isOutdated()) {
return;
}
this.lastAction = this.read.bind(this);
try {
this.exception = null;
await this.readData();
} catch (exception: any) {
this.exception = exception;
}
});
}
private async saveProperties() {
await this.scheduler.schedule(undefined, async () => {
if (!this.actions || !this.resourceKey || this.isExecutionContextSaved) {
return;
}
this.lastAction = this.saveProperties.bind(this);
this.message = 'plugin_sql_editor_navigation_tab_script_state_updating';
try {
this.exception = null;
if (!this.isReadonly()) {
this.message = 'plugin_sql_editor_navigation_tab_script_state_updating';
const executionContext = await this.actions.setProperties(this, this.resourceKey, this.executionContext);
if (executionContext) {
this.setExecutionContext(executionContext);
} else {
this.setExecutionContext();
}
this.setExecutionContext(executionContext);
this.setBaseExecutionContext(this.executionContext);
}
} catch (exception: any) {
this.exception = exception;
} finally {
this.message = undefined;
}
});
}
private async write() {
await this.scheduler.schedule(undefined, async () => {
if (!this.actions || !this.resourceKey || this.isScriptSaved) {
return;
}
this.lastAction = this.write.bind(this);
try {
this.exception = null;
await this.readData();
if (!this.isIncomingChanges) {
this.message = 'plugin_sql_editor_navigation_tab_script_state_saving';
await this.actions.write(this, this.resourceKey, this.script);
this.setBaseScript(this.script);
}
} finally {
this.message = undefined;
}
});
}
private async readData() {
try {
if (!this.actions || !this.resourceKey) {
return;
}
this.message = 'plugin_sql_editor_navigation_tab_script_state_reading';
const script = await this.actions.read(this, this.resourceKey);
const executionContext = await this.actions.getProperties(this, this.resourceKey);
runInAction(() => {
if (!this.loaded) {
if (this.baseScript !== script) {
this.setScript(script);
}
if (!isObjectsEqual(toJS(this.baseExecutionContext), executionContext)) {
this.setExecutionContext(executionContext);
}
this.setBaseScript(script);
this.setBaseExecutionContext(executionContext);
} else {
this.setIncomingExecutionContext(executionContext);
this.setIncomingScript(script);
}
this.markUpdated();
this.loaded = true;
});
} finally {
this.message = undefined;
}
}
private debouncedWrite() {
this.write();
}
@@ -394,4 +435,12 @@ export class ResourceSqlDataSource extends BaseSqlDataSource {
private debouncedSaveProperties() {
this.saveProperties();
}
protected setBaseScript(script: string): void {
this.state.baseScript = script;
}
protected setBaseExecutionContext(executionContext: IConnectionExecutionContextInfo | undefined): void {
this.state.baseExecutionContext = toJS(executionContext);
}
}
@@ -16,12 +16,12 @@ import { getRmResourceKey, IResourceManagerMoveData, ResourceManagerResource } f
import { NetworkStateService, WindowEventsService } from '@cloudbeaver/core-root';
import { CachedMapAllKey, resourceKeyList, ResourceKeySimple, ResourceKeyUtils } from '@cloudbeaver/core-sdk';
import { LocalStorageSaveService } from '@cloudbeaver/core-settings';
import { throttle } from '@cloudbeaver/core-utils';
import { createPath, getPathParent, throttle } from '@cloudbeaver/core-utils';
import { NavigationTabsService } from '@cloudbeaver/plugin-navigation-tabs';
import { NavResourceNodeService } from '@cloudbeaver/plugin-navigation-tree-rm';
import { ResourceManagerService } from '@cloudbeaver/plugin-resource-manager';
import { ResourceManagerScriptsService } from '@cloudbeaver/plugin-resource-manager-scripts';
import { getSqlEditorName, SqlDataSourceService, SqlEditorService } from '@cloudbeaver/plugin-sql-editor';
import { ResourceManagerScriptsService, SCRIPTS_TYPE_ID } from '@cloudbeaver/plugin-resource-manager-scripts';
import { createSqlDataSourceHistoryInitialState, getSqlEditorName, SqlDataSourceService, SqlEditorService } from '@cloudbeaver/plugin-sql-editor';
import { SqlEditorTabService } from '@cloudbeaver/plugin-sql-editor-navigation-tab';
import type { IResourceSqlDataSourceState } from './IResourceSqlDataSourceState';
@@ -70,17 +70,18 @@ export class ResourceSqlDataSourceBootstrap extends Bootstrap {
for (const [key, value] of Array.from(map.entries())) {
if (
!['string', 'undefined'].includes(typeof value.resourceKey) ||
!['undefined', 'object'].includes(typeof value.executionContext) ||
!['string', 'undefined'].includes(typeof value.executionContext?.connectionId) ||
!['string', 'undefined'].includes(typeof value.executionContext?.id) ||
!['string', 'undefined', 'object'].includes(typeof value.executionContext?.defaultCatalog) ||
!['string', 'undefined', 'object'].includes(typeof value.executionContext?.defaultSchema)
!['string'].includes(typeof value.script) ||
!['string', 'undefined'].includes(typeof value.baseScript) ||
isExecutionContextInfoValid(value.executionContext) ||
isExecutionContextInfoValid(value.baseExecutionContext)
) {
map.delete(key);
}
}
return map;
},
this.bindState.bind(this),
'indexed',
);
}
@@ -97,20 +98,16 @@ export class ResourceSqlDataSourceBootstrap extends Bootstrap {
this.connectionInfoResource,
this.resourceManagerResource,
this.sqlEditorService,
this.createState(editorId),
this.createState(editorId, options?.script),
);
if (options?.executionContext) {
dataSource.setExecutionContext(options.executionContext);
}
if (options?.script) {
dataSource.setScript(options.script);
}
if (options?.name) {
dataSource.setName(options.name);
}
// if (options?.name) {
// dataSource.setName(options.name);
// }
dataSource.setActions({
rename: this.rename.bind(this),
@@ -138,15 +135,11 @@ export class ResourceSqlDataSourceBootstrap extends Bootstrap {
},
onDestroy: (_, editorId) => this.deleteState(editorId),
onUnload: async dataSource => {
if (dataSource instanceof ResourceSqlDataSource) {
await dataSource.write();
}
await dataSource.save();
},
canDestroy: async (dataSource, editorId) => {
try {
if (dataSource instanceof ResourceSqlDataSource) {
await dataSource.write();
}
await dataSource.save();
} catch {
const tab = this.sqlEditorTabService.sqlEditorTabs.find(tab => tab.handlerState.editorId === editorId);
let name: string | undefined = undefined;
@@ -173,12 +166,15 @@ export class ResourceSqlDataSourceBootstrap extends Bootstrap {
load(): void | Promise<void> {}
private createState(editorId: string, resourceKey?: string): IResourceSqlDataSourceState {
private createState(editorId: string, script?: string, resourceKey?: string): IResourceSqlDataSourceState {
let state = this.dataSourceStateState.get(editorId);
if (!state) {
state = observable<IResourceSqlDataSourceState>({
script: script ?? '',
baseScript: script ?? '',
resourceKey,
history: createSqlDataSourceHistoryInitialState(script),
});
this.dataSourceStateState.set(editorId, state);
@@ -250,12 +246,18 @@ export class ResourceSqlDataSourceBootstrap extends Bootstrap {
this.navigationTabsService.closeTabSilent(resourceKeyList(tabs), true);
}
private async rename(dataSource: ResourceSqlDataSource, resourceKey: string, newResourceKey: string): Promise<string> {
private async rename(dataSource: ResourceSqlDataSource, resourceKey: string, name: string): Promise<string> {
if (!dataSource.projectId) {
throw new Error('Project ID is not defined');
}
if (!this.resourceManagerService.enabled) {
throw new Error('Resource Manager disabled');
}
try {
name = this.projectInfoResource.getNameWithExtension(dataSource.projectId, SCRIPTS_TYPE_ID, name);
const newResourceKey = createPath(getPathParent(resourceKey), name);
await this.navResourceNodeService.move(resourceKey, newResourceKey);
return newResourceKey;
} catch (exception) {
@@ -308,4 +310,22 @@ export class ResourceSqlDataSourceBootstrap extends Bootstrap {
throw exception;
}
}
private bindState() {
for (const [editorId, datasource] of this.sqlDataSourceService.dataSources) {
if (datasource instanceof ResourceSqlDataSource) {
datasource.bindState(this.createState(editorId));
}
}
}
}
function isExecutionContextInfoValid(executionContext: IConnectionExecutionContextInfo | undefined) {
return (
!['undefined', 'object'].includes(typeof executionContext) ||
!['string', 'undefined'].includes(typeof executionContext?.connectionId) ||
!['string', 'undefined'].includes(typeof executionContext?.id) ||
!['string', 'undefined', 'object'].includes(typeof executionContext?.defaultCatalog) ||
!['string', 'undefined', 'object'].includes(typeof executionContext?.defaultSchema)
);
}
@@ -0,0 +1,10 @@
.unsavedMark {
background-color: var(--theme-primary);
width: 8px;
height: 8px;
border-radius: 100%;
margin-right: 4px;
margin-left: -4px;
display: inline-block;
vertical-align: middle;
}
@@ -9,7 +9,7 @@ import { observer } from 'mobx-react-lite';
import { useContext } from 'react';
import styled from 'reshadow';
import { useStyles } from '@cloudbeaver/core-blocks';
import { s, useStyles } from '@cloudbeaver/core-blocks';
import { Connection, ConnectionInfoResource } from '@cloudbeaver/core-connections';
import { useService } from '@cloudbeaver/core-di';
import { ITabData, Tab, TabIcon, TabTitle } from '@cloudbeaver/core-ui';
@@ -18,6 +18,7 @@ import type { TabHandlerTabComponent } from '@cloudbeaver/plugin-navigation-tabs
import { DATA_CONTEXT_SQL_EDITOR_STATE, getSqlEditorName, ISqlEditorTabState, SqlDataSourceService } from '@cloudbeaver/plugin-sql-editor';
import { DATA_CONTEXT_SQL_EDITOR_TAB } from './DATA_CONTEXT_SQL_EDITOR_TAB';
import sqlEditorTabStyles from './SqlEditorTab.m.css';
export const SqlEditorTab: TabHandlerTabComponent<ISqlEditorTabState> = observer(function SqlEditorTab({ tab, onSelect, onClose, style }) {
const viewContext = useContext(CaptureViewContext);
@@ -41,6 +42,7 @@ export const SqlEditorTab: TabHandlerTabComponent<ISqlEditorTabState> = observer
const name = getSqlEditorName(tab.handlerState, dataSource, connection);
const icon = dataSource?.icon ?? '/icons/sql_script_m.svg';
const saved = dataSource?.isSaved !== false;
const handleSelect = ({ tabId }: ITabData<any>) => onSelect(tabId);
const handleClose = onClose ? ({ tabId }: ITabData<any>) => onClose(tabId) : undefined;
@@ -49,6 +51,7 @@ export const SqlEditorTab: TabHandlerTabComponent<ISqlEditorTabState> = observer
<Tab tabId={tab.id} style={style} title={name} menuContext={tabMenuContext} onOpen={handleSelect} onClose={handleClose}>
<TabIcon icon={icon} />
<TabTitle>{name}</TabTitle>
{!saved && <unsaved-mark className={s(sqlEditorTabStyles, { unsavedMark: true })} />}
</Tab>,
);
});
@@ -457,7 +457,7 @@ export class SqlEditorTabService extends Bootstrap {
const dataSource = this.sqlDataSourceService.get(editorTab.handlerState.editorId);
if (dataSource?.isSaved() === false) {
if (dataSource?.isSaved === false) {
const result = await this.commonDialogService.open(ConfirmationDialog, {
title: 'plugin_sql_editor_navigation_tab_data_source_save_confirmation_title',
subTitle: dataSource.name ?? undefined,
@@ -0,0 +1,35 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 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 { Bootstrap, injectable } from '@cloudbeaver/core-di';
import { LocalizationService } from '@cloudbeaver/core-localization';
@injectable()
export class LocaleService extends Bootstrap {
constructor(private readonly localizationService: LocalizationService) {
super();
}
register(): void | Promise<void> {
this.localizationService.addProvider(this.provider.bind(this));
}
load(): void | Promise<void> {}
private async provider(locale: string) {
switch (locale) {
case 'ru':
return (await import('./locales/ru')).default;
case 'it':
return (await import('./locales/it')).default;
case 'zh':
return (await import('./locales/zh')).default;
default:
return (await import('./locales/en')).default;
}
}
}
@@ -0,0 +1,10 @@
.reactCodemirrorPanel {
composes: theme-typography--caption from global;
display: flex;
padding: 2px;
}
.box {
display: flex;
flex: 1;
overflow: auto;
}
@@ -7,14 +7,13 @@
*/
import { observer } from 'mobx-react-lite';
import { useState } from 'react';
import styled, { css } from 'reshadow';
import { useCombinedRef, useExecutor, useStyles } from '@cloudbeaver/core-blocks';
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 } from '@cloudbeaver/core-ui';
import { closeCompletion, IEditorRef } from '@cloudbeaver/plugin-codemirror6';
import { closeCompletion, IEditorRef, ReactCodemirrorPanel, useCodemirrorExtensions } from '@cloudbeaver/plugin-codemirror6';
import type { ISqlEditorModeProps } from '@cloudbeaver/plugin-sql-editor';
import { ACTIVE_QUERY_EXTENSION } from '../ACTIVE_QUERY_EXTENSION';
@@ -23,27 +22,26 @@ import { SQLCodeEditorLoader } from '../SQLCodeEditor/SQLCodeEditorLoader';
import { useSQLCodeEditor } from '../SQLCodeEditor/useSQLCodeEditor';
import { useSqlDialectAutocompletion } from '../useSqlDialectAutocompletion';
import { useSqlDialectExtension } from '../useSqlDialectExtension';
import style from './SQLCodeEditorPanel.m.css';
import { useSQLCodeEditorPanel } from './useSQLCodeEditorPanel';
const styles = css`
box {
display: flex;
flex: 1;
overflow: auto;
}
`;
export const SQLCodeEditorPanel: TabContainerPanelComponent<ISqlEditorModeProps> = observer(function SQLCodeEditorPanel({ data }) {
const notificationService = useService(NotificationService);
const navNodeManagerService = useService(NavNodeManagerService);
const translate = useTranslate();
const styles = useS(style);
const [editorRef, setEditorRef] = useState<IEditorRef | null>(null);
const editor = useSQLCodeEditor(editorRef);
const panel = useSQLCodeEditorPanel(data, editor);
const extensions = useCodemirrorExtensions(undefined, [ACTIVE_QUERY_EXTENSION, QUERY_STATUS_GUTTER_EXTENSION]);
const autocompletion = useSqlDialectAutocompletion(data);
const sqlDialect = useSqlDialectExtension(data.dialect);
extensions.set(...autocompletion);
extensions.set(...sqlDialect);
const dndBox = useDNDBox({
canDrop: context => context.has(DATA_CONTEXT_NAV_NODE),
onDrop: async (context, mouse) => {
@@ -92,18 +90,42 @@ export const SQLCodeEditorPanel: TabContainerPanelComponent<ISqlEditorModeProps>
],
});
return styled(useStyles(styles))(
<box ref={dndBox.setRef}>
function applyIncoming() {
data.dataSource?.applyIncoming();
}
function keepCurrent() {
data.dataSource?.keepCurrent();
}
return (
<div ref={dndBox.setRef} className={styles.box}>
<SQLCodeEditorLoader
ref={setEditorRef}
getValue={() => data.value}
extensions={[ACTIVE_QUERY_EXTENSION, QUERY_STATUS_GUTTER_EXTENSION, autocompletion, sqlDialect]}
incomingValue={data.incomingValue}
extensions={extensions}
readonly={data.readonly}
autoFocus
lineNumbers
onChange={panel.onQueryChange}
onUpdate={panel.onUpdate}
/>
</box>,
>
{data.isIncomingChanges && (
<>
<ReactCodemirrorPanel className={styles.reactCodemirrorPanel} top>
<MenuBarSmallItem title={translate('plugin_sql_editor_new_merge_conflict_keep_current_tooltip')} onClick={keepCurrent}>
{translate('plugin_sql_editor_new_merge_conflict_keep_current_label')}
</MenuBarSmallItem>
</ReactCodemirrorPanel>
<ReactCodemirrorPanel className={styles.reactCodemirrorPanel} top incomingView>
<MenuBarSmallItem title={translate('plugin_sql_editor_new_merge_conflict_accept_incoming_tooltip')} onClick={applyIncoming}>
{translate('plugin_sql_editor_new_merge_conflict_accept_incoming_label')}
</MenuBarSmallItem>
</ReactCodemirrorPanel>
</>
)}
</SQLCodeEditorLoader>
</div>
);
});
@@ -11,7 +11,7 @@ import { createComplexLoader, useComplexLoader, useObjectRef } from '@cloudbeave
import { useService } from '@cloudbeaver/core-di';
import { LocalizationService } from '@cloudbeaver/core-localization';
import { GlobalConstants } from '@cloudbeaver/core-utils';
import type { Completion, CompletionConfig, CompletionContext, CompletionResult, Extension } from '@cloudbeaver/plugin-codemirror6';
import type { Compartment, Completion, CompletionConfig, CompletionContext, CompletionResult, Extension } from '@cloudbeaver/plugin-codemirror6';
import type { ISQLEditorData, SQLProposal } from '@cloudbeaver/plugin-sql-editor';
const codemirrorComplexLoader = createComplexLoader(() => import('@cloudbeaver/plugin-codemirror6'));
@@ -23,7 +23,7 @@ type SqlCompletion = Completion & {
const CLOSE_CHARACTERS = /[\s()[\]{};:>,=\\*]/;
const COMPLETION_WORD = /[\w*]*/;
export function useSqlDialectAutocompletion(data: ISQLEditorData): Extension[] {
export function useSqlDialectAutocompletion(data: ISQLEditorData): [Compartment, Extension] {
const { closeCompletion, useEditorAutocompletion } = useComplexLoader(codemirrorComplexLoader);
const localizationService = useService(LocalizationService);
const optionsRef = useObjectRef({ data });
@@ -11,6 +11,7 @@ import { createComplexLoader, type IComplexLoaderData, useComplexLoader } from '
import type { SqlDialectInfo } from '@cloudbeaver/core-sdk';
import {
CassandraLoader,
Compartment,
type Extension,
MariaSQLLoader,
MSSQLLoader,
@@ -24,7 +25,9 @@ import {
const codemirrorComplexLoader = createComplexLoader(() => import('@cloudbeaver/plugin-codemirror6'));
export function useSqlDialectExtension(dialectInfo: SqlDialectInfo | undefined): Extension {
const SQL_EDITOR_COMPARTMENT = new Compartment();
export function useSqlDialectExtension(dialectInfo: SqlDialectInfo | undefined): [Compartment, Extension] {
const { SQLDialect, SQL_EDITOR } = useComplexLoader(codemirrorComplexLoader);
const loader = getDialectLoader(dialectInfo?.name);
const dialect = useComplexLoader(loader);
@@ -45,9 +48,12 @@ export function useSqlDialectExtension(dialectInfo: SqlDialectInfo | undefined):
});
}
return SQL_EDITOR({
dialect: dialectInner,
});
return [
SQL_EDITOR_COMPARTMENT,
SQL_EDITOR({
dialect: dialectInner,
}),
];
}, [dialect, dialectInfo]);
}
@@ -0,0 +1,6 @@
export default [
['plugin_sql_editor_new_merge_conflict_keep_current_label', 'Keep Current'],
['plugin_sql_editor_new_merge_conflict_keep_current_tooltip', 'Keep Current changes'],
['plugin_sql_editor_new_merge_conflict_accept_incoming_label', 'Accept Incoming'],
['plugin_sql_editor_new_merge_conflict_accept_incoming_tooltip', 'Accept Incoming changes'],
];
@@ -0,0 +1,6 @@
export default [
['plugin_sql_editor_new_merge_conflict_keep_current_label', 'Keep Current'],
['plugin_sql_editor_new_merge_conflict_keep_current_tooltip', 'Keep Current changes'],
['plugin_sql_editor_new_merge_conflict_accept_incoming_label', 'Accept Incoming'],
['plugin_sql_editor_new_merge_conflict_accept_incoming_tooltip', 'Accept Incoming changes'],
];
@@ -0,0 +1,6 @@
export default [
['plugin_sql_editor_new_merge_conflict_keep_current_label', 'Оставить текущие'],
['plugin_sql_editor_new_merge_conflict_keep_current_tooltip', 'Оставить текущие изменения'],
['plugin_sql_editor_new_merge_conflict_accept_incoming_label', 'Принять входящие'],
['plugin_sql_editor_new_merge_conflict_accept_incoming_tooltip', 'Принять входящие изменения'],
];
@@ -0,0 +1,6 @@
export default [
['plugin_sql_editor_new_merge_conflict_keep_current_label', 'Keep Current'],
['plugin_sql_editor_new_merge_conflict_keep_current_tooltip', 'Keep Current changes'],
['plugin_sql_editor_new_merge_conflict_accept_incoming_label', 'Accept Incoming'],
['plugin_sql_editor_new_merge_conflict_accept_incoming_tooltip', 'Accept Incoming changes'],
];
@@ -7,6 +7,7 @@
*/
import type { PluginManifest } from '@cloudbeaver/core-di';
import { LocaleService } from './LocaleService';
import { PluginBootstrap } from './PluginBootstrap';
import { SQLCodeEditorPanelService } from './SQLEditor/SQLCodeEditorPanel/SQLCodeEditorPanelService';
@@ -15,5 +16,5 @@ export const sqlEditorNewPlugin: PluginManifest = {
name: 'Sql Editor New Plugin',
},
providers: [PluginBootstrap, SQLCodeEditorPanelService],
providers: [PluginBootstrap, SQLCodeEditorPanelService, LocaleService],
};
@@ -5,11 +5,11 @@
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { makeObservable, observable } from 'mobx';
import { action, computed, makeObservable, observable, toJS } from 'mobx';
import type { IConnectionExecutionContextInfo } from '@cloudbeaver/core-connections';
import { ISyncExecutor, SyncExecutor } from '@cloudbeaver/core-executor';
import { isContainsException, staticImplements } from '@cloudbeaver/core-utils';
import { isContainsException, isObjectsEqual, isValuesEqual, staticImplements } from '@cloudbeaver/core-utils';
import type { IDatabaseDataModel, IDatabaseResultSet } from '@cloudbeaver/plugin-data-viewer';
import type { IDataQueryOptions } from '../QueryDataSource';
@@ -25,10 +25,38 @@ export abstract class BaseSqlDataSource implements ISqlDataSource {
static key = 'base';
abstract get name(): string | null;
abstract get script(): string;
abstract get baseScript(): string;
abstract get baseExecutionContext(): IConnectionExecutionContextInfo | undefined;
abstract get executionContext(): IConnectionExecutionContextInfo | undefined;
databaseModels: IDatabaseDataModel<IDataQueryOptions, IDatabaseResultSet>[];
exception?: Error | Error[] | null | undefined;
message?: string;
incomingScript: string | undefined;
incomingExecutionContext: IConnectionExecutionContextInfo | undefined | null;
get isIncomingChanges(): boolean {
return this.incomingScript !== undefined || this.incomingExecutionContext !== null;
}
get isAutoSaveEnabled(): boolean {
return true;
}
get isScriptSaved(): boolean {
return this.script === this.baseScript;
}
get isExecutionContextSaved(): boolean {
return (
isValuesEqual(this.baseExecutionContext?.connectionId, this.executionContext?.connectionId, undefined) &&
isValuesEqual(this.baseExecutionContext?.defaultCatalog, this.executionContext?.defaultCatalog, undefined) &&
isValuesEqual(this.baseExecutionContext?.defaultSchema, this.executionContext?.defaultSchema, undefined)
);
}
get isSaved(): boolean {
return this.isScriptSaved && this.isExecutionContextSaved;
}
get sourceKey(): string {
return Object.getPrototypeOf(this).constructor.key;
@@ -48,18 +76,18 @@ export abstract class BaseSqlDataSource implements ISqlDataSource {
readonly onSetScript: ISyncExecutor<ISetScriptData>;
readonly onDatabaseModelUpdate: ISyncExecutor<IDatabaseDataModel<IDataQueryOptions, IDatabaseResultSet>[]>;
protected saved: boolean;
protected outdated: boolean;
protected editing: boolean;
constructor(icon = '/icons/sql_script_m.svg') {
this.icon = icon;
this.databaseModels = [];
this.incomingScript = undefined;
this.incomingExecutionContext = null;
this.exception = undefined;
this.message = undefined;
this.outdated = true;
this.editing = true;
this.saved = true;
this.history = new SqlDataSourceHistory();
this.onUpdate = new SyncExecutor();
this.onSetScript = new SyncExecutor();
@@ -76,21 +104,51 @@ export abstract class BaseSqlDataSource implements ISqlDataSource {
this.history.onNavigate.addHandler(value => this.setScript(value, SOURCE_HISTORY));
makeObservable<this, 'outdated' | 'editing' | 'saved'>(this, {
makeObservable<this, 'outdated' | 'editing'>(this, {
isSaved: computed,
isIncomingChanges: computed,
isAutoSaveEnabled: computed,
isScriptSaved: computed,
isExecutionContextSaved: computed,
setScript: action,
setIncomingScript: action,
setName: action,
setExecutionContext: action,
setIncomingExecutionContext: action,
markUpdated: action,
markOutdated: action,
setEditing: action,
setProject: action,
applyIncoming: action,
keepCurrent: action,
reset: action,
databaseModels: observable.ref,
exception: observable.ref,
outdated: observable.ref,
message: observable.ref,
editing: observable.ref,
saved: observable.ref,
incomingScript: observable.ref,
incomingExecutionContext: observable.ref,
});
}
setScript(script: string, source?: string): void {
this.saved = false;
this.onSetScript.execute({ script, source });
}
setIncomingScript(script: string): void {
if (script !== this.baseScript) {
if (this.script === this.baseScript) {
this.setBaseScript(script);
this.setScript(script);
} else {
this.incomingScript = script;
}
} else {
this.incomingScript = undefined;
}
}
abstract canRename(name: string | null): boolean;
setName(name: string | null): void {
@@ -98,12 +156,26 @@ export abstract class BaseSqlDataSource implements ISqlDataSource {
}
setExecutionContext(executionContext?: IConnectionExecutionContextInfo | undefined): void {
this.saved = false;
this.onUpdate.execute();
}
isSaved(): boolean {
return this.saved;
setIncomingExecutionContext(executionContext?: IConnectionExecutionContextInfo | undefined): void {
executionContext = toJS(executionContext);
this.setBaseExecutionContext(executionContext);
this.setExecutionContext(executionContext);
// TODO: we need to display execution context changes
// if (!isObjectsEqual(executionContext, toJS(this.baseExecutionContext))) {
// if (isObjectsEqual(this.executionContext, toJS(this.baseExecutionContext))) {
// this.setBaseExecutionContext(executionContext);
// this.setExecutionContext(executionContext);
// } else {
// this.incomingExecutionContext = executionContext;
// }
// } else {
// this.incomingExecutionContext = null;
// }
}
isError(): boolean {
@@ -154,10 +226,42 @@ export abstract class BaseSqlDataSource implements ISqlDataSource {
this.onUpdate.execute();
}
save(): Promise<void> | void {}
load(): Promise<void> | void {}
reset(): Promise<void> | void {
this.saved = true;
keepCurrent(): void {
if (this.incomingScript !== undefined) {
this.setBaseScript(this.incomingScript);
this.incomingScript = undefined;
}
if (this.incomingExecutionContext !== null) {
this.setBaseExecutionContext(this.incomingExecutionContext);
this.incomingExecutionContext = null;
}
}
applyIncoming(): void {
if (this.incomingScript !== undefined) {
this.setBaseScript(this.incomingScript);
}
if (this.incomingExecutionContext !== null) {
this.setBaseExecutionContext(this.incomingExecutionContext);
}
this.reset();
}
save(): Promise<void> | void {
this.markUpdated();
}
load(): Promise<void> | void {}
reset(): Promise<void> | void {
this.setScript(this.baseScript);
this.setExecutionContext(this.baseExecutionContext);
this.incomingScript = undefined;
this.incomingExecutionContext = null;
}
dispose(): void | Promise<void> {}
protected abstract setBaseScript(script: string): void;
protected abstract setBaseExecutionContext(executionContext: IConnectionExecutionContextInfo | undefined): void;
}
@@ -29,6 +29,7 @@ export interface ISqlDataSource extends ILoadableState {
readonly icon?: string;
readonly emptyPlaceholder?: string;
readonly script: string;
readonly incomingScript?: string;
readonly projectId: string | null;
readonly databaseModels: IDatabaseDataModel<IDataQueryOptions, IDatabaseResultSet>[];
readonly executionContext?: IConnectionExecutionContextInfo;
@@ -38,8 +39,12 @@ export interface ISqlDataSource extends ILoadableState {
readonly onDatabaseModelUpdate: ISyncExecutor<IDatabaseDataModel<IDataQueryOptions, IDatabaseResultSet>[]>;
readonly features: ESqlDataSourceFeatures[];
readonly history: ISqlDataSourceHistory;
readonly isAutoSaveEnabled: boolean;
readonly isIncomingChanges: boolean;
readonly isSaved: boolean;
readonly isScriptSaved: boolean;
readonly isExecutionContextSaved: boolean;
isSaved(): boolean;
isReadonly(): boolean;
isEditing(): boolean;
isOutdated(): boolean;
@@ -52,6 +57,10 @@ export interface ISqlDataSource extends ILoadableState {
setScript(script: string, source?: string): void;
setEditing(state: boolean): void;
setExecutionContext(executionContext?: IConnectionExecutionContextInfo): void;
setIncomingExecutionContext(executionContext?: IConnectionExecutionContextInfo): void;
setIncomingScript(script?: string): void;
applyIncoming(): void;
keepCurrent(): void;
save(): Promise<void> | void;
load(): Promise<void> | void;
reset(): Promise<void> | void;
@@ -14,6 +14,14 @@ import { ESqlDataSourceFeatures } from '../ESqlDataSourceFeatures';
import type { ILocalStorageSqlDataSourceState } from './ILocalStorageSqlDataSourceState';
export class LocalStorageSqlDataSource extends BaseSqlDataSource {
get baseScript(): string {
return this.state.script;
}
get baseExecutionContext(): IConnectionExecutionContextInfo | undefined {
return this.state.executionContext;
}
static key = 'local-storage';
get name(): string | null {
@@ -32,6 +40,10 @@ export class LocalStorageSqlDataSource extends BaseSqlDataSource {
return [ESqlDataSourceFeatures.script, ESqlDataSourceFeatures.query, ESqlDataSourceFeatures.executable, ESqlDataSourceFeatures.setName];
}
get isSaved(): boolean {
return true;
}
private state!: ILocalStorageSqlDataSourceState;
constructor(state: ILocalStorageSqlDataSourceState) {
@@ -45,10 +57,6 @@ export class LocalStorageSqlDataSource extends BaseSqlDataSource {
});
}
isSaved(): boolean {
return true;
}
isReadonly(): boolean {
return false;
}
@@ -77,4 +85,12 @@ export class LocalStorageSqlDataSource extends BaseSqlDataSource {
this.outdated = false;
this.history.restore(state.history);
}
protected setBaseScript(script: string): void {
this.state.script = script;
}
protected setBaseExecutionContext(executionContext: IConnectionExecutionContextInfo | undefined): void {
this.state.executionContext = executionContext;
}
}
@@ -13,6 +13,12 @@ import { BaseSqlDataSource } from './BaseSqlDataSource';
import { ESqlDataSourceFeatures } from './ESqlDataSourceFeatures';
export class MemorySqlDataSource extends BaseSqlDataSource {
get baseScript(): string {
return this._script;
}
get baseExecutionContext(): IConnectionExecutionContextInfo | undefined {
return this._executionContext;
}
static key = 'memory';
get name(): string | null {
@@ -31,6 +37,10 @@ export class MemorySqlDataSource extends BaseSqlDataSource {
return [ESqlDataSourceFeatures.script, ESqlDataSourceFeatures.query, ESqlDataSourceFeatures.executable, ESqlDataSourceFeatures.setName];
}
get isSaved(): boolean {
return true;
}
private _name: string | null;
private _script: string;
private _executionContext?: IConnectionExecutionContextInfo;
@@ -50,10 +60,6 @@ export class MemorySqlDataSource extends BaseSqlDataSource {
});
}
isSaved(): boolean {
return true;
}
isReadonly(): boolean {
return false;
}
@@ -76,4 +82,12 @@ export class MemorySqlDataSource extends BaseSqlDataSource {
canRename(name: string | null): boolean {
return true;
}
protected setBaseScript(script: string): void {
this._script = script;
}
protected setBaseExecutionContext(executionContext: IConnectionExecutionContextInfo | undefined): void {
this._executionContext = executionContext;
}
}
@@ -35,7 +35,9 @@ export interface ISQLEditorData {
readonly isLineScriptEmpty: boolean;
readonly isScriptEmpty: boolean;
readonly isDisabled: boolean;
readonly isIncomingChanges: boolean;
readonly value: string;
readonly incomingValue?: string;
readonly dataSource: ISqlDataSource | undefined;
readonly onExecute: ISyncExecutor<boolean>;
readonly onSegmentExecute: ISyncExecutor<ISegmentExecutionData>;
@@ -1,3 +1,7 @@
.sqlActions .menuBarItem {
padding: 4px;
}
.sqlActions .menuBarItemLabel {
display: none;
}
@@ -14,7 +14,7 @@ import { useService } from '@cloudbeaver/core-di';
import { CommonDialogService, ConfirmationDialog, DialogueStateResult } from '@cloudbeaver/core-dialogs';
import { SyncExecutor } from '@cloudbeaver/core-executor';
import type { SqlCompletionProposal, SqlDialectInfo, SqlScriptInfoFragment } from '@cloudbeaver/core-sdk';
import { createLastPromiseGetter, isObjectsEqual, LastPromiseGetter, throttleAsync } from '@cloudbeaver/core-utils';
import { createLastPromiseGetter, LastPromiseGetter, throttleAsync } from '@cloudbeaver/core-utils';
import type { ISqlEditorTabState } from '../ISqlEditorTabState';
import { ESqlDataSourceFeatures } from '../SqlDataSource/ESqlDataSourceFeatures';
@@ -122,10 +122,18 @@ export function useSqlEditor(state: ISqlEditorTabState): ISQLEditorData {
return context?.executing || false;
},
get isIncomingChanges(): boolean {
return this.dataSource?.isIncomingChanges ?? false;
},
get value(): string {
return this.dataSource?.script ?? '';
},
get incomingValue(): string | undefined {
return this.dataSource?.incomingScript;
},
onMode: new SyncExecutor(),
onExecute: new SyncExecutor(),
onSegmentExecute: new SyncExecutor(),
@@ -31,5 +31,5 @@ interface Props {
}
export const SqlEditorStatusBar = observer<Props>(function SqlEditorStatusBar({ dataSource }) {
return styled(viewerStyles)(<Loader state={dataSource} message={dataSource?.message} small inline inlineException />);
return styled(viewerStyles)(<Loader state={dataSource} message={dataSource?.message} inline inlineException />);
});
@@ -13,6 +13,8 @@ export * from './actions/ACTION_SQL_EDITOR_SHOW_EXECUTION_PLAN';
export * from './SqlDataSource/LocalStorage/ILocalStorageSqlDataSourceState';
export * from './SqlDataSource/LocalStorage/LocalStorageSqlDataSource';
export * from './SqlDataSource/LocalStorage/LocalStorageSqlDataSourceBootstrap';
export * from './SqlDataSource/SqlDataSourceHistory/ISqlDataSourceHistoryState';
export * from './SqlDataSource/SqlDataSourceHistory/createSqlDataSourceHistoryInitialState';
export * from './SqlDataSource/BaseSqlDataSource';
export * from './SqlDataSource/ESqlDataSourceFeatures';
export * from './SqlDataSource/ISqlDataSource';
@@ -12,6 +12,7 @@ import { Button, useClipboard, useResource, useTranslate } from '@cloudbeaver/co
import { ConnectionDialectResource, ConnectionExecutionContextService, createConnectionParam } from '@cloudbeaver/core-connections';
import { useService } from '@cloudbeaver/core-di';
import { CommonDialogBody, CommonDialogFooter, CommonDialogHeader, CommonDialogWrapper, DialogComponentProps } from '@cloudbeaver/core-dialogs';
import { useCodemirrorExtensions } from '@cloudbeaver/plugin-codemirror6';
import type { IDatabaseDataModel } from '@cloudbeaver/plugin-data-viewer';
import { SQLCodeEditorLoader, useSqlDialectExtension } from '@cloudbeaver/plugin-sql-editor-new';
@@ -53,6 +54,8 @@ export const ScriptPreviewDialog = observer<DialogComponentProps<Payload>>(funct
contextInfo ? createConnectionParam(contextInfo.projectId, contextInfo.connectionId) : null,
);
const sqlDialect = useSqlDialectExtension(dialect.data);
const extensions = useCodemirrorExtensions();
extensions.set(...sqlDialect);
const apply = async () => {
await payload.model.save();
@@ -64,7 +67,7 @@ export const ScriptPreviewDialog = observer<DialogComponentProps<Payload>>(funct
<CommonDialogHeader title="data_viewer_script_preview_dialog_title" icon="sql-script" onReject={rejectDialog} />
<CommonDialogBody noBodyPadding noOverflow>
<wrapper>
<SQLCodeEditorLoader value={payload.script} extensions={[sqlDialect]} lineNumbers readonly />
<SQLCodeEditorLoader value={payload.script} extensions={extensions} lineNumbers readonly />
</wrapper>
</CommonDialogBody>
<CommonDialogFooter>
@@ -15,6 +15,7 @@ import { ConnectionInfoResource, createConnectionParam } from '@cloudbeaver/core
import { useService } from '@cloudbeaver/core-di';
import { CommonDialogBody, CommonDialogFooter, CommonDialogHeader, CommonDialogWrapper, DialogComponentProps } from '@cloudbeaver/core-dialogs';
import { GQLErrorCatcher, SqlDialectInfo } from '@cloudbeaver/core-sdk';
import { useCodemirrorExtensions } from '@cloudbeaver/plugin-codemirror6';
import { SqlDialectInfoService } from '@cloudbeaver/plugin-sql-editor';
import { SQLCodeEditorLoader, useSqlDialectExtension } from '@cloudbeaver/plugin-sql-editor-new';
@@ -99,6 +100,8 @@ export const GeneratedSqlDialog = observer<DialogComponentProps<Payload>>(functi
);
const sqlDialect = useSqlDialectExtension(state.dialect);
const extensions = useCodemirrorExtensions();
extensions.set(...sqlDialect);
const error = useErrorDetails(state.error.exception);
useEffect(() => {
@@ -122,7 +125,7 @@ export const GeneratedSqlDialog = observer<DialogComponentProps<Payload>>(functi
<CommonDialogBody noOverflow noBodyPadding>
<wrapper>
<Loader loading={state.loading}>
{() => styled(styles)(<SQLCodeEditorLoader value={state.query} extensions={[sqlDialect]} readonly />)}
{() => styled(styles)(<SQLCodeEditorLoader value={state.query} extensions={extensions} readonly />)}
</Loader>
</wrapper>
</CommonDialogBody>
+2415 -2887
View File
File diff suppressed because it is too large Load Diff