1
0
mirror of synced 2026-10-23 07:08:55 +03:00

Improve keyboard shortcuts for tab switching, line duplication in the SQL editor, and Enter/Escape in dialogs. #3834, #5691, #7167, #7232 (#10067)

Tab switching now works when focus is inside a tool and defaults to
Ctrl+Alt+PageUp/PageDown, Ctrl/Cmd+Shift+D duplicates the current line or
selection in the SQL editor, and dialogs handle Enter and Escape
consistently.
This commit is contained in:
Dave Page
2026-10-12 17:16:28 +01:00
parent fad01ed776
commit e5b6728fa9
8 changed files with 530 additions and 88 deletions

View File

@@ -15,47 +15,47 @@ When using main browser window, the following keyboard shortcuts are available:
:class: longtable
:widths: 2 2 3
+----------------------------+--------------------+------------------------------------+
| Shortcut (Windows/Linux) | Shortcut (Mac) | Function |
+============================+====================+====================================+
| Shift + Ctrl + a | Shift + Ctrl + a | Add grid row |
+----------------------------+--------------------+------------------------------------+
| Shift + Alt + b | Shift + Option + b | Object explorer |
+----------------------------+--------------------+------------------------------------+
| Ctrl + Alt + b | Ctrl + Option + b | Toggle Object Explorer |
+----------------------------+--------------------+------------------------------------+
| Shift + Alt + n | Shift + Option + n | Create object |
+----------------------------+--------------------+------------------------------------+
| Shift + Alt + d | Shift + Option + d | Delete object |
+----------------------------+--------------------+------------------------------------+
| Shift + Alt + g | Shift + Option + g | Direct debugging |
+----------------------------+--------------------+------------------------------------+
| Shift + Alt + e | Shift + Option + e | Edit object properties |
+----------------------------+--------------------+------------------------------------+
| Shift + Alt + f | Shift + Option + f | File main menu |
+----------------------------+--------------------+------------------------------------+
| Shift + Alt + h | Shift + Option + h | Help main menu |
+----------------------------+--------------------+------------------------------------+
| Shift + Alt + o | Shift + Option + o | Object main menu |
+----------------------------+--------------------+------------------------------------+
| Shift + Alt + q | Shift + Option + q | Open query tool |
+----------------------------+--------------------+------------------------------------+
| Shift + Ctrl + f | Shift + Ctrl + f | Quick Search |
+----------------------------+--------------------+------------------------------------+
| F5 | F5 | Refresh object explorer |
+----------------------------+--------------------+------------------------------------+
| Shift + Alt + s | Shift + Option + s | Search objects |
+----------------------------+--------------------+------------------------------------+
| Shift + Alt + [ | Shift + Option + [ | Tabbed panel backward |
+----------------------------+--------------------+------------------------------------+
| Shift + Alt + ] | Shift + Option + ] | Tabbed panel forward |
+----------------------------+--------------------+------------------------------------+
| Shift + Alt + w | Shift + Ctrl + w | Close tab panel |
+----------------------------+--------------------+------------------------------------+
| Shift + Alt + l | Shift + Option + l | Tools main menu |
+----------------------------+--------------------+------------------------------------+
| Shift + Alt + v | Shift + Option + v | View data |
+----------------------------+--------------------+------------------------------------+
+----------------------------+---------------------------+------------------------------------+
| Shortcut (Windows/Linux) | Shortcut (Mac) | Function |
+============================+===========================+====================================+
| Shift + Ctrl + a | Shift + Ctrl + a | Add grid row |
+----------------------------+---------------------------+------------------------------------+
| Shift + Alt + b | Shift + Option + b | Object explorer |
+----------------------------+---------------------------+------------------------------------+
| Ctrl + Alt + b | Ctrl + Option + b | Toggle Object Explorer |
+----------------------------+---------------------------+------------------------------------+
| Shift + Alt + n | Shift + Option + n | Create object |
+----------------------------+---------------------------+------------------------------------+
| Shift + Alt + d | Shift + Option + d | Delete object |
+----------------------------+---------------------------+------------------------------------+
| Shift + Alt + g | Shift + Option + g | Direct debugging |
+----------------------------+---------------------------+------------------------------------+
| Shift + Alt + e | Shift + Option + e | Edit object properties |
+----------------------------+---------------------------+------------------------------------+
| Shift + Alt + f | Shift + Option + f | File main menu |
+----------------------------+---------------------------+------------------------------------+
| Shift + Alt + h | Shift + Option + h | Help main menu |
+----------------------------+---------------------------+------------------------------------+
| Shift + Alt + o | Shift + Option + o | Object main menu |
+----------------------------+---------------------------+------------------------------------+
| Shift + Alt + q | Shift + Option + q | Open query tool |
+----------------------------+---------------------------+------------------------------------+
| Shift + Ctrl + f | Shift + Ctrl + f | Quick Search |
+----------------------------+---------------------------+------------------------------------+
| F5 | F5 | Refresh object explorer |
+----------------------------+---------------------------+------------------------------------+
| Shift + Alt + s | Shift + Option + s | Search objects |
+----------------------------+---------------------------+------------------------------------+
| Ctrl + Alt + Page Up | Ctrl + Option + Page Up | Tabbed panel backward |
+----------------------------+---------------------------+------------------------------------+
| Ctrl + Alt + Page Down | Ctrl + Option + Page Down | Tabbed panel forward |
+----------------------------+---------------------------+------------------------------------+
| Shift + Alt + w | Shift + Ctrl + w | Close tab panel |
+----------------------------+---------------------------+------------------------------------+
| Shift + Alt + l | Shift + Option + l | Tools main menu |
+----------------------------+---------------------------+------------------------------------+
| Shift + Alt + v | Shift + Option + v | View data |
+----------------------------+---------------------------+------------------------------------+
Property Grid Controls
**********************
@@ -100,6 +100,8 @@ When using the syntax-highlighting SQL editors, the following shortcuts are avai
+--------------------------+----------------------+-------------------------------------+
| Ctrl + / | Cmd + / | Comment/Uncomment code (Block) |
+--------------------------+----------------------+-------------------------------------+
| Ctrl + Shift + d | Cmd + Shift + d | Duplicate current line/selection |
+--------------------------+----------------------+-------------------------------------+
| Ctrl + a | Cmd + a | Select all |
+--------------------------+----------------------+-------------------------------------+
| Ctrl + c | Cmd + c | Copy selected text to the clipboard |

View File

@@ -196,9 +196,9 @@ def register_browser_preferences(self):
'keyboardshortcut',
{
'alt': True,
'shift': True,
'control': False,
'key': {'key_code': 91, 'char': '['}
'shift': False,
'control': True,
'key': {'key_code': 33, 'char': 'PageUp'}
},
category_label=PREF_LABEL_KEYBOARD_SHORTCUTS,
fields=fields
@@ -226,9 +226,9 @@ def register_browser_preferences(self):
'keyboardshortcut',
{
'alt': True,
'shift': True,
'control': False,
'key': {'key_code': 93, 'char': ']'}
'shift': False,
'control': True,
'key': {'key_code': 34, 'char': 'PageDown'}
},
category_label=PREF_LABEL_KEYBOARD_SHORTCUTS,
fields=fields

View File

@@ -153,42 +153,49 @@ _.extend(pgBrowser.keyboardNavigation, {
bindRightPanel: function(event, combo) {
const self = this;
const shortcutObj = this.keyboardShortcut;
const activeElement = document.activeElement;
const rootDock = document.getElementById('root');
if (!rootDock) return;
if (activeElement.closest('.dock-tab-btn')) {
const currDockTab = activeElement.closest('.dock-tab-btn');
const dockLayout = currDockTab.closest('.dock-layout');
const dockLayoutTabs = dockLayout ? Array.from(dockLayout.querySelectorAll('.dock-tab-btn')) : null;
// Find the active workspace tab independently of where the keyboard focus
// currently sits. Previously this relied on document.activeElement, which
// breaks when focus is inside a tool's own (nested) dock layout - the SQL
// editor, an ERD/Schema Diff canvas - or inside the PSQL iframe, because
// the resolved tab id then belonged to the tool's inner tab rather than a
// main workspace tab (issue #7232).
//
// rc-dock renders the object explorer and the workspace as separate
// tab-sets, and each marks its own active tab with `dock-tab-active`, so
// prefer the active tab that is not the object explorer.
//
// Only the outermost dock layout is considered. The SQL editor, ERD and
// Schema Diff each render a DockLayout of their own inside a workspace
// tab, and their active tabs (Data Output, Messages, Notifications and
// so on) carry the same `dock-tab-active` class, so a search across the
// whole tree can land on one of those and cycle a tool's inner tabs
// instead of the workspace tabs it was asked to move between.
const topDockLayout = rootDock.querySelector('.dock-layout');
if (!topDockLayout) return;
if (dockLayoutTabs && dockLayoutTabs.length > 1) {
const activeTabIndex = dockLayoutTabs.indexOf(currDockTab);
self._focusTab(dockLayoutTabs, activeTabIndex, shortcutObj, combo);
}
}
else if (activeElement.nodeName === 'IFRAME' || activeElement.closest('.dock-tabpane.dock-tabpane-active')) {
let activeTabId = '';
activeTabId = (activeElement.nodeName === 'IFRAME') ? activeElement.id : activeElement.closest('.dock-tabpane.dock-tabpane-active').id;
const dockLayout = document.getElementById('root');
const dockLayoutTabs = dockLayout ? Array.from(dockLayout.querySelectorAll('.dock-tab-btn')) : null;
const activeTabBtns = Array.from(
topDockLayout.querySelectorAll('.dock-tab.dock-tab-active .dock-tab-btn')
).filter(tab => tab.closest('.dock-layout') === topDockLayout);
const activeTabBtn =
activeTabBtns.find(tab => !tab.id.includes('id-object-explorer')) ||
activeTabBtns[0];
if (!activeTabBtn) return;
if (dockLayoutTabs && dockLayoutTabs.length > 1 && activeTabId) {
const activeTabIndex = dockLayoutTabs.findIndex(tab => tab.id.slice(14) === activeTabId);
self._focusTab(dockLayoutTabs, activeTabIndex, shortcutObj, combo);
}
}
else if (activeElement === document.body || document.querySelector('div[data-test="app-menu-bar"]')) {
const activeTabs = document.getElementsByClassName('dock-tabpane dock-tabpane-active');
// Restrict navigation to the tabs of the same tab-set (dock panel) as the
// active tab, so cycling stays within the workspace tabs and does not
// include the object explorer or a tool's nested tabs.
const panel = activeTabBtn.closest('.dock-panel');
const dockLayoutTabs = panel ? Array.from(
panel.querySelectorAll('.dock-tab-btn'))
.filter(tab => tab.closest('.dock-panel') === panel &&
tab.closest('.dock-layout') === topDockLayout) : [];
if (activeTabs.length > 1) {
const activeTabId = activeTabs[1].id;
const dockLayout = document.getElementById('root');
const dockLayoutTabs = dockLayout ? Array.from(dockLayout.querySelectorAll('.dock-tab-btn')) : null;
if (dockLayoutTabs && dockLayoutTabs.length > 1 && activeTabId) {
const activeTabIndex = dockLayoutTabs.findIndex(tab => tab.id.slice(14) === activeTabId);
self._focusTab(dockLayoutTabs, activeTabIndex, shortcutObj, combo);
}
}
if (dockLayoutTabs.length > 1) {
const activeTabIndex = dockLayoutTabs.indexOf(activeTabBtn);
self._focusTab(dockLayoutTabs, activeTabIndex, shortcutObj, combo);
}
},
_focusTab: function(dockLayoutTabs, activeTabIdx, shortcut_obj, combo){

View File

@@ -97,7 +97,7 @@ export default function SchemaDialogView({
);
};
const save = (changeData) => {
const save = (changeData, onSaved) => {
props.onSave(schemaState.isNew, changeData)
.then(()=>{
if(schema.informText) {
@@ -106,6 +106,7 @@ export default function SchemaDialogView({
schema.informText,
);
}
onSaved?.();
}).catch((err)=>{
schemaState.setError({
name: 'apierror',
@@ -119,8 +120,15 @@ export default function SchemaDialogView({
});
};
const onSaveClick = () => {
// Do nothing when there is no change or there is an error
// closeOnSave is only ever passed explicitly as true, by the Ctrl/Cmd+Enter
// handler below. The Save button's onClick passes its click event instead,
// which is never === true, so a plain Save click keeps its existing
// behaviour (some dialogs, e.g. object properties, stay open after Save).
const onSaveClick = (closeOnSave) => {
// Do nothing when a save is already in flight, when there is no change,
// or when there is an error. The Save button is disabled whilst saving,
// but the Ctrl/Cmd+Enter shortcut calls this directly.
if (schemaState.isSaving) return;
if (
!schemaState._changes || Object.keys(schemaState._changes).length === 0 ||
schemaState.errors.name
@@ -129,15 +137,17 @@ export default function SchemaDialogView({
setSaving(true);
setLoaderText(schemaState.customLoadingText || gettext('Saving...'));
const onSaved = closeOnSave === true ? () => props.onClose?.() : undefined;
if (!schema.warningText) {
save(schemaState.changes(true));
save(schemaState.changes(true), onSaved);
return;
}
Notifier.confirm(
gettext('Warning'),
schema.warningText,
() => { save(schemaState.changes(true)); },
() => { save(schemaState.changes(true), onSaved); },
() => {
setSaving(false);
setLoaderText('');
@@ -177,9 +187,35 @@ export default function SchemaDialogView({
return <SaveIcon />;
};
const onKeyDown = (e) => {
// Ctrl/Cmd+Enter saves and closes the dialog from anywhere within it
// (issue #7167). onSaveClick is a no-op when there is nothing to save or
// there is a validation error, so this is safe to call unconditionally.
if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key === 'Enter') {
e.preventDefault();
onSaveClick(true);
return;
}
// Escape closes the dialog, mirroring the Close button (issue #5691).
// This is needed for dialogs rendered as dockable panels (Properties,
// Backup, and other utility dialogs); dialogs rendered inside a MUI modal
// already close on Escape, so skip those to avoid a double close. The
// !e.defaultPrevented guard lets an inner control that handles Escape
// (e.g. an open dropdown) consume it first.
if (e.key === 'Escape' && !e.defaultPrevented && props.onClose &&
!e.currentTarget.closest('.MuiDialog-root')) {
e.preventDefault();
props.onClose();
}
};
/* I am Groot */
return useMemo(() =>
<StyledBox>
// Only the children are memoized: the wrapper carries onKeyDown, which
// closes over props.onClose and onSaveClick, and memoizing it would pin
// whichever versions of those existed when the deps last changed.
const dialogContent = useMemo(() =>
<>
<SchemaStateContext.Provider value={schemaState}>
<Box className='Dialog-form'>
<FormLoader/>
@@ -234,8 +270,10 @@ export default function SchemaDialogView({
</Box>
}
</SchemaStateContext.Provider>
</StyledBox>, [schema._id, viewHelperProps.mode, resetKey]
</>, [schema._id, viewHelperProps.mode, resetKey]
);
return <StyledBox onKeyDown={onKeyDown}>{dialogContent}</StyledBox>;
}
SchemaDialogView.propTypes = {

View File

@@ -30,7 +30,7 @@ import {
keymap,
} from '@codemirror/view';
import { EditorState, Compartment } from '@codemirror/state';
import { history, defaultKeymap, historyKeymap, indentLess, indentMore, deleteCharBackwardStrict } from '@codemirror/commands';
import { history, defaultKeymap, historyKeymap, indentLess, indentMore, deleteCharBackwardStrict, copyLineDown } from '@codemirror/commands';
import { closeBrackets, autocompletion, closeBracketsKeymap, completionKeymap, acceptCompletion } from '@codemirror/autocomplete';
import {
foldGutter,
@@ -140,6 +140,12 @@ const defaultExtensions = [
key: 'Backspace',
preventDefault: true,
run: deleteCharBackwardStrict,
},{
// Duplicate the current line, or the selected lines if there is a
// selection (issue #3834).
key: 'Mod-Shift-d',
preventDefault: true,
run: copyLineDown,
}]),
PgSQL.language.data.of({
autocomplete: false,

View File

@@ -0,0 +1,191 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2026, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { act, fireEvent, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import BaseUISchema from 'sources/SchemaView/base_schema.ui';
import SchemaView from '../../../pgadmin/static/js/SchemaView';
import { TestSchema } from './TestSchema.ui';
import { withBrowser } from '../genericFunctions';
// A single required field is all the Ctrl/Cmd+Enter save-and-close tests
// need; TestSchema's nested tab and row collection require a lot more
// simulated input just to get to a savable state.
class MinimalSchema extends BaseUISchema {
constructor() {
super({field1: null});
}
get baseFields() {
return [
{
id: 'field1', label: 'Field1', type: 'text', group: null,
mode: ['properties', 'edit', 'create'], disabled: false, visible: true,
},
];
}
}
// Escape closes a dialog rendered as a dockable panel (issue #5691). The
// handler sits on the dialog wrapper, which must not be memoized along with
// the dialog body: the body only changes with the schema, the mode or the
// reset key, whereas onClose is a fresh function on most parent renders, and
// a memoized wrapper would keep calling whichever one it captured first.
describe('SchemaDialogView keyboard handling', () => {
const SchemaViewWithBrowser = withBrowser(SchemaView);
const user = userEvent.setup();
const dialog = (schema, onClose) => (
<SchemaViewWithBrowser
formType='dialog'
schema={schema}
viewHelperProps={{mode: 'create'}}
onSave={jest.fn(() => Promise.resolve())}
onClose={onClose}
onHelp={jest.fn()}
onEdit={jest.fn()}
onDataChange={jest.fn()}
hasSQL={false}
disableSqlHelp={true}
disableDialogHelp={true}
/>
);
const renderDialog = async (onClose, onSave = jest.fn(() => Promise.resolve()), schema = new TestSchema()) => {
let ctrl;
await act(async () => {
ctrl = render(
<SchemaViewWithBrowser
formType='dialog'
schema={schema}
viewHelperProps={{mode: 'create'}}
onSave={onSave}
onClose={onClose}
onHelp={jest.fn()}
onEdit={jest.fn()}
onDataChange={jest.fn()}
hasSQL={false}
disableSqlHelp={true}
disableDialogHelp={true}
/>
);
});
return ctrl;
};
const pressEscape = async (ctrl) => {
await act(async () => {
fireEvent.keyDown(ctrl.container.firstChild, {key: 'Escape'});
});
};
const pressCtrlEnter = async (ctrl) => {
await act(async () => {
fireEvent.keyDown(ctrl.container.firstChild, {key: 'Enter', ctrlKey: true});
});
};
it('closes the dialog on Escape', async () => {
const onClose = jest.fn();
const ctrl = await renderDialog(onClose);
await pressEscape(ctrl);
expect(onClose).toHaveBeenCalled();
});
// An inner control that handles Escape itself (react-select closing an open
// menu, for example) calls preventDefault, and the event still bubbles up
// to the dialog, which must then leave the dialog open.
it('does not close when an inner control consumes Escape', async () => {
const onClose = jest.fn();
const ctrl = await renderDialog(onClose, undefined, new MinimalSchema());
const control = ctrl.container.querySelector('[name="field1"]');
control.addEventListener(
'keydown', (event) => event.preventDefault(), {once: true}
);
await act(async () => {
fireEvent.keyDown(control, {key: 'Escape'});
});
expect(onClose).not.toHaveBeenCalled();
});
it('calls the current onClose, not the one from the first render',
async () => {
const firstOnClose = jest.fn();
const secondOnClose = jest.fn();
// The same schema throughout: the memo deps are the schema id, the mode
// and the reset key, so this is the case where nothing invalidates the
// memo and only the callback has changed.
const schema = new TestSchema();
let ctrl;
await act(async () => {
ctrl = render(dialog(schema, firstOnClose));
});
// The parent re-renders with a new callback, as it does whenever it
// defines onClose inline.
await act(async () => {
ctrl.rerender(dialog(schema, secondOnClose));
});
await pressEscape(ctrl);
expect(secondOnClose).toHaveBeenCalled();
expect(firstOnClose).not.toHaveBeenCalled();
});
// Ctrl/Cmd+Enter is meant to save and close in one step (issue #7167).
// onSaveClick alone is not enough: it only calls onSave, so this covers the
// dialog actually closing once that save resolves.
it('saves and closes the dialog on Ctrl/Cmd+Enter', async () => {
const onClose = jest.fn();
const onSave = jest.fn(() => Promise.resolve());
const ctrl = await renderDialog(onClose, onSave, new MinimalSchema());
// Wait for the dialog's auto-focus to settle before typing, as the other
// SchemaDialogView specs do.
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 500));
});
await user.type(ctrl.container.querySelector('[name="field1"]'), 'val1');
await pressCtrlEnter(ctrl);
expect(onSave).toHaveBeenCalled();
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
// A second Ctrl/Cmd+Enter whilst the first save is still in flight must not
// start another save: the Save button is disabled during a save, but the
// shortcut reaches onSaveClick directly.
it('ignores Ctrl/Cmd+Enter whilst a save is in progress', async () => {
const onClose = jest.fn();
let resolveSave;
const onSave = jest.fn(() => new Promise(resolve => { resolveSave = resolve; }));
const ctrl = await renderDialog(onClose, onSave, new MinimalSchema());
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 500));
});
await user.type(ctrl.container.querySelector('[name="field1"]'), 'val1');
await pressCtrlEnter(ctrl);
await pressCtrlEnter(ctrl);
expect(onSave).toHaveBeenCalledTimes(1);
await act(async () => { resolveSave(); });
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
});
});

View File

@@ -0,0 +1,176 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2026, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
// keyboard.js reaches pgadmin.js by relative path, which skips the
// sources/pgadmin alias that maps to the fake, so point it there explicitly.
jest.mock('../../../pgadmin/static/js/pgadmin', () =>
jest.requireActual('../fake_pgadmin'));
import pgAdmin from 'sources/pgadmin';
import '../../../pgadmin/browser/static/js/keyboard';
// Cycling between workspace tabs must stay in the workspace: the object
// explorer is a tab-set of its own, and the SQL editor, ERD and Schema Diff
// each render a nested DockLayout whose own tabs carry the same
// dock-tab-active class (issue #7232).
describe('keyboardNavigation.bindRightPanel', () => {
const shortcutObj = {
tabbed_panel_forward: 'ctrl+alt+pagedown',
tabbed_panel_backward: 'ctrl+alt+pageup',
close_tab_panel: 'shift+alt+w',
};
const tabBtn = (id, isActive) => {
const tab = document.createElement('div');
tab.className = isActive ? 'dock-tab dock-tab-active' : 'dock-tab';
const btn = document.createElement('div');
btn.className = 'dock-tab-btn';
btn.id = `rc-dock-tab-btn-${id}`;
tab.appendChild(btn);
return tab;
};
const panel = (tabs) => {
const el = document.createElement('div');
el.className = 'dock-panel';
tabs.forEach((tab) => el.appendChild(tab));
return el;
};
/* A workspace holding the object explorer, three workspace tabs and, inside
* the active workspace tab, a tool with its own dock layout. */
const buildLayout = ({activeWorkspaceTab = 'id-dashboard'} = {}) => {
const root = document.createElement('div');
root.id = 'root';
const topLayout = document.createElement('div');
topLayout.className = 'dock-layout';
root.appendChild(topLayout);
topLayout.appendChild(panel([tabBtn('id-object-explorer', true)]));
const workspaceTabs = ['id-dashboard', 'id-properties', 'id-sql'].map(
(id) => tabBtn(id, id === activeWorkspaceTab));
const workspacePanel = panel(workspaceTabs);
topLayout.appendChild(workspacePanel);
// The tool's own dock layout, nested inside the workspace panel exactly
// as the SQL editor's is.
const innerLayout = document.createElement('div');
innerLayout.className = 'dock-layout';
innerLayout.appendChild(panel([
tabBtn('id-dataoutput', true),
tabBtn('id-messages', false),
]));
workspacePanel.appendChild(innerLayout);
document.body.appendChild(root);
return {root, workspacePanel};
};
let focusTabSpy;
beforeEach(() => {
document.body.innerHTML = '';
pgAdmin.Browser.keyboardNavigation.keyboardShortcut = shortcutObj;
focusTabSpy = jest.spyOn(
pgAdmin.Browser.keyboardNavigation, '_focusTab'
).mockImplementation(() => {});
});
afterEach(() => {
focusTabSpy.mockRestore();
document.body.innerHTML = '';
});
it('cycles the workspace tabs, not a tool\'s nested tabs', () => {
buildLayout();
pgAdmin.Browser.keyboardNavigation.bindRightPanel(
new Event('keydown'), {key: shortcutObj.tabbed_panel_forward});
expect(focusTabSpy).toHaveBeenCalled();
const [tabs, activeIdx] = focusTabSpy.mock.calls[0];
const ids = tabs.map((tab) => tab.id);
expect(ids).toEqual([
'rc-dock-tab-btn-id-dashboard',
'rc-dock-tab-btn-id-properties',
'rc-dock-tab-btn-id-sql',
]);
// Neither the nested tool tabs nor the object explorer may take part.
expect(ids).not.toContain('rc-dock-tab-btn-id-dataoutput');
expect(ids).not.toContain('rc-dock-tab-btn-id-object-explorer');
expect(tabs[activeIdx].id).toBe('rc-dock-tab-btn-id-dashboard');
});
it('starts from whichever workspace tab is active', () => {
buildLayout({activeWorkspaceTab: 'id-sql'});
pgAdmin.Browser.keyboardNavigation.bindRightPanel(
new Event('keydown'), {key: shortcutObj.tabbed_panel_backward});
const [tabs, activeIdx] = focusTabSpy.mock.calls[0];
expect(tabs[activeIdx].id).toBe('rc-dock-tab-btn-id-sql');
});
/* The selection must not depend on where the nested layout happens to sit
* in the DOM: rc-dock is free to order panels as it likes, and an inner
* layout appearing first would otherwise win the search for the active
* tab and cycle a tool's own tabs. */
it('ignores nested tool tabs even when they come first in the DOM', () => {
const root = document.createElement('div');
root.id = 'root';
const topLayout = document.createElement('div');
topLayout.className = 'dock-layout';
root.appendChild(topLayout);
const innerLayout = document.createElement('div');
innerLayout.className = 'dock-layout';
innerLayout.appendChild(panel([
tabBtn('id-dataoutput', true),
tabBtn('id-messages', false),
]));
topLayout.appendChild(innerLayout);
topLayout.appendChild(panel([tabBtn('id-object-explorer', true)]));
topLayout.appendChild(panel([
tabBtn('id-dashboard', false),
tabBtn('id-sql', true),
]));
document.body.appendChild(root);
pgAdmin.Browser.keyboardNavigation.bindRightPanel(
new Event('keydown'), {key: shortcutObj.tabbed_panel_forward});
expect(focusTabSpy).toHaveBeenCalled();
const [tabs, activeIdx] = focusTabSpy.mock.calls[0];
const ids = tabs.map((tab) => tab.id);
expect(ids).toEqual([
'rc-dock-tab-btn-id-dashboard',
'rc-dock-tab-btn-id-sql',
]);
expect(tabs[activeIdx].id).toBe('rc-dock-tab-btn-id-sql');
});
it('does nothing when only the object explorer is present', () => {
const root = document.createElement('div');
root.id = 'root';
const topLayout = document.createElement('div');
topLayout.className = 'dock-layout';
topLayout.appendChild(panel([tabBtn('id-object-explorer', true)]));
root.appendChild(topLayout);
document.body.appendChild(root);
pgAdmin.Browser.keyboardNavigation.bindRightPanel(
new Event('keydown'), {key: shortcutObj.tabbed_panel_forward});
expect(focusTabSpy).not.toHaveBeenCalled();
});
});

View File

@@ -11,6 +11,7 @@
import { withTheme } from '../fake_theme';
import CodeMirror from 'sources/components/ReactCodeMirror';
import { syntaxTree } from '@codemirror/language';
import { runScopeHandlers } from '@codemirror/view';
import { render } from '@testing-library/react';
@@ -320,4 +321,25 @@ describe('CodeMirrorCustomEditorView', ()=>{
expect(result.to).toBe(stmts[1].to);
});
it('Mod-Shift-D duplicates the current line',()=>{
cmRerender({value: 'select 1;\nselect 2;'});
editor.dispatch({selection: {anchor: 0}});
// jsdom does not report a Mac platform, so Mod is Ctrl here.
const handled = runScopeHandlers(editor, new KeyboardEvent('keydown', {
key: 'D', code: 'KeyD', keyCode: 68, ctrlKey: true, shiftKey: true,
}), 'editor');
expect(handled).toBe(true);
expect(editor.state.doc.toString()).toEqual('select 1;\nselect 1;\nselect 2;');
});
it('Mod-Shift-D duplicates every line in the selection',()=>{
cmRerender({value: 'select 1;\nselect 2;\nselect 3;'});
editor.dispatch({selection: {anchor: 0, head: 12}});
runScopeHandlers(editor, new KeyboardEvent('keydown', {
key: 'D', code: 'KeyD', keyCode: 68, ctrlKey: true, shiftKey: true,
}), 'editor');
expect(editor.state.doc.toString()).toEqual(
'select 1;\nselect 2;\nselect 1;\nselect 2;\nselect 3;');
});
});