From df561acba6b72c1844b9373a79d30f3d36555e52 Mon Sep 17 00:00:00 2001 From: dev-hari-prasad Date: Wed, 30 Sep 2026 01:23:58 +0530 Subject: [PATCH 1/2] Allow reopening closed Scratch Pad via tab context menu and update docs. #10474 --- docs/en_US/query_tool.rst | 4 +- .../static/js/helpers/Layout/index.jsx | 122 +++++++-- .../sqleditor/ScratchPadReopen.spec.js | 244 ++++++++++++++++++ 3 files changed, 350 insertions(+), 20 deletions(-) create mode 100644 web/regression/javascript/sqleditor/ScratchPadReopen.spec.js diff --git a/docs/en_US/query_tool.rst b/docs/en_US/query_tool.rst index 8cfdef4150c..62339857645 100644 --- a/docs/en_US/query_tool.rst +++ b/docs/en_US/query_tool.rst @@ -35,8 +35,8 @@ 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*. * 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 2a4f0da0fc1..4d992b9ab3a 100644 --- a/web/pgadmin/static/js/helpers/Layout/index.jsx +++ b/web/pgadmin/static/js/helpers/Layout/index.jsx @@ -399,6 +399,51 @@ export class LayoutDocker { return siblingIds.find(id => id !== tabId && currentIds.includes(id)); } + findDefaultDockPosition(tabId, flatDefault, flatCurrent) { + // 1. Try finding a sibling tab in the same panel group + const siblingId = this.findSiblingTab(tabId, flatDefault, flatCurrent); + if (siblingId) { + return { refTabId: siblingId, direction: 'middle' }; + } + + const currentIds = flatCurrent.map(t => t.id); + + // 2. Try finding an adjacent panel in the same parent box (e.g. Scratch Pad next to Query Editor) + const findAdjacentInBox = (box) => { + if (!box?.children) return null; + for (let i = 0; i < box.children.length; i++) { + const child = box.children[i]; + if (child.tabs && child.tabs.some(t => t.id === tabId)) { + // Look for adjacent sibling child in parent box + const adjIndices = [i - 1, i + 1].filter(idx => idx >= 0 && idx < box.children.length); + for (const adjIdx of adjIndices) { + const adjChild = box.children[adjIdx]; + const adjTabIds = []; + const collectIds = (b) => { + if (b.tabs) adjTabIds.push(...b.tabs.map(t => t.id)); + if (b.children) b.children.forEach(collectIds); + }; + collectIds(adjChild); + const openAdjTabId = adjTabIds.find(id => currentIds.includes(id)); + if (openAdjTabId) { + const isAfter = i > adjIdx; + const direction = box.mode === 'vertical' + ? (isAfter ? 'bottom' : 'top') + : (isAfter ? 'right' : 'left'); + return { refTabId: openAdjTabId, direction }; + } + } + } else if (child.children) { + const res = findAdjacentInBox(child); + if (res) return res; + } + } + return null; + }; + + return findAdjacentInBox(this.defaultLayout?.dockbox); + } + saveLayout(l) { let api = getApiInstance(); if(!this.layoutId || !this.layoutObj) { @@ -576,12 +621,12 @@ export default function Layout({groups, noContextGroups, getLayoutInstance, layo const prefStore = usePreferences(); const dynamicTabsStyleRef = useRef(); const saveAppStateRef = useRef(prefStore?.getPreferencesForModule('misc')?.save_app_state); - const { deleteToolData } = useApplicationState(); + const { deleteToolData } = useApplicationState() || {}; useEffect(()=>{ layoutDockerObj.eventBus.registerListener(LAYOUT_EVENTS.REMOVE, (panelId)=>{ layoutDockerObj.close(panelId); - deleteToolData(panelId); + deleteToolData?.(panelId); }); layoutDockerObj.eventBus.registerListener(LAYOUT_EVENTS.CONTEXT, (e, id, extraMenus)=>{ @@ -613,6 +658,22 @@ export default function Layout({groups, noContextGroups, getLayoutInstance, layo }, [prefStore]); + const flatDefaultLayout = useMemo(()=>{ + const flat = []; + const flattenLayout = (box)=>{ + box.children.forEach((child)=>{ + if(child.children) { + flattenLayout(child); + } + else { + flat.push(...(child.tabs ?? [])); + } + }); + }; + flattenLayout(props.defaultLayout.dockbox); + return flat; + }, [props.defaultLayout]); + const getTabMenuItems = (panelId)=>{ const ret = []; if(panelId) { @@ -651,6 +712,47 @@ export default function Layout({groups, noContextGroups, getLayoutInstance, layo } }); } + + // Check for closed default closable tabs that can be re-opened + const missingClosableTabs = flatDefaultLayout.filter( + (t) => t.internal?.closable && !layoutDockerObj.isTabOpen(t.id) + ); + if (missingClosableTabs.length > 0) { + ret.push({ + type: 'separator', + }, { + label: gettext('Add Panel'), + getMenuItems: () => missingClosableTabs.map((tab) => ({ + label: tab.internal?.title || (typeof tab.title === 'string' ? tab.title : tab.id), + callback: () => { + const flatCurrent = []; + const flatten = (box) => { + box.children.forEach((child) => { + if (child.children) flatten(child); + else flatCurrent.push(...(child.tabs ?? [])); + }); + }; + const currentDockbox = layoutDockerObj.layoutObj?.getLayout()?.dockbox; + if (currentDockbox) { + flatten(currentDockbox); + } + const dockPos = layoutDockerObj.findDefaultDockPosition(tab.id, flatDefaultLayout, flatCurrent); + const targetTabId = dockPos?.refTabId || panelId || layoutDockerObj.resetToTabPanel; + const targetData = layoutDockerObj.find(targetTabId); + const fallbackDirection = targetData?.tabs ? 'middle' : 'after-tab'; + const direction = dockPos?.direction || fallbackDirection; + + layoutDockerObj.openTab({ + id: tab.id, + content: tab.content, + ...tab.internal, + }, targetTabId, direction); + layoutDockerObj.focus(tab.id); + layoutDockerObj.saveLayout(); + } + })) + }); + } } return ret; }; @@ -676,22 +778,6 @@ export default function Layout({groups, noContextGroups, getLayoutInstance, layo return savedTab; }; - const flatDefaultLayout = useMemo(()=>{ - const flat = []; - const flattenLayout = (box)=>{ - box.children.forEach((child)=>{ - if(child.children) { - flattenLayout(child); - } - else { - flat.push(...(child.tabs ?? [])); - } - }); - }; - flattenLayout(props.defaultLayout.dockbox); - return flat; - }, [props.defaultLayout]); - const loadTab = (tab)=>{ const tabData = flatDefaultLayout.find((t)=>t.id == tab.id); if(!tabData && tab.metaData) { diff --git a/web/regression/javascript/sqleditor/ScratchPadReopen.spec.js b/web/regression/javascript/sqleditor/ScratchPadReopen.spec.js new file mode 100644 index 00000000000..0bf29e1fc06 --- /dev/null +++ b/web/regression/javascript/sqleditor/ScratchPadReopen.spec.js @@ -0,0 +1,244 @@ +///////////////////////////////////////////////////////////// +// +// 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, + tabs: [ + LayoutDocker.getPanel({ + id: 'id-scratch', + title: 'Scratch Pad', + closable: true, + content: