diff --git a/superset-frontend/packages/superset-core/src/sqlLab/index.ts b/superset-frontend/packages/superset-core/src/sqlLab/index.ts
index 844c25f9ea63..cf7f4f076cf7 100644
--- a/superset-frontend/packages/superset-core/src/sqlLab/index.ts
+++ b/superset-frontend/packages/superset-core/src/sqlLab/index.ts
@@ -62,6 +62,14 @@ export interface Tab {
*/
id: string;
+ /**
+ * The stable backend-assigned identifier for this tab. Exposed as an opaque
+ * string so the public extension API does not leak the backend's internal
+ * numeric tab id. Set once the tab has been persisted to the backend;
+ * undefined for new tabs before the first backend sync.
+ */
+ backendId?: string;
+
/**
* The display title of the tab.
* This is what users see in the tab header.
diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.tsx b/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.tsx
index 86c4a6b23da8..7e8e8ef9ac87 100644
--- a/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.tsx
+++ b/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.tsx
@@ -41,6 +41,8 @@ import {
import ResultSet from 'src/SqlLab/components/ResultSet';
import { api } from 'src/hooks/apiResources/queryApi';
import setupCodeOverrides from 'src/setup/setupCodeOverrides';
+import { views } from 'src/core';
+import { ViewLocations } from 'src/SqlLab/contributions';
import type { Action, Middleware, Store } from 'redux';
import SqlEditor, { Props } from '.';
@@ -348,6 +350,29 @@ describe('SqlEditor', () => {
).toBeInTheDocument();
});
+ test('renders a registered northPane view in place of the editor', async () => {
+ const { queryEditor } = mockedProps;
+ // The fixture has no tabViewId, so the component falls back to the id;
+ // mirror that here to derive the same persistence key.
+ const storageKey = `sqllab.northPaneView.${queryEditor.id}`;
+ localStorage.setItem(storageKey, 'test.northPane');
+ const disposable = views.registerView(
+ { id: 'test.northPane', name: 'Test North Pane' },
+ ViewLocations.sqllab.northPane,
+ () =>
NorthPane content
,
+ );
+
+ try {
+ const { findByTestId, queryByTestId } = setup(mockedProps, store);
+ expect(await findByTestId('np-view')).toBeInTheDocument();
+ // The default SQL editor pane is replaced, not rendered alongside.
+ expect(queryByTestId('react-ace')).not.toBeInTheDocument();
+ } finally {
+ disposable.dispose();
+ localStorage.removeItem(storageKey);
+ }
+ });
+
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('with EstimateQueryCost enabled', () => {
beforeEach(() => {
diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx
index c07c01223f5f..d502ea192b3a 100644
--- a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx
+++ b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx
@@ -121,6 +121,37 @@ import KeyboardShortcutButton, {
KeyboardShortcut,
} from '../KeyboardShortcutButton';
import SqlEditorTopBar from '../SqlEditorTopBar';
+import {
+ ViewLocations,
+ PENDING_NORTH_PANE_VIEW_KEY,
+} from 'src/SqlLab/contributions';
+import { resolveView, useViews } from 'src/core/views';
+
+/** Per-tab localStorage key storing the active northPane view ID. */
+const NORTH_PANE_VIEW_KEY = (tabId: string) => `sqllab.northPaneView.${tabId}`;
+
+// The northPane keys are dynamic per-tab strings rather than members of the
+// typed LocalStorageKeys enum, so the typed helpers don't apply. Guard the raw
+// access here so a storage-restricted browser can't crash the editor mount.
+const readNorthPaneStorage = (key: string): string | null => {
+ try {
+ return localStorage.getItem(key);
+ } catch {
+ return null;
+ }
+};
+
+const writeNorthPaneStorage = (key: string, value: string | null): void => {
+ try {
+ if (value === null) {
+ localStorage.removeItem(key);
+ } else {
+ localStorage.setItem(key, value);
+ }
+ } catch {
+ // localStorage may be unavailable (blocked/quota/private mode); ignore.
+ }
+};
const bootstrapData = getBootstrapData();
const scheduledQueriesConf = bootstrapData?.common?.conf?.SCHEDULED_QUERIES;
@@ -271,6 +302,48 @@ const SqlEditor: FC = ({
const logAction = useLogAction({ queryEditorId: queryEditor.id });
const isActive = currentQueryEditorId === queryEditor.id;
+
+ // Re-renders when an extension registers a northPane view after async load.
+ const northPaneViews = useViews(ViewLocations.sqllab.northPane) || [];
+
+ // Resolve the per-tab localStorage key the same way every other SQL Lab
+ // consumer does (`tabViewId ?? id`), so the value written, read back, and
+ // observed via the `storage` event all agree once a tab is backend-persisted.
+ const northPaneStorageId = queryEditor.tabViewId ?? queryEditor.id;
+
+ // ID of the northPane view active for this tab, or null for the default
+ // SQL editor layout. Set by an extension via PENDING_NORTH_PANE_VIEW_KEY
+ // before calling createTab(); persisted per-tab in localStorage.
+ const [northPaneViewId, setNorthPaneViewId] = useState(() => {
+ const pendingViewId = readNorthPaneStorage(PENDING_NORTH_PANE_VIEW_KEY);
+ if (pendingViewId) {
+ writeNorthPaneStorage(PENDING_NORTH_PANE_VIEW_KEY, null);
+ writeNorthPaneStorage(
+ NORTH_PANE_VIEW_KEY(northPaneStorageId),
+ pendingViewId,
+ );
+ return pendingViewId;
+ }
+ return readNorthPaneStorage(NORTH_PANE_VIEW_KEY(northPaneStorageId));
+ });
+
+ useEffect(() => {
+ writeNorthPaneStorage(
+ NORTH_PANE_VIEW_KEY(northPaneStorageId),
+ northPaneViewId,
+ );
+ }, [northPaneStorageId, northPaneViewId]);
+
+ useEffect(() => {
+ const handler = (e: StorageEvent) => {
+ if (e.key === NORTH_PANE_VIEW_KEY(northPaneStorageId)) {
+ setNorthPaneViewId(e.newValue || null);
+ }
+ };
+ window.addEventListener('storage', handler);
+ return () => window.removeEventListener('storage', handler);
+ }, [northPaneStorageId]);
+
const [autorun, setAutorun] = useState(queryEditor.autorun);
const [ctas, setCtas] = useState('');
const [northPercent, setNorthPercent] = useState(
@@ -1046,6 +1119,30 @@ const SqlEditor: FC = ({
'Choose one of the available databases from the panel on the left.',
)}
/>
+ ) : northPaneViewId &&
+ northPaneViews.some(v => v.id === northPaneViewId) ? (
+