Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/en_US/query_tool.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
139 changes: 123 additions & 16 deletions web/pgadmin/static/js/helpers/Layout/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -774,7 +880,8 @@ Layout.propTypes = {
resetToTabPanel: PropTypes.string,
enableToolEvents: PropTypes.bool,
isLayoutVisible: PropTypes.bool,
className: PropTypes.string
className: PropTypes.string,
allowAddPanel: PropTypes.bool
};


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1132,6 +1132,7 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN
layoutId="SQLEditor/Layout"
savedLayout={params.layout}
resetToTabPanel={PANELS.MESSAGES}
allowAddPanel
/>
<StatusBar eol={qtState.eol} handleEndOfLineChange={handleEndOfLineChange}/>
</Box>
Expand Down
177 changes: 177 additions & 0 deletions web/regression/javascript/sqleditor/ScratchPadReopen.spec.js
Original file line number Diff line number Diff line change
@@ -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: <textarea />,
}),
],
},
],
},
{
mode: 'horizontal',
children: [
{
tabs: [
LayoutDocker.getPanel({ id: 'id-data-output', title: 'Data Output' }),
LayoutDocker.getPanel({ id: 'id-messages', title: 'Messages' }),
],
},
],
},
],
},
};
const ThemedLayout = withTheme(Layout);

let dockerInstance;
const renderLayout = (props = {}) => {
render(
<ApplicationStateProvider>
<ThemedLayout
defaultLayout={queryToolLayout}
layoutId="SQLEditor/Layout"
resetToTabPanel="id-messages"
getLayoutInstance={(obj) => {
dockerInstance = obj;
}}
{...props}
/>
</ApplicationStateProvider>
);
};

const reopenFromMenu = async (rightClickText) => {
fireEvent.contextMenu(screen.getByText(rightClickText));
fireEvent.click(await screen.findByText('Add Panel'));
const item = await screen.findByText('Scratch Pad', { selector: '[role="menuitem"]' });
act(() => {
fireEvent.click(item);
});
};

beforeEach(() => {
getApiInstance.mockReturnValue({
post: jest.fn().mockResolvedValue({ data: {} }),
get: jest.fn().mockResolvedValue({ data: {} }),
});
});

describe('findDefaultDockPosition', () => {
it('docks Scratch Pad to the right of the panel holding the open Query tab', () => {
renderLayout();
act(() => {
dockerInstance.close('id-scratch');
});
expect(dockerInstance.findDefaultDockPosition('id-scratch')).toEqual({
refTabId: 'id-query',
direction: 'right',
});
});

it('uses the middle of the group when an open sibling shares the tab group', () => {
renderLayout();
act(() => {
dockerInstance.close('id-scratch');
});
expect(dockerInstance.findSiblingTab('id-history', (id) => dockerInstance.isTabOpen(id)))
.toBe('id-query');
});

it('returns null when no related tab is open', () => {
renderLayout();
expect(dockerInstance.findDefaultDockPosition('unknown-tab')).toBeNull();
});
});

describe('Add Panel menu', () => {
it('is not shown unless allowAddPanel is set', async () => {
renderLayout();
act(() => {
dockerInstance.close('id-scratch');
});
fireEvent.contextMenu(screen.getByText('Query'));
await screen.findByText('Close All');
expect(screen.queryByText('Add Panel')).toBeNull();
});

it('reopens the Scratch Pad in its own pane to the right of Query, keeping size and maximizable', async () => {
renderLayout({ allowAddPanel: true });
act(() => {
dockerInstance.close('id-scratch');
});
expect(dockerInstance.isTabOpen('id-scratch')).toBe(false);

await reopenFromMenu('Query');

expect(dockerInstance.isTabOpen('id-scratch')).toBe(true);
const queryPanel = dockerInstance.find('id-query').parent;
const scratchPanel = dockerInstance.find('id-scratch').parent;
// Query group is untouched, Scratch Pad is not merged into it
expect(queryPanel.tabs.map((t) => t.id)).toEqual(['id-query', 'id-history']);
expect(scratchPanel.tabs.map((t) => t.id)).toEqual(['id-scratch']);
// Both panes sit side by side, with the Scratch Pad on the right
expect(scratchPanel.parent).toBe(queryPanel.parent);
const siblings = scratchPanel.parent.children;
expect(siblings.indexOf(scratchPanel)).toBeGreaterThan(siblings.indexOf(queryPanel));
expect(scratchPanel.maximizable).toBe(true);
});

it('falls back to the right-clicked tab group when no related tab is open', async () => {
renderLayout({ allowAddPanel: true });
await act(async () => {
dockerInstance.close('id-scratch');
});
const posSpy = jest.spyOn(dockerInstance, 'findDefaultDockPosition').mockReturnValue(null);
act(() => {
dockerInstance.reopenDefaultTab('id-scratch', 'id-messages');
});
posSpy.mockRestore();
expect(dockerInstance.find('id-scratch').parent.tabs.map((t) => t.id))
.toEqual(['id-data-output', 'id-messages', 'id-scratch']);
});

it('does nothing for a tab that is already open', () => {
renderLayout({ allowAddPanel: true });
const dockMoveSpy = jest.spyOn(dockerInstance.layoutObj, 'dockMove');
dockerInstance.reopenDefaultTab('id-scratch', 'id-messages');
expect(dockMoveSpy).not.toHaveBeenCalled();
dockMoveSpy.mockRestore();
});
});
});