Skip to content
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
Expand All @@ -10,6 +10,5 @@ export interface IKeyBinding {
id: string;
preventDefault?: boolean;
keys?: string | string[];
keysWin?: string | string[];
keysMac?: string | string[];
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
Expand All @@ -11,7 +11,6 @@ interface IKeyBindingOptions {
id: string;
preventDefault?: boolean;
keys?: string | string[];
keysWin?: string | string[];
keysMac?: string | string[];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ const FORMAT_SHORTCUT_KEYS_MAP: Record<string, string> = {
del: 'delete',
delete: 'delete',
};
const SOURCE_DIVIDER_REGEXP = /\+/gi;
const APPLIED_DIVIDER = ' + ';

export const SHORTCUT_DIVIDER = '+';
export const SOURCE_DIVIDER_REGEXP = /\+/gi;
export const APPLIED_DIVIDER = ` ${SHORTCUT_DIVIDER} `;

function transformKeys(keyBinding: IKeyBinding): string[] {
return getCommonAndOSSpecificKeys(keyBinding).map(shortcut =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2026 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 { describe, expect, it, vi, beforeEach } from 'vitest';
import { OperatingSystem, getOS } from '@cloudbeaver/core-utils';

import { getCommonAndOSSpecificKeys } from './getCommonAndOSSpecificKeys.js';
import type { IKeyBinding } from './IKeyBinding.js';

vi.mock('@cloudbeaver/core-utils', async () => {
const actual = await vi.importActual('@cloudbeaver/core-utils');
return {
...actual,
getOS: vi.fn(),
};
});

describe('getCommonAndOSSpecificKeys', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should return empty array when keyBinding is undefined', () => {
const result = getCommonAndOSSpecificKeys(undefined);
expect(result).toEqual([]);
});

describe('on macOS', () => {
beforeEach(() => {
vi.mocked(getOS).mockReturnValue(OperatingSystem.macOS);
});

it('should return only common keys when keysMac is not provided', () => {
const keyBinding: IKeyBinding = {
id: 'test',
keys: 'Ctrl+A',
};
const result = getCommonAndOSSpecificKeys(keyBinding);
expect(result).toEqual(['Ctrl+A']);
});

it('should return only macOS keys when keys is not provided', () => {
const keyBinding: IKeyBinding = {
id: 'test',
keysMac: 'Cmd+A',
};
const result = getCommonAndOSSpecificKeys(keyBinding);
expect(result).toEqual(['Cmd+A']);
});

it('should return both common and macOS keys when both are provided', () => {
const keyBinding: IKeyBinding = {
id: 'test',
keys: 'Ctrl+A',
keysMac: 'Cmd+A',
};
const result = getCommonAndOSSpecificKeys(keyBinding);
expect(result).toEqual(['Cmd+A', 'Ctrl+A']);
});

it('should handle array of keys', () => {
const keyBinding: IKeyBinding = {
id: 'test',
keys: ['Ctrl+A', 'Ctrl+B'],
keysMac: ['Cmd+A', 'Cmd+B'],
};
const result = getCommonAndOSSpecificKeys(keyBinding);
expect(result).toEqual(['Cmd+A', 'Cmd+B', 'Ctrl+A', 'Ctrl+B']);
});

it('should handle mixed string and array keys', () => {
const keyBinding: IKeyBinding = {
id: 'test',
keys: 'Ctrl+A',
keysMac: ['Cmd+A', 'Cmd+B'],
};
const result = getCommonAndOSSpecificKeys(keyBinding);
expect(result).toEqual(['Cmd+A', 'Cmd+B', 'Ctrl+A']);
});

it('should remove duplicates', () => {
const keyBinding: IKeyBinding = {
id: 'test',
keys: 'Ctrl+A',
keysMac: 'Ctrl+A',
};
const result = getCommonAndOSSpecificKeys(keyBinding);
expect(result).toEqual(['Ctrl+A']);
});

it('should handle empty keys', () => {
const keyBinding: IKeyBinding = {
id: 'test',
keys: '',
keysMac: '',
};
const result = getCommonAndOSSpecificKeys(keyBinding);
expect(result).toEqual([]);
});

it('should filter out empty strings', () => {
const keyBinding: IKeyBinding = {
id: 'test',
keys: ['Ctrl+A', ''],
keysMac: ['Cmd+A', ''],
};
const result = getCommonAndOSSpecificKeys(keyBinding);
expect(result).toEqual(['Cmd+A', 'Ctrl+A']);
});
});

describe('on Windows', () => {
beforeEach(() => {
vi.mocked(getOS).mockReturnValue(OperatingSystem.windowsOS);
});

it('should return only common keys, ignoring keysMac', () => {
const keyBinding: IKeyBinding = {
id: 'test',
keys: 'Ctrl+A',
keysMac: 'Cmd+A',
};
const result = getCommonAndOSSpecificKeys(keyBinding);
expect(result).toEqual(['Ctrl+A']);
});

it('should return empty array when only keysMac is provided', () => {
const keyBinding: IKeyBinding = {
id: 'test',
keysMac: 'Cmd+A',
};
const result = getCommonAndOSSpecificKeys(keyBinding);
expect(result).toEqual([]);
});

it('should handle array of keys', () => {
const keyBinding: IKeyBinding = {
id: 'test',
keys: ['Ctrl+A', 'Ctrl+B'],
keysMac: ['Cmd+A', 'Cmd+B'],
};
const result = getCommonAndOSSpecificKeys(keyBinding);
expect(result).toEqual(['Ctrl+A', 'Ctrl+B']);
});

it('should handle string keys', () => {
const keyBinding: IKeyBinding = {
id: 'test',
keys: 'Ctrl+A',
};
const result = getCommonAndOSSpecificKeys(keyBinding);
expect(result).toEqual(['Ctrl+A']);
});

it('should filter out empty strings', () => {
const keyBinding: IKeyBinding = {
id: 'test',
keys: ['Ctrl+A', ''],
};
const result = getCommonAndOSSpecificKeys(keyBinding);
expect(result).toEqual(['Ctrl+A']);
});
});

describe('on Linux', () => {
beforeEach(() => {
vi.mocked(getOS).mockReturnValue(OperatingSystem.linuxOS);
});

it('should return only common keys, ignoring keysMac', () => {
const keyBinding: IKeyBinding = {
id: 'test',
keys: 'Ctrl+A',
keysMac: 'Cmd+A',
};
const result = getCommonAndOSSpecificKeys(keyBinding);
expect(result).toEqual(['Ctrl+A']);
});

it('should return empty array when only keysMac is provided', () => {
const keyBinding: IKeyBinding = {
id: 'test',
keysMac: 'Cmd+A',
};
const result = getCommonAndOSSpecificKeys(keyBinding);
expect(result).toEqual([]);
});

it('should handle array of keys', () => {
const keyBinding: IKeyBinding = {
id: 'test',
keys: ['Ctrl+A', 'Ctrl+B'],
keysMac: ['Cmd+A', 'Cmd+B'],
};
const result = getCommonAndOSSpecificKeys(keyBinding);
expect(result).toEqual(['Ctrl+A', 'Ctrl+B']);
});

it('should handle string keys', () => {
const keyBinding: IKeyBinding = {
id: 'test',
keys: 'Ctrl+A',
};
const result = getCommonAndOSSpecificKeys(keyBinding);
expect(result).toEqual(['Ctrl+A']);
});

it('should filter out empty strings', () => {
const keyBinding: IKeyBinding = {
id: 'test',
keys: ['Ctrl+A', ''],
};
const result = getCommonAndOSSpecificKeys(keyBinding);
expect(result).toEqual(['Ctrl+A']);
});
});

describe('edge cases', () => {
it('should handle undefined keys', () => {
const keyBinding: IKeyBinding = {
id: 'test',
};
const result = getCommonAndOSSpecificKeys(keyBinding);
expect(result).toEqual([]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,19 @@ export function getCommonAndOSSpecificKeys(keyBinding: IKeyBinding | undefined):
return [];
}

return [...getOSSpecificKeys(keyBinding), ...getKeys(keyBinding.keys)];
return Array.from(new Set([...getOSSpecificKeys(keyBinding), ...getKeys(keyBinding.keys)]));
}

export function getOSSpecificKeys(keyBinding: IKeyBinding): string[] {
const OS = getOS();
const keys: string[] = [];

if (OS === OperatingSystem.windowsOS) {
keys.push(...getKeys(keyBinding.keysWin));
}

if (OS === OperatingSystem.macOS) {
keys.push(...getKeys(keyBinding.keysMac));
return getKeys(keyBinding.keysMac);
}

return keys;
return [];
}

function getKeys(keys: string[] | string | undefined): string[] {
return Array.isArray(keys) ? keys : [keys ?? ''].filter(Boolean);
return Array.isArray(keys) ? keys.filter(Boolean) : [keys ?? ''].filter(Boolean);
}
Original file line number Diff line number Diff line change
@@ -1,22 +1,32 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
* Copyright (C) 2020-2026 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 { autocompletion, startCompletion } from '@codemirror/autocomplete';
import { Compartment, type Extension } from '@codemirror/state';
import { keymap } from '@codemirror/view';
import { acceptCompletion, autocompletion, startCompletion } from '@codemirror/autocomplete';
import { Compartment, Prec, type Extension } from '@codemirror/state';
import { keymap, type KeyBinding } from '@codemirror/view';

export type CompletionConfig = Parameters<typeof autocompletion>[0];

// Shortcuts are case sensitive
export const CODEMIRROR_SHORTCUT_SPLITTER = '-';
export const EDITOR_START_COMPLETION_KEYBINDING: KeyBinding = {
key: ['Shift', 'Mod', 'Space'].join(CODEMIRROR_SHORTCUT_SPLITTER),
run: startCompletion,
preventDefault: true,
};
export const EDITOR_ACCEPT_COMPLETION_KEYBINDING: KeyBinding = {
key: ['Tab'].join(CODEMIRROR_SHORTCUT_SPLITTER),
run: acceptCompletion,
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 },
]);
const EDITOR_AUTOCOMPLETION_KEYMAP = Prec.high(keymap.of([EDITOR_START_COMPLETION_KEYBINDING, EDITOR_ACCEPT_COMPLETION_KEYBINDING]));

export function createEditorAutocompletion(config?: CompletionConfig): [Compartment, Extension] {
return [
Expand Down
14 changes: 12 additions & 2 deletions webapp/packages/plugin-help/src/Shortcuts/SHORTCUTS_DATA.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
KEY_BINDING_OPEN_IN_TAB,
KEY_BINDING_REDO,
KEY_BINDING_UNDO,
SOURCE_DIVIDER_REGEXP,
APPLIED_DIVIDER,
} from '@cloudbeaver/core-view';
import {
KEY_BINDING_ADD_NEW_ROW,
Expand All @@ -30,6 +32,8 @@ import {
KEY_BINDING_SQL_EDITOR_EXECUTE_SCRIPT,
KEY_BINDING_SQL_EDITOR_FORMAT,
KEY_BINDING_SQL_EDITOR_SHOW_EXECUTION_PLAN,
KEY_BINDING_SQL_EDITOR_START_COMPLETION,
KEY_BINDING_SQL_EDITOR_ACCEPT_COMPLETION,
} from '@cloudbeaver/plugin-sql-editor';
import { KEY_BINDING_SQL_EDITOR_SAVE_AS_SCRIPT } from '@cloudbeaver/plugin-sql-editor-navigation-tab-script';

Expand Down Expand Up @@ -65,8 +69,6 @@ const FORMAT_SHORTCUT_KEYS_MAP: Record<string, string> = {
pagedown: 'pagedown',
period: '.',
};
const SOURCE_DIVIDER_REGEXP = /\+/gi;
const APPLIED_DIVIDER = ' + ';

export const DATA_VIEWER_SHORTCUTS: IShortcut[] = [
{
Expand Down Expand Up @@ -140,6 +142,14 @@ export const SQL_EDITOR_SHORTCUTS: IShortcut[] = [
label: 'sql_editor_shortcut_open_editor_in_new_tab',
code: transformKeys(KEY_BINDING_OPEN_IN_TAB),
},
{
label: 'sql_editor_shortcut_start_completion',
code: transformKeys(KEY_BINDING_SQL_EDITOR_START_COMPLETION),
},
{
label: 'sql_editor_shortcut_accept_completion',
code: transformKeys(KEY_BINDING_SQL_EDITOR_ACCEPT_COMPLETION),
},
];

export const NAVIGATION_TREE_SHORTCUTS: IShortcut[] = [
Expand Down
2 changes: 2 additions & 0 deletions webapp/packages/plugin-help/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export default [
['sql_editor_shortcut_open_editor_in_new_tab', 'Open SQL Editor in the separate browser Tab'],
['sql_editor_shortcut_find', 'Find'],
['sql_editor_shortcut_comment_uncomment_selection', 'Comment/Uncomment selection'],
['sql_editor_shortcut_start_completion', 'Show autocomplete suggestions'],
['sql_editor_shortcut_accept_completion', 'Accept autocompletion'],

['navigation_tree_shortcut_enable_filter', 'Enable filtering'],

Expand Down
Loading
Loading