From 0edc54eafcb06249a3c5050f2379905c1a1c0175 Mon Sep 17 00:00:00 2001 From: Dave Page Date: Mon, 17 Aug 2026 15:21:43 +0100 Subject: [PATCH] feat: add a keyboard shortcut for collapsing the Object Explorer #10254 made the Object Explorer collapsible by clicking the current workspace icon, but left that as the only way to do it. This adds a shortcut, Ctrl+Alt+B by default, registered as its own toggle_object_explorer preference so anyone can change it. Ctrl+Alt+B rather than anything in the Shift+Alt family, which is full: b, f, o, l, h, q, v, s, n, e, d and g are all taken, b by the existing Object Explorer shortcut, which keeps its meaning of focusing the tree. Ctrl+Alt+B is close enough to VS Code's Ctrl+B for the side bar to be guessable, and keeps a family resemblance to the bracket keys used for tab navigation. Ctrl+Shift+B would have been the other obvious candidate, but Chrome and Firefox both use it for the bookmarks bar. Worth noting for anyone reviewing: on keyboard layouts where AltGr is Ctrl+Alt, this combination can be swallowed by the layout and produce a character instead. That is true of any Ctrl+Alt binding, the preference is user-configurable, and Shift+Ctrl+Alt+B is available if the default turns out to annoy people. The shortcut itself only fires an event, since the workspace provider owns the visibility state, and the provider ignores it in classic layout where there is no workspace toolbar and the Object Explorer is always shown. Tested in a browser as well as in the unit tests: from the workspace layout the key collapses and restores the panel, and the preference is registered alongside the existing shortcuts. --- docs/en_US/keyboard_shortcuts.rst | 2 + .../browser/register_browser_preferences.py | 15 +++++++ web/pgadmin/browser/static/js/constants.js | 4 ++ web/pgadmin/browser/static/js/keyboard.js | 9 ++++- .../static/js/WorkspaceProvider.jsx | 12 +++++- .../keyboard_toggle_object_explorer_spec.js | 40 +++++++++++++++++++ .../workspaces/WorkspaceProvider.spec.jsx | 29 +++++++++++++- 7 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 web/regression/javascript/browser/keyboard_toggle_object_explorer_spec.js 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();