diff --git a/docs/en_US/query_tool.rst b/docs/en_US/query_tool.rst index 8cfdef4150c..848dca55546 100644 --- a/docs/en_US/query_tool.rst +++ b/docs/en_US/query_tool.rst @@ -35,8 +35,9 @@ The Query Tool features two panels: to view the queries that have been executed in the session, a *Scratch Pad* which can be used to hold text snippets during editing, and an *AI Assistant* tab for generating SQL from natural language (when AI is configured). If the Scratch Pad is - closed, it can be re-opened (or additional ones opened) by right-clicking in - the SQL Editor and other panels and adding a new panel. + closed, it can be re-opened by right-clicking on any panel tab and selecting + *Add Panel*. Each Query Tool has a single Scratch Pad, so *Add Panel* only lists + it while it is closed. * The lower panel displays the *Data Output* panel. The tabbed panel displays the result set returned by a query, information about a query's execution plan, server messages related to the query's execution and any asynchronous diff --git a/web/pgadmin/static/js/helpers/Layout/index.jsx b/web/pgadmin/static/js/helpers/Layout/index.jsx index fac94a4738c..a16e42e84f0 100644 --- a/web/pgadmin/static/js/helpers/Layout/index.jsx +++ b/web/pgadmin/static/js/helpers/Layout/index.jsx @@ -342,6 +342,8 @@ export class LayoutDocker { flattenLayout(this.defaultLayout.dockbox, flatDefault); flattenLayout(this.layoutObj.getLayout().dockbox, flatCurrent); + const currentIds = flatCurrent.map(t => t.id); + // Find tabs in default but not in saved layout const missingTabs = _.differenceBy(flatDefault, flatCurrent, 'id'); @@ -366,7 +368,7 @@ export class LayoutDocker { // Add each missing tab next to a sibling from its original panel group missingNonClosableTabs.forEach((tab) => { - const siblingId = this.findSiblingTab(tab.id, flatDefault, flatCurrent); + const siblingId = this.findSiblingTab(tab.id, (id) => currentIds.includes(id)); if (siblingId) { this.openTab({ id: tab.id, @@ -389,27 +391,111 @@ export class LayoutDocker { }); } - findSiblingTab(tabId, flatDefault, flatCurrent) { - // Find which panel group this tab belongs to in the default layout - const findPanelTabs = (box, targetId) => { - for (const child of box.children) { + /** + * Finds an open tab that shares a panel group with the given tab in the + * default layout. + * @param {string} tabId - id of the tab to find a sibling for. + * @param {function} isOpen - predicate telling whether a tab id is open. + * @returns {string|undefined} id of the open sibling, if any. + */ + findSiblingTab(tabId, isOpen) { + const siblingIds = this.findDefaultTab(tabId)?.panel.tabs.map(t => t.id); + return siblingIds?.find(id => id !== tabId && isOpen(id)); + } + + /** + * Finds a tab in the default layout along with the panel holding it. + * @param {string} tabId - id of the tab to look for. + * @returns {{tab: object, panel: object}|null} + */ + findDefaultTab(tabId) { + const search = (box) => { + for (const child of box?.children ?? []) { if (child.children) { - const result = findPanelTabs(child, targetId); + const result = search(child); if (result) return result; - } else if (child.tabs) { - const hasTarget = child.tabs.some(t => t.id === targetId); - if (hasTarget) return child.tabs.map(t => t.id); + } else { + const tab = child.tabs?.find(t => t.id === tabId); + if (tab) return {tab, panel: child}; } } return null; }; + return search(this.defaultLayout.dockbox); + } - const siblingIds = findPanelTabs(this.defaultLayout.dockbox, tabId); - if (!siblingIds) return null; + /** + * Works out where a closed default tab should be docked: next to an open + * tab of its original panel group, or else beside the neighbouring panel + * it sat next to in the default layout. + * @param {string} tabId - id of the closed tab. + * @returns {{refTabId: string, direction: string}|null} an open tab to dock + * against and the rc-dock direction, or null when nothing relevant is open. + */ + findDefaultDockPosition(tabId) { + const isOpen = (id) => this.isTabOpen(id); + const siblingId = this.findSiblingTab(tabId, isOpen); + if (siblingId) { + return {refTabId: siblingId, direction: 'middle'}; + } - // Find a sibling that exists in current layout - const currentIds = flatCurrent.map(t => t.id); - return siblingIds.find(id => id !== tabId && currentIds.includes(id)); + const collectIds = (box) => [ + ...(box.tabs?.map(t => t.id) ?? []), + ...(box.children ?? []).flatMap(collectIds), + ]; + const findAdjacent = (box) => { + const children = box.children ?? []; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (child.tabs?.some(t => t.id === tabId)) { + for (const adjIdx of [i - 1, i + 1]) { + const openId = collectIds(children[adjIdx] ?? {}).find(isOpen); + if (openId) { + const isAfter = i > adjIdx; + const direction = box.mode === 'vertical' + ? (isAfter ? 'bottom' : 'top') + : (isAfter ? 'right' : 'left'); + return {refTabId: openId, direction}; + } + } + } else if (child.children) { + const res = findAdjacent(child); + if (res) return res; + } + } + return null; + }; + return findAdjacent(this.defaultLayout.dockbox); + } + + /** + * Re-opens a closed default tab where it originally was, keeping the size + * and other properties of the panel that held it. + * @param {string} tabId - id of the default tab to re-open. + * @param {string} fallbackTabId - tab whose panel is used when the original + * position can't be worked out. + */ + reopenDefaultTab(tabId, fallbackTabId) { + const defaultTab = this.findDefaultTab(tabId); + if (!defaultTab || this.isTabOpen(tabId)) return; + + const {tab, panel} = defaultTab; + const newTab = LayoutDocker.getPanel({id: tab.id, content: tab.content, ...tab.internal}); + const dockPos = this.findDefaultDockPosition(tabId); + const refPanel = this.find(dockPos?.refTabId ?? fallbackTabId)?.parent; + if (!refPanel) return; + + // Side directions need a panel target, and can't split a maximised or + // floating panel; the other cases join the target's tab group. + if (!['left', 'right', 'top', 'bottom'].includes(dockPos?.direction) + || ['maximize', 'float'].includes(refPanel.parent?.mode)) { + this.layoutObj.dockMove(newTab, refPanel, 'middle'); + } else { + this.layoutObj.dockMove({ + ..._.pick(panel, ['size', 'minWidth', 'minHeight', 'maxWidth', 'maxHeight', 'maximizable']), + tabs: [newTab], + }, refPanel, dockPos.direction); + } } saveLayout(l) { @@ -583,7 +669,7 @@ export function getDefaultGroup() { }; } -export default function Layout({groups, noContextGroups, getLayoutInstance, layoutId, savedLayout, resetToTabPanel, enableToolEvents=false, isLayoutVisible=true, className, ...props}) { +export default function Layout({groups, noContextGroups, getLayoutInstance, layoutId, savedLayout, resetToTabPanel, enableToolEvents=false, isLayoutVisible=true, className, allowAddPanel=false, ...props}) { const [[contextPos, contextPanelId, contextExtraMenus], setContextPos] = React.useState([null, null, null]); const defaultGroups = React.useMemo(()=>({ 'dialogs': getDialogsGroup(), @@ -669,6 +755,26 @@ export default function Layout({groups, noContextGroups, getLayoutInstance, layo } }); } + + // Closed default tabs that can be re-opened, e.g. the Scratch Pad + const closedTabs = allowAddPanel ? flatDefaultLayout.filter( + (t) => t.internal?.closable && !layoutDockerObj.isTabOpen(t.id) + ) : []; + if(closedTabs.length > 0) { + ret.push({ + type: 'separator', + }, { + label: gettext('Add Panel'), + getMenuItems: () => closedTabs.map((tab) => ({ + label: tab.internal?.title || tab.id, + callback: () => { + layoutDockerObj.reopenDefaultTab(tab.id, panelId); + layoutDockerObj.focus(tab.id); + layoutDockerObj.saveLayout(); + } + })) + }); + } } return ret; }; @@ -774,7 +880,8 @@ Layout.propTypes = { resetToTabPanel: PropTypes.string, enableToolEvents: PropTypes.bool, isLayoutVisible: PropTypes.bool, - className: PropTypes.string + className: PropTypes.string, + allowAddPanel: PropTypes.bool }; diff --git a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx index 49cb7f5ffc6..533a3ff3316 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx @@ -1132,6 +1132,7 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN layoutId="SQLEditor/Layout" savedLayout={params.layout} resetToTabPanel={PANELS.MESSAGES} + allowAddPanel /> diff --git a/web/regression/javascript/sqleditor/ScratchPadReopen.spec.js b/web/regression/javascript/sqleditor/ScratchPadReopen.spec.js new file mode 100644 index 00000000000..82c9e8c2411 --- /dev/null +++ b/web/regression/javascript/sqleditor/ScratchPadReopen.spec.js @@ -0,0 +1,177 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { withTheme } from '../fake_theme'; +import Layout, { LayoutDocker } from '../../../pgadmin/static/js/helpers/Layout'; +import getApiInstance from '../../../pgadmin/static/js/api_instance'; +import { ApplicationStateProvider } from '../../../pgadmin/settings/static/ApplicationStateProvider'; + +jest.mock('../../../pgadmin/static/js/api_instance'); + +describe('ScratchPad Reopen & LayoutDocker dock positioning', () => { + const queryToolLayout = { + dockbox: { + mode: 'vertical', + children: [ + { + mode: 'horizontal', + children: [ + { + tabs: [ + LayoutDocker.getPanel({ id: 'id-query', title: 'Query' }), + LayoutDocker.getPanel({ id: 'id-history', title: 'Query History' }), + ], + }, + { + size: 75, + maximizable: true, + tabs: [ + LayoutDocker.getPanel({ + id: 'id-scratch', + title: 'Scratch Pad', + closable: true, + content: