This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch feat/sqllab-extension-contribution-points
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 85920f701a895023c5cb59f529a709510f7453c2
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Tue May 12 22:23:52 2026 -0700

    feat(sqllab,extensions): contribution surfaces for tab/pane extensions
    
    This builds on top of the storage API to let extensions contribute 
first-class SQL Lab experiences — replacing the default editor split with their 
own pane, and adding their own tab types to the new-tab dropdown.
    
    Changes:
    
    - Add two view locations to SqlLab/contributions.ts:
      - sqllab.northPane — full-pane replacement for the default 
editor+SouthPane split
      - sqllab.newTab — tab types listed in the '+' new-tab dropdown
    - Expose PENDING_NORTH_PANE_VIEW_KEY: extensions set this localStorage key 
before calling sqlLab.createTab() to declare which northPane view the new tab 
opens with. SqlEditor consumes and removes the key on init, then persists the 
choice per-tab so the mode survives reloads.
    - Add onViewsChange() subscription to core/views: components re-render when 
an extension registers a view asynchronously after first paint.
    - Expose Tab.backendId on the public superset-core Tab interface so 
extensions can correlate UI tabs with their tabstateview row.
    - TabbedSqlEditors: the '+' button becomes a Dropdown when extensions 
contribute newTab items, listing 'SQL Editor' (built-in) plus any contributed 
tab types.
    - ExtensionsStartup: surface extension load errors as warning toasts 
instead of only logging — a silent extension load failure was hard to notice.
    
    Co-Authored-By: Claude Opus 4.7 <[email protected]>
---
 .../packages/superset-core/src/sqlLab/index.ts     |   7 ++
 .../src/SqlLab/components/SqlEditor/index.tsx      |  79 +++++++++++++++
 .../SqlLab/components/TabbedSqlEditors/index.tsx   | 107 +++++++++++++++++----
 superset-frontend/src/SqlLab/contributions.ts      |  22 +++++
 superset-frontend/src/core/views/index.ts          |  22 +++++
 .../src/extensions/ExtensionsStartup.tsx           |   8 +-
 6 files changed, 223 insertions(+), 22 deletions(-)

diff --git a/superset-frontend/packages/superset-core/src/sqlLab/index.ts 
b/superset-frontend/packages/superset-core/src/sqlLab/index.ts
index 844c25f9ea6..81ed0aff723 100644
--- a/superset-frontend/packages/superset-core/src/sqlLab/index.ts
+++ b/superset-frontend/packages/superset-core/src/sqlLab/index.ts
@@ -62,6 +62,13 @@ export interface Tab {
    */
   id: string;
 
+  /**
+   * The stable backend-assigned ID for this tab (the tabstateview integer 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/index.tsx 
b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx
index d7ce0e56296..4c29ca5a050 100644
--- a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx
+++ b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx
@@ -120,6 +120,15 @@ import KeyboardShortcutButton, {
   KeyboardShortcut,
 } from '../KeyboardShortcutButton';
 import SqlEditorTopBar from '../SqlEditorTopBar';
+import {
+  ViewLocations,
+  PENDING_NORTH_PANE_VIEW_KEY,
+} from 'src/SqlLab/contributions';
+import { views } from 'src/core';
+import { resolveView, onViewsChange } from 'src/core/views';
+
+/** Per-tab localStorage key storing the active northPane view ID. */
+const NORTH_PANE_VIEW_KEY = (tabId: string) => `sqllab.northPaneView.${tabId}`;
 
 const bootstrapData = getBootstrapData();
 const scheduledQueriesConf = bootstrapData?.common?.conf?.SCHEDULED_QUERIES;
@@ -270,6 +279,52 @@ const SqlEditor: FC<Props> = ({
 
   const logAction = useLogAction({ queryEditorId: queryEditor.id });
   const isActive = currentQueryEditorId === queryEditor.id;
+
+  const [northPaneViews, setNorthPaneViews] = useState(
+    () => views.getViews(ViewLocations.sqllab.northPane) || [],
+  );
+
+  useEffect(() => {
+    const unsubscribe = onViewsChange(ViewLocations.sqllab.northPane, () => {
+      setNorthPaneViews(views.getViews(ViewLocations.sqllab.northPane) || []);
+    });
+    return unsubscribe;
+  }, []);
+
+  // 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<string | null>(() => {
+    const pendingViewId = localStorage.getItem(PENDING_NORTH_PANE_VIEW_KEY);
+    if (pendingViewId) {
+      localStorage.removeItem(PENDING_NORTH_PANE_VIEW_KEY);
+      localStorage.setItem(NORTH_PANE_VIEW_KEY(queryEditor.id), pendingViewId);
+      return pendingViewId;
+    }
+    return localStorage.getItem(NORTH_PANE_VIEW_KEY(queryEditor.id));
+  });
+
+  useEffect(() => {
+    const persistKey = NORTH_PANE_VIEW_KEY(
+      queryEditor.tabViewId ?? queryEditor.id,
+    );
+    if (northPaneViewId) {
+      localStorage.setItem(persistKey, northPaneViewId);
+    } else {
+      localStorage.removeItem(persistKey);
+    }
+  }, [queryEditor.tabViewId, queryEditor.id, northPaneViewId]);
+
+  useEffect(() => {
+    const handler = (e: StorageEvent) => {
+      if (e.key === NORTH_PANE_VIEW_KEY(queryEditor.id)) {
+        setNorthPaneViewId(e.newValue || null);
+      }
+    };
+    window.addEventListener('storage', handler);
+    return () => window.removeEventListener('storage', handler);
+  }, [queryEditor.id]);
+
   const [autorun, setAutorun] = useState(queryEditor.autorun);
   const [ctas, setCtas] = useState('');
   const [northPercent, setNorthPercent] = useState(
@@ -1042,6 +1097,30 @@ const SqlEditor: FC<Props> = ({
             'Choose one of the available databases from the panel on the 
left.',
           )}
         />
+      ) : northPaneViewId &&
+        northPaneViews.some(v => v.id === northPaneViewId) ? (
+        <div
+          css={css`
+            height: 100%;
+            display: flex;
+            flex-direction: column;
+          `}
+        >
+          <SqlEditorTopBar
+            queryEditorId={queryEditor.id}
+            defaultPrimaryActions={null}
+            defaultSecondaryActions={[]}
+          />
+          <div
+            css={css`
+              flex: 1;
+              overflow: auto;
+              padding: 0 ${theme.sizeUnit * 4}px;
+            `}
+          >
+            {resolveView(northPaneViewId)}
+          </div>
+        </div>
       ) : (
         queryPane()
       )}
diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx 
b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx
index f03f399583c..190e2c05822 100644
--- a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx
+++ b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { PureComponent } from 'react';
+import { PureComponent, useState, useMemo } from 'react';
 import { EditableTabs } from '@superset-ui/core/components/Tabs';
 import { connect } from 'react-redux';
 import type { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
@@ -24,10 +24,13 @@ import { t } from '@apache-superset/core/translation';
 import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
 import { styled, css } from '@apache-superset/core/theme';
 import { Logger } from 'src/logger/LogUtils';
-import { EmptyState, Tooltip } from '@superset-ui/core/components';
+import { Dropdown, EmptyState, Tooltip } from '@superset-ui/core/components';
+import { MenuItemType } from '@superset-ui/core/components/Menu';
 import { detectOS } from 'src/utils/common';
 import * as Actions from 'src/SqlLab/actions/sqlLab';
 import { Icons } from '@superset-ui/core/components/Icons';
+import { menus, commands } from 'src/core';
+import { ViewLocations } from 'src/SqlLab/contributions';
 import SqlEditor from '../SqlEditor';
 import SqlEditorTabHeader from '../SqlEditorTabHeader';
 
@@ -92,6 +95,86 @@ const TabTitle = styled.span`
 // Get the user's OS
 const userOS = detectOS();
 
+const newTabTooltip =
+  userOS === 'Windows' ? t('New tab (Ctrl + q)') : t('New tab (Ctrl + t)');
+
+const PlusIcon = (
+  <Icons.PlusOutlined
+    iconSize="l"
+    css={css`
+      vertical-align: middle;
+    `}
+    data-test="add-tab-icon"
+  />
+);
+
+function NewTabButton({ onAddSqlEditor }: { onAddSqlEditor: () => void }) {
+  const [open, setOpen] = useState(false);
+
+  const dropdownItems = useMemo<MenuItemType[]>(() => {
+    if (!open) return [];
+    const primaryItems =
+      menus.getMenu(ViewLocations.sqllab.newTab)?.primary ?? [];
+    return [
+      {
+        key: 'sql-editor',
+        label: t('SQL Editor'),
+        icon: <Icons.TableOutlined iconSize="m" />,
+        onClick: () => {
+          setOpen(false);
+          onAddSqlEditor();
+        },
+      },
+      ...primaryItems.map(item => {
+        const command = commands.getCommand(item.command);
+        const Icon = command?.icon
+          ? ((Icons as Record<string, typeof Icons.FileOutlined>)[
+              command.icon
+            ] ?? Icons.FileOutlined)
+          : Icons.FileOutlined;
+        return {
+          key: command?.id ?? item.command,
+          label: command?.title ?? item.command,
+          icon: <Icon iconSize="m" />,
+          onClick: () => {
+            setOpen(false);
+            commands.executeCommand(item.command);
+          },
+        } as MenuItemType;
+      }),
+    ];
+  }, [open, onAddSqlEditor]);
+
+  const handleClick = (e: React.MouseEvent) => {
+    // Antd's Tabs wraps addIcon in its own <button onClick={() => 
onEdit('add')}>.
+    // Stop propagation so antd doesn't also call newQueryEditor() while we 
handle it.
+    e.stopPropagation();
+    const primaryItems =
+      menus.getMenu(ViewLocations.sqllab.newTab)?.primary ?? [];
+    if (primaryItems.length === 0) {
+      onAddSqlEditor();
+    } else {
+      setOpen(prev => !prev);
+    }
+  };
+
+  return (
+    <Tooltip id="add-tab" placement="left" title={newTabTooltip}>
+      <Dropdown
+        open={open}
+        onOpenChange={setOpen}
+        menu={{ items: dropdownItems }}
+        trigger={[]}
+      >
+        {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
+        <span role="button" tabIndex={0} onClick={handleClick}>
+          {PlusIcon}
+        </span>
+      </Dropdown>
+    </Tooltip>
+  );
+}
+
 type TabbedSqlEditorsProps = ReturnType<typeof mergeProps>;
 
 class TabbedSqlEditors extends PureComponent<TabbedSqlEditorsProps> {
@@ -231,25 +314,7 @@ class TabbedSqlEditors extends 
PureComponent<TabbedSqlEditorsProps> {
         onTabClick={this.onTabClicked}
         onEdit={this.handleEdit}
         type={this.props.queryEditors?.length === 0 ? 'card' : 'editable-card'}
-        addIcon={
-          <Tooltip
-            id="add-tab"
-            placement="left"
-            title={
-              userOS === 'Windows'
-                ? t('New tab (Ctrl + q)')
-                : t('New tab (Ctrl + t)')
-            }
-          >
-            <Icons.PlusOutlined
-              iconSize="l"
-              css={css`
-                vertical-align: middle;
-              `}
-              data-test="add-tab-icon"
-            />
-          </Tooltip>
-        }
+        addIcon={<NewTabButton onAddSqlEditor={() => this.newQueryEditor()} />}
         items={tabItems}
       />
     );
diff --git a/superset-frontend/src/SqlLab/contributions.ts 
b/superset-frontend/src/SqlLab/contributions.ts
index 2d25d4425d4..7fad758bfe8 100644
--- a/superset-frontend/src/SqlLab/contributions.ts
+++ b/superset-frontend/src/SqlLab/contributions.ts
@@ -46,5 +46,27 @@ export const ViewLocations = {
     statusBar: 'sqllab.statusBar',
     results: 'sqllab.results',
     queryHistory: 'sqllab.queryHistory',
+    // Extensions can register a full-pane replacement here. SqlEditor renders
+    // the registered view instead of the default editor+SouthPane split when
+    // a tab was opened in that mode.
+    northPane: 'sqllab.northPane',
+    // Extensions register tab-type commands here. When any are present the
+    // "+" new-tab button becomes a dropdown listing all registered tab types
+    // plus the built-in SQL Editor option.
+    newTab: 'sqllab.newTab',
   },
 } as const;
+
+/**
+ * localStorage key an extension sets before calling createTab() to declare
+ * which northPane view the new tab should open with.  The value must be the
+ * view ID passed to views.registerView() (e.g. "my-ext.northPane").  SqlEditor
+ * consumes and removes this key during initialization, then persists the 
chosen
+ * view ID under a per-tab key so the mode survives page reloads.
+ *
+ * @example
+ * // In an extension's newTab command handler:
+ * localStorage.setItem(PENDING_NORTH_PANE_VIEW_KEY, 'my-ext.northPane');
+ * sqlLab.createTab({ title: 'My View' });
+ */
+export const PENDING_NORTH_PANE_VIEW_KEY = 'sqllab.pendingNorthPaneView';
diff --git a/superset-frontend/src/core/views/index.ts 
b/superset-frontend/src/core/views/index.ts
index 5bed7d10910..fc7d63e8bbd 100644
--- a/superset-frontend/src/core/views/index.ts
+++ b/superset-frontend/src/core/views/index.ts
@@ -39,6 +39,24 @@ const viewRegistry: Map<
 
 const locationIndex: Map<string, Set<string>> = new Map();
 
+// Subscribers notified when views at a specific location change
+const locationListeners: Map<string, Set<() => void>> = new Map();
+
+/**
+ * Subscribe to view registrations at a given location.
+ * Returns an unsubscribe function. Useful for components that need to
+ * re-render when an extension registers a view after async load.
+ */
+export const onViewsChange = (
+  location: string,
+  cb: () => void,
+): (() => void) => {
+  const listeners = locationListeners.get(location) ?? new Set();
+  listeners.add(cb);
+  locationListeners.set(location, listeners);
+  return () => listeners.delete(cb);
+};
+
 const registerView: typeof viewsApi.registerView = (
   view: View,
   location: string,
@@ -52,9 +70,13 @@ const registerView: typeof viewsApi.registerView = (
   ids.add(id);
   locationIndex.set(location, ids);
 
+  // Notify any React components waiting on this location
+  locationListeners.get(location)?.forEach(cb => cb());
+
   return new Disposable(() => {
     viewRegistry.delete(id);
     locationIndex.get(location)?.delete(id);
+    locationListeners.get(location)?.forEach(cb => cb());
   });
 };
 
diff --git a/superset-frontend/src/extensions/ExtensionsStartup.tsx 
b/superset-frontend/src/extensions/ExtensionsStartup.tsx
index 6015800525c..5751db6072f 100644
--- a/superset-frontend/src/extensions/ExtensionsStartup.tsx
+++ b/superset-frontend/src/extensions/ExtensionsStartup.tsx
@@ -20,6 +20,7 @@ import { useEffect, useState } from 'react';
 // eslint-disable-next-line no-restricted-syntax
 import * as supersetCore from '@apache-superset/core';
 import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
 import {
   authentication,
   core,
@@ -30,8 +31,9 @@ import {
   sqlLab,
   views,
 } from 'src/core';
-import { useSelector } from 'react-redux';
+import { useSelector, useDispatch } from 'react-redux';
 import { RootState } from 'src/views/store';
+import { addWarningToast } from 'src/components/MessageToasts/actions';
 import ExtensionsLoader from './ExtensionsLoader';
 import './types';
 
@@ -39,6 +41,7 @@ const ExtensionsStartup: React.FC<{ children?: 
React.ReactNode }> = ({
   children,
 }) => {
   const [initialized, setInitialized] = useState(false);
+  const dispatch = useDispatch();
 
   const userId = useSelector<RootState, number | undefined>(
     ({ user }) => user.userId,
@@ -78,6 +81,9 @@ const ExtensionsStartup: React.FC<{ children?: 
React.ReactNode }> = ({
             'Error setting up extensions:',
             error,
           );
+          dispatch(
+            addWarningToast(t('Extensions failed to load: %s', String(error))),
+          );
         }
       }
       setInitialized(true);

Reply via email to