diff --git a/docs/en_US/keyboard_shortcuts.rst b/docs/en_US/keyboard_shortcuts.rst index 5f766637865..0121624f7dd 100644 --- a/docs/en_US/keyboard_shortcuts.rst +++ b/docs/en_US/keyboard_shortcuts.rst @@ -22,6 +22,8 @@ When using main browser window, the following keyboard shortcuts are available: +----------------------------+--------------------+------------------------------------+ | 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 | diff --git a/web/pgadmin/browser/register_browser_preferences.py b/web/pgadmin/browser/register_browser_preferences.py index d2fe51dc5fa..79da8ed5123 100644 --- a/web/pgadmin/browser/register_browser_preferences.py +++ b/web/pgadmin/browser/register_browser_preferences.py @@ -163,6 +163,21 @@ def register_browser_preferences(self): fields=fields ) + self.preference.register( + 'keyboard_shortcuts', + 'toggle_object_explorer', + gettext('Toggle Object Explorer'), + 'keyboardshortcut', + { + 'alt': True, + 'shift': False, + 'control': True, + 'key': {'key_code': 66, 'char': 'b'} + }, + category_label=PREF_LABEL_KEYBOARD_SHORTCUTS, + fields=fields + ) + self.preference.register( 'keyboard_shortcuts', 'tabbed_panel_backward', diff --git a/web/pgadmin/browser/static/js/constants.js b/web/pgadmin/browser/static/js/constants.js index 8ad64a2089d..e1ed4cbde3c 100644 --- a/web/pgadmin/browser/static/js/constants.js +++ b/web/pgadmin/browser/static/js/constants.js @@ -148,3 +148,7 @@ export const AllPermissionTypes = { // Fired on pgAdmin.Browser.Events when code outside React, e.g. the keyboard // shortcut that focuses the tree, needs the Object Explorer on screen. export const SHOW_OBJECT_EXPLORER_EVENT = 'pgadmin:object-explorer:show'; + +// Fired on pgAdmin.Browser.Events to collapse or restore the Object Explorer, +// the same thing clicking the current workspace icon does. +export const TOGGLE_OBJECT_EXPLORER_EVENT = 'pgadmin:object-explorer:toggle'; diff --git a/web/pgadmin/browser/static/js/keyboard.js b/web/pgadmin/browser/static/js/keyboard.js index 662b8dd0239..e9779329903 100644 --- a/web/pgadmin/browser/static/js/keyboard.js +++ b/web/pgadmin/browser/static/js/keyboard.js @@ -14,7 +14,8 @@ import * as commonUtils from '../../../static/js/utils'; import gettext from 'sources/gettext'; import pgWindow from 'sources/window'; import usePreferences from '../../../preferences/static/js/store'; -import { SHOW_OBJECT_EXPLORER_EVENT } from './constants'; +import { SHOW_OBJECT_EXPLORER_EVENT, TOGGLE_OBJECT_EXPLORER_EVENT } + from './constants'; const pgBrowser = pgAdmin.Browser = pgAdmin.Browser || {}; @@ -41,6 +42,7 @@ _.extend(pgBrowser.keyboardNavigation, { ...(prefStore.getPreferences('browser', 'main_menu_tools')?.value) && {'tools_shortcut': commonUtils.parseShortcutValue(prefStore.getPreferences('browser', 'main_menu_tools')?.value)}, ...(prefStore.getPreferences('browser', 'main_menu_help')?.value) && {'help_shortcut': commonUtils.parseShortcutValue(prefStore.getPreferences('browser', 'main_menu_help')?.value)}, 'left_tree_shortcut': commonUtils.parseShortcutValue(prefStore.getPreferences('browser', 'browser_tree')?.value), + 'toggle_object_explorer': commonUtils.parseShortcutValue(prefStore.getPreferences('browser', 'toggle_object_explorer')?.value), 'tabbed_panel_backward': commonUtils.parseShortcutValue(prefStore.getPreferences('browser', 'tabbed_panel_backward')?.value), 'tabbed_panel_forward': commonUtils.parseShortcutValue(prefStore.getPreferences('browser', 'tabbed_panel_forward')?.value), 'sub_menu_query_tool': commonUtils.parseShortcutValue(prefStore.getPreferences('browser', 'sub_menu_query_tool')?.value), @@ -64,6 +66,7 @@ _.extend(pgBrowser.keyboardNavigation, { }}, // Main menu 'bindRightPanel': {'shortcuts': [this.keyboardShortcut.tabbed_panel_backward, this.keyboardShortcut.tabbed_panel_forward, this.keyboardShortcut.close_tab_panel]}, // Main window panels 'bindLeftTree': {'shortcuts': this.keyboardShortcut.left_tree_shortcut}, // Main menu, + 'bindToggleObjectExplorer': {'shortcuts': this.keyboardShortcut.toggle_object_explorer}, // Collapse/restore the Object Explorer 'bindSubMenuQueryTool': {'shortcuts': this.keyboardShortcut.sub_menu_query_tool}, // Sub menu - Open Query Tool, 'bindSubMenuViewData': {'shortcuts': this.keyboardShortcut.sub_menu_view_data}, // Sub menu - Open View Data, 'bindSubMenuSearchObjects': {'shortcuts': this.keyboardShortcut.sub_menu_search_objects}, // Sub menu - Open search objects, @@ -213,6 +216,10 @@ _.extend(pgBrowser.keyboardNavigation, { tree.t.select(tree.i); }, 0); }, + bindToggleObjectExplorer: function() { + // The provider owns the visibility state; it listens for this. + pgAdmin.Browser.Events.trigger(TOGGLE_OBJECT_EXPLORER_EVENT); + }, bindSubMenuQueryTool: function() { const tree = this.getTreeDetails(); diff --git a/web/pgadmin/misc/workspaces/static/js/WorkspaceProvider.jsx b/web/pgadmin/misc/workspaces/static/js/WorkspaceProvider.jsx index 548b89eb497..c29e76a04e7 100644 --- a/web/pgadmin/misc/workspaces/static/js/WorkspaceProvider.jsx +++ b/web/pgadmin/misc/workspaces/static/js/WorkspaceProvider.jsx @@ -9,7 +9,8 @@ import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import PropTypes from 'prop-types'; -import { BROWSER_PANELS, SHOW_OBJECT_EXPLORER_EVENT, WORKSPACES } +import { BROWSER_PANELS, SHOW_OBJECT_EXPLORER_EVENT, + TOGGLE_OBJECT_EXPLORER_EVENT, WORKSPACES } from '../../../../browser/static/js/constants'; import { usePgAdmin } from '../../../../static/js/PgAdminProvider'; import usePreferences from '../../../../preferences/static/js/store'; @@ -171,6 +172,15 @@ export function WorkspaceProvider({children}) { SHOW_OBJECT_EXPLORER_EVENT, ()=>setObjectExplorerVisible(true)); }, [pgAdmin, setObjectExplorerVisible]); + useEffect(()=>{ + // Classic layout has no workspace toolbar and always shows the Object + // Explorer, so the shortcut has nothing to collapse there. + if(isClassic) return; + + return pgAdmin.Browser.Events.registerListener( + TOGGLE_OBJECT_EXPLORER_EVENT, toggleObjectExplorer); + }, [pgAdmin, isClassic, toggleObjectExplorer]); + const value = useMemo(()=>({ config: config, currentWorkspace: currentWorkspace, diff --git a/web/regression/javascript/browser/keyboard_toggle_object_explorer_spec.js b/web/regression/javascript/browser/keyboard_toggle_object_explorer_spec.js new file mode 100644 index 00000000000..bb41915add0 --- /dev/null +++ b/web/regression/javascript/browser/keyboard_toggle_object_explorer_spec.js @@ -0,0 +1,40 @@ +///////////////////////////////////////////////////////////// +// +// 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 { TOGGLE_OBJECT_EXPLORER_EVENT } from + '../../../pgadmin/browser/static/js/constants'; +import '../../../pgadmin/browser/static/js/keyboard'; + +/* The shortcut does not own the visibility state, the workspace provider + * does, so all it has to do is say what the user asked for. */ +describe('keyboardNavigation.bindToggleObjectExplorer', () => { + it('asks the workspace provider to toggle the Object Explorer', + async () => { + const listener = jest.fn(); + const dereg = pgAdmin.Browser.Events.registerListener( + TOGGLE_OBJECT_EXPLORER_EVENT, listener); + + try { + pgAdmin.Browser.keyboardNavigation.bindToggleObjectExplorer(); + // EventBus dispatches through a couple of microtasks, so wait for a + // macrotask to be sure they have all run. + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(listener).toHaveBeenCalled(); + } finally { + dereg(); + } + }); +}); diff --git a/web/regression/javascript/workspaces/WorkspaceProvider.spec.jsx b/web/regression/javascript/workspaces/WorkspaceProvider.spec.jsx index 7e38750fca8..bf3f55a7b94 100644 --- a/web/regression/javascript/workspaces/WorkspaceProvider.spec.jsx +++ b/web/regression/javascript/workspaces/WorkspaceProvider.spec.jsx @@ -9,7 +9,7 @@ import { act, render } from '@testing-library/react'; -import { SHOW_OBJECT_EXPLORER_EVENT } from +import { SHOW_OBJECT_EXPLORER_EVENT, TOGGLE_OBJECT_EXPLORER_EVENT } from '../../../pgadmin/browser/static/js/constants'; import { WorkspaceProvider, useWorkspace } from '../../../pgadmin/misc/workspaces/static/js/WorkspaceProvider'; @@ -19,6 +19,18 @@ import getApiInstance from '../../../pgadmin/static/js/api_instance'; jest.mock('../../../pgadmin/static/js/api_instance'); +// The workspace layout, since that is the only layout with a workspace +// toolbar and therefore the only one where the Object Explorer can be +// collapsed at all. +jest.mock('../../../pgadmin/preferences/static/js/store', () => { + const store = () => ({ + getPreferencesForModule: () => ({layout: 'workspace'}), + }); + store.subscribe = () => () => {/* nothing to unsubscribe */}; + store.getState = () => ({}); + return {__esModule: true, default: store}; +}); + // The Object Explorer can be collapsed, and that choice is persisted, so the // visibility state has to survive a reload and the writes must not be able to // land out of order (#9631). @@ -97,6 +109,21 @@ describe('WorkspaceProvider Object Explorer visibility', () => { expect(values).toEqual(['false', 'true']); }); + it('toggles when asked to by code outside React', async () => { + await renderProvider(); + expect(workspace.isObjectExplorerVisible).toBe(true); + + await act(async () => { + pgAdmin.Browser.Events.trigger(TOGGLE_OBJECT_EXPLORER_EVENT); + }); + expect(workspace.isObjectExplorerVisible).toBe(false); + + await act(async () => { + pgAdmin.Browser.Events.trigger(TOGGLE_OBJECT_EXPLORER_EVENT); + }); + expect(workspace.isObjectExplorerVisible).toBe(true); + }); + it('shows the panel when asked to by code outside React', async () => { pgAdmin.Browser.utils.layout['Browser/ObjectExplorerVisible'] = 'false'; await renderProvider();