From 88bc7766f219f52292d42999c53f4f7799e5e762 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Tue, 12 May 2026 22:23:52 -0700 Subject: [PATCH 1/6] feat(sqllab,extensions): contribution surfaces for tab/pane extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets 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/removes the key on init, then persists the choice per-tab so the mode survives reloads. - 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 contributed tab types. - ExtensionsStartup: surface extension load errors as warning toasts instead of only logging. Rebased onto current apache/master (dropping the unmerged storage-tiers stack this was originally branched on). Adapted to master's evolution: SqlEditor now consumes master's reactive useViews() hook (#40915) instead of a custom onViewsChange() subscription, so the northPane view appears when an extension registers it asynchronously — without blocking initial render. ExtensionsStartup keeps master's non-blocking async load and adds the error-toast surfacing. Co-Authored-By: Amin Ghadersohi Co-Authored-By: Claude Opus 4.7 Co-Authored-By: Claude Fable 5 --- .../superset-core/src/sqlLab/index.ts | 7 ++ .../src/SqlLab/components/SqlEditor/index.tsx | 70 +++++++++++++ .../components/TabbedSqlEditors/index.tsx | 99 +++++++++++++++---- superset-frontend/src/SqlLab/contributions.ts | 22 +++++ .../src/extensions/ExtensionsStartup.tsx | 26 ++++- 5 files changed, 205 insertions(+), 19 deletions(-) diff --git a/superset-frontend/packages/superset-core/src/sqlLab/index.ts b/superset-frontend/packages/superset-core/src/sqlLab/index.ts index 844c25f9ea63..81ed0aff7233 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 c07c01223f5f..8dd3ab134d7a 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx @@ -121,6 +121,14 @@ 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}`; const bootstrapData = getBootstrapData(); const scheduledQueriesConf = bootstrapData?.common?.conf?.SCHEDULED_QUERIES; @@ -271,6 +279,44 @@ 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) || []; + + // 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 = 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( @@ -1046,6 +1092,30 @@ const SqlEditor: FC = ({ 'Choose one of the available databases from the panel on the left.', )} /> + ) : northPaneViewId && + northPaneViews.some(v => v.id === northPaneViewId) ? ( +
+ +
+ {resolveView(northPaneViewId)} +
+
) : ( queryPane() )} diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx index 445704c1bc1c..38377fe14f4a 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,11 +24,14 @@ import { t } from '@apache-superset/core/translation'; import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; import { styled } 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 { ErrorBoundary } from 'src/components/ErrorBoundary'; 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'; @@ -98,6 +101,82 @@ const AddTabIconWrapper = 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 = ( + + + +); + +function NewTabButton({ onAddSqlEditor }: { onAddSqlEditor: () => void }) { + const [open, setOpen] = useState(false); + + const dropdownItems = useMemo(() => { + if (!open) return []; + const primaryItems = + menus.getMenu(ViewLocations.sqllab.newTab)?.primary ?? []; + return [ + { + key: 'sql-editor', + label: t('SQL Editor'), + icon: , + onClick: () => { + setOpen(false); + onAddSqlEditor(); + }, + }, + ...primaryItems.map(item => { + const command = commands.getCommand(item.command); + const Icon = command?.icon + ? ((Icons as Record)[ + command.icon + ] ?? Icons.FileOutlined) + : Icons.FileOutlined; + return { + key: command?.id ?? item.command, + label: command?.title ?? item.command, + icon: , + onClick: () => { + setOpen(false); + commands.executeCommand(item.command); + }, + } as MenuItemType; + }), + ]; + }, [open, onAddSqlEditor]); + + const handleClick = (e: React.MouseEvent) => { + // Antd's Tabs wraps addIcon in its own