justinpark opened a new issue, #44353:
URL: https://github.com/apache/superset/issues/44353

   ## [SIP] Proposal for SQL Lab LeftBar Extension Panel
   
   SQL Lab's left sidebar was a single hardcoded panel — a 
database/catalog/schema selector plus a table explorer tree — with no way for 
an extension to add its own panel there. This SIP turns it into a 
VerticalMenuLayout: a narrow icon rail on the left edge, and a content panel 
beside it that shows whichever item is active. Rather than a bespoke, 
SQL-Lab-only registration function, the rail is built on a generic primitive 
added to the views module: a view container — a named rail slot (icon, id, 
order) with no content of its own — plus the existing views.registerView, whose 
location can now target either a built-in location or a registered container's 
id. This mirrors VS Code's viewsContainers + views split, so the design isn't 
specific to SQL Lab's left bar; any future rail-shaped surface can reuse the 
same two functions instead of growing its own trigger/panel API.
   
   ### Motivation
   
   ### Background
   
   * **Current limitation:** the `sqllab.leftSidebar` view location existed 
before this SIP, but it was wired only for *toolbar menu actions* 
(`registerToolbarAction`/`PanelToolbar`, rendered inside `TableExploreTree` — 
"Collapse all," "Force refresh schema list"). An extension could add a button 
to the existing explorer's toolbar; it could not add its own panel next to it.  
   * **The asymmetry this closes:** `sqllab.rightSidebar` already accepted full 
view contributions through `views.registerView` \+ `useViews` \+ 
`ViewListExtension`. The left sidebar had no equivalent, even though the "\[SQL 
Lab\] Layout extensions architecture" doc specified one — the 
`VerticalMenuLayout` for the left bar.  
   * **Why a rail, not a stack of panels:** per that architecture doc, the left 
bar's contributions are mutually exclusive views selected from an icon strip, 
not simultaneously visible panels — closer to VS Code's activity bar than to a 
dashboard's grid of widgets. That constrains the design throughout: one active 
panel at a time, switching by rail selection.  
   * **Why a generic primitive, not a SQL-Lab-specific one:** an earlier 
revision of this SIP shipped `sqlLab.registerLeftBarView(view, trigger, panel)` 
— a bespoke trigger-plus-panel pair modeled on `chat.registerChat`. 
Implementing it surfaced that "a rail slot plus the views registered into it" 
isn't actually a SQL Lab concept — it's the same container/view split VS Code 
uses everywhere a rail or activity bar appears. Keeping it SQL-Lab-specific 
would have meant re-deriving this exact API the next time some other surface 
needed a rail. This revision moves the primitive into the generic `views` 
module instead.
   
   ### Benefits for extension authors and users
   
   * **A custom icon, not just custom content.** `views.registerView` only ever 
accepted a single component — content, rendered into a location Superset itself 
decides how to present. A view container closes that gap generically: 
`registerViewContainer(location, { id, name, icon, order? })` registers the 
rail slot and its icon, and the container's own `id` then becomes a valid 
`location` for an ordinary `registerView` call. The icon is a real component, 
not a config field, so it can carry a custom design and dynamic state (a badge, 
a spinner, a notification dot) the same way a chat trigger can.  
   * **One API for both the built-ins and extensions.** Explorer and Settings 
are no longer hardcoded into the rail component or special-cased in host code — 
they're registered through the exact same 
`registerViewContainer`/`registerView` pair an extension uses, as an ordinary 
module-level side effect (`builtins.tsx`). The rail component itself 
(`LeftBarRail`) has no notion of SQL Lab, Explorer, or Settings at all; it just 
renders whatever `ViewContainer[]` it's given.  
   * **The built-in explorer stops being special-cased.** Once at least one 
extension container is registered, Explorer becomes one item among equals in 
the rail — reorderable and hideable through the same settings surface as any 
extension view.  
   * **State survives switching.** Moving between rail items and back no longer 
resets whatever the user had open — searched, expanded, scrolled, or (for 
Settings) mid-edited.
   
   ## Items Not Included
   
   * **Concurrent/multi-pane panels.** The rail selects one active panel; this 
is not a docking or split-view system. An extension needing two panels open 
side by side is out of scope.  
   * **Rail overflow for a large number of registered containers.** Today the 
rail simply grows; there's no "…" overflow menu at, say, 10+ items. See 
[Considerations](#considerations).
   
   ## Proposed Change
   
   | ✅ Do | ❌ Don't |
   | :---- | :---- |
   | Register a rail slot via 
`views.registerViewContainer('sqllab.leftSidebar', { id, name, icon, order? 
})`, then contribute its content via `views.registerView({ id, name }, 
containerId, Component)` | Assume `sqllab.leftSidebar` itself accepts a view 
directly — registering a view there with no matching container is rejected 
(warning \+ inert `Disposable`), the same as any other unknown `location` |
   | Use `registerToolbarAction` for a button that acts on the *currently 
visible* panel (e.g. a refresh icon) | Build a whole second panel just to house 
one button |
   | Let Explorer be one interchangeable, reorderable, hideable rail item | 
Special-case Explorer's position or assume it's always first/visible in 
extension code |
   | Read `sqlLab.onDidChangeActiveTab` from inside a panel that needs the 
current query editor's context | Assume a panel gets remounted per SQL tab — 
only the built-in Explorer does |
   
   ## UI Design
   
   | None leftbar extensions attached (as-is) | With leftbar extensions (rail 
menu) |
   | :---- | :---- |
   | ![][image1] | ![][image2] |
   
   When no extensions are attached, the default layout is preserved as-is. When 
one or more left bar extensions are added, a rail menu is introduced to 
accommodate the additional navigation items.
   
   ## Technical Design
   
   ### `views.registerViewContainer` / `views.registerView`
   
   A container is a plain descriptor for the rail slot; the view it hosts is 
registered separately, via the same `registerView` every other location uses — 
there is no SQL-Lab-specific registration function:
   
   ```ts
   interface ViewContainer {
     id: string;
     name: string;
     icon: ComponentType;
     description?: string;
     /** Sort weight, ascending; defaults to 100. Ties break on `id`, so rail
      *  order is identical on every load regardless of which extension's
      *  Module Federation container happens to resolve first. */
     order?: number;
   }
   
   function registerViewContainer(location: string, container: ViewContainer): 
Disposable;
   function getViewContainers(location: string): ViewContainer[];
   
   // Existing API — `location` can now also be a registered container's `id`:
   function registerView(view: View, location: string, component: 
ComponentType): Disposable;
   ```
   
   Registering one is a direct pair of calls from the extension's own module — 
no `extension.json` field, no separate Module Federation expose, no 
special-cased pre-loading step:
   
   ```ts
   import { views } from '@apache-superset/core';
   
   const disposable = Disposable.from(
     views.registerViewContainer('sqllab.leftSidebar', {
       id: 'acme.lineage',
       name: 'Lineage',
       icon: LineageTrigger,
     }),
     views.registerView({ id: 'acme.lineage', name: 'Lineage' }, 
'acme.lineage', LineagePanel),
   );
   ```
   
   There is no `onDidRegisterViewContainer`/`onDidUnregisterViewContainer` 
event pair — a deliberate scope cut, since no consumer needs to react to 
*another* extension's container list changing yet (the existing 
`onDidRegisterView`/`onDidUnregisterView` pair still fires for the view half of 
a registration). Reactivity inside SQL Lab itself goes through host-internal 
hooks (`useViewContainers`, `useManageableLeftBarEntries`, backed by 
`useSyncExternalStore`), not through the public extension surface. Adding 
container events later is additive, not breaking, if a real need shows up.
   
   ### Unknown-location registration is rejected, not silently accepted or 
thrown
   
   ```ts
   const isKnownLocation = (location: string): boolean =>
     STATIC_LOCATIONS.has(location) || containerRegistry.has(location);
   
   const registerView: typeof viewsApi.registerView = (view, location, 
component) => {
     if (!isKnownLocation(location)) {
       logging.warn(
         `[Superset] Cannot register view "${view.id}" at unknown location 
"${location}". ` +
           'Register a view container with this id first, or use one of the ' +
           "host's built-in locations.",
       );
       return new Disposable(() => {});
     }
     // ...existing body
   };
   ```
   
   An earlier revision of this SIP special-cased `sqllab.leftSidebar` with a 
hard `throw`, redirecting callers to a bespoke `registerLeftBarView`. That 
special case is gone: `sqllab.leftSidebar` is now just another location, and 
the check that used to exist only for it — "does this location resolve to 
something the host knows how to render?" — is now the *general* rule for every 
`registerView` call, built-in location or extension container alike. 
`registerViewContainer` has the mirror-image guard: an id colliding with one of 
the host's built-in location names, or with an already-registered container, is 
rejected the same way (warning \+ inert `Disposable`, first registration wins).
   
   ### The registry (`src/core/views/index.ts`)
   
   One module now owns both registries — views and containers — rather than 
splitting the container half into a SQL-Lab-specific file:
   
   * `containerRegistry`/`containerLocationIndex` (containers) sit alongside 
the pre-existing `viewRegistry`/`locationIndex` (views), sharing the same 
`useSyncExternalStore` subscriber set and per-location memoized snapshots.  
   * Containers sort by `(order ?? 100, id.localeCompare)` — deterministic 
regardless of the network-dependent order extensions finish loading in 
(`ExtensionsLoader.initializeExtensions` runs them via `Promise.all`).  
   * A duplicate container `id` logs a warning and **keeps the first 
registration**, returning an inert `Disposable` — the same policy 
`commands.registerCommand` and the view registry itself use, chosen so a 
second, buggy registration can never swap out a panel the user already has 
open.  
   * `getViewContainers()`/`useViewContainers()` return a memoized snapshot, 
invalidated only on actual mutation, and a stable empty-array constant when 
nothing's registered — `useSyncExternalStore` compares by reference, so a fresh 
`[]` on every call would loop.
   
   ### Error isolation
   
   Every container's `icon` renders inside its own `ErrorBoundary` (`RailIcon` 
in `LeftBarRail.tsx`) — a crashing trigger can't take down the rest of the 
strip, and renders nothing on crash rather than an error card, since an icon 
slot has too little room for one. On the content side, 
`views.getViews(viewId)?.length ? <ViewListExtension viewId={viewId} /> : 
<ExtensionPlaceholder id={viewId} />` reuses the existing view-resolution path 
(and its own per-view `ErrorBoundary`, unchanged from before this SIP) rather 
than a bespoke panel host — a crashing panel still can't take down the rail or 
any other view, but that isolation now comes from the generic views machinery 
instead of a left-sidebar-specific one.
   
   ### Built-ins are two more registrations, not special cases (`builtins.tsx`)
   
   Explorer and Settings are registered once, as a module-level side effect, 
through the exact same public API an extension uses:
   
   ```ts
   export const registerBuiltinLeftBarContainers = (): Disposable =>
     Disposable.from(
       views.registerViewContainer(LEFT_SIDEBAR_LOCATION, {
         id: TAB_EXPLORER_ID, name: t('Explorer'), icon: ExplorerIcon, order: 
-1,
       }),
       views.registerView({ id: TAB_EXPLORER_ID, name: t('Explorer') }, 
TAB_EXPLORER_ID, ExplorerContainerView),
       views.registerViewContainer(LEFT_SIDEBAR_LOCATION, {
         id: TAB_SETTINGS_ID, name: t('Settings'), icon: SettingsIcon, order: 
Number.MAX_SAFE_INTEGER,
       }),
       views.registerView({ id: TAB_SETTINGS_ID, name: t('Settings') }, 
TAB_SETTINGS_ID, LeftBarViewSettingsPanel),
     );
   
   registerBuiltinLeftBarContainers();
   ```
   
   Explorer's `order: -1` sorts it before any container relying on the default 
(`100`), so it leads the strip without depending on how its id happens to 
compare against an extension's; Settings' `order: MAX_SAFE_INTEGER` sorts it 
last on its own merits, on top of `useLeftBarTabs` always pinning it last 
regardless of order. Because `LeftBarRail` itself carries no 
Explorer/Settings-specific logic, this registration *is* the only place either 
one's rail presence is defined.
   
   ### The manageable set (`useManageableLeftBarEntries.ts`) and tab assembly 
(`useLeftBarTabs.ts`)
   
   `useManageableLeftBarEntries()` reads 
`useViewContainers(LEFT_SIDEBAR_LOCATION)` and returns `[]` when nothing beyond 
the two built-ins is registered — Explorer alone isn't "manageable"; it only 
joins that set once there's something else registered alongside it, so a SQL 
Lab install with zero left-bar extensions shows no rail chrome at all, 
identical to before this SIP. Once it's non-empty, Explorer is included and is 
reorderable/hideable exactly like any registered container; the built-in 
Settings container is excluded from this set (it's always appended last by 
`useLeftBarTabs`, unconditionally, and can never be hidden), so it stays 
reachable no matter what the user does to everything else.
   
   Order and visibility are user preferences, not extension configuration, 
persisted to `localStorage` (`SqllabLeftbarViewSettings` → `{ order: string[]; 
hidden: string[] }`), global rather than per query-editor tab — the same 
reasoning `ChatProvider` uses for its own `{ open, mode }` persistence: a 
user's arrangement of the sidebar isn't a property of which SQL tab happens to 
be focused.
   
   ### The rail (`LeftBarRail.tsx`) — now a generic container host
   
   An antd `Menu` (`mode="inline"` \+ `inlineCollapsed`), not a hand-rolled 
button strip, for the tooltips/keyboard-nav/selected-state it provides for free 
— unchanged from the previous revision. What changed is what the component 
knows about:
   
   * Its props are exactly `{ items: ViewContainer[]; pinnedItems?: 
ViewContainer[]; activeId: string; onSelect }` — no 
`TAB_EXPLORER_ID`/`TAB_SETTINGS_ID` constants, no built-in-icon fallback, no 
SQL-Lab import at all. Grouping "the main items" from "the pinned-at-bottom 
item" is decided by the caller (`useLeftBarLayout`'s `mainTabs`/`settingsTab` 
split), not by the rail.  
   * Still split into **two separate `Menu`s** inside one container — the 
caller's `items` in one, `pinnedItems` in a second, visually separated at the 
bottom. A single icon can't be "unselected" by clicking it twice in antd's 
model, so re-clicking the active icon is handled explicitly (`toggleContent()`, 
in `useLeftBarLayout`) rather than relying on `Menu`'s own selection semantics. 
 
   * `collapsedWidth` is overridden from antd's default (80px) to 
`SQL_EDITOR_LEFTBAR_COLLAPSED_WIDTH` (56px) via a component-token, through a 
nested `AntdThemeProvider` scoped to just the rail.  
   * The divider between the rail and the content panel is one continuous line 
on the container, not each `Menu`'s own `border-inline-end` — those are 
neutralized by overriding the `colorSplit` **token** (not the CSS property 
directly) inside that same scoped theme, since antd's `Menu` component token 
has no border-color field of its own to target.  
   * Lives as a **sibling of the content `Splitter`**, not nested inside it — 
so it stays mounted and visible regardless of the Splitter panel's own 
collapsed or hidden state.
   
   Because the rail is now generic, reusing it for some future non-SQL-Lab rail 
location is a matter of feeding it a different `ViewContainer[]` — no fork, no 
new component.
   
   ### "Arrange sidebar menu" panel (`LeftBarViewSettingsPanel.tsx`)
   
   Reachable via the Settings item pinned at the bottom of the rail; unchanged 
in behavior from the previous revision, now typed against `ViewContainer` (from 
`useManageableLeftBarEntries`) instead of the removed `LeftBarView`:
   
   * Drag-and-drop reordering (`@dnd-kit`), a checkbox per row for show/hide, 
Apply/Cancel actions. Cancel reverts to the last-*applied* settings, not to the 
defaults.  
   * **Apply is disabled once every checkbox is unchecked** — a user can't lock 
themselves out of the sidebar entirely by hiding everything and applying, since 
Settings itself isn't a row in this list and would otherwise become the only 
way back in.
   
   ### Panel-switch persistence
   
   Every rail view the user has switched to stays **mounted but hidden** 
(`display: none`) rather than being unmounted when it's not the active one — 
adjusted during the render phase itself (React's documented "adjusting state 
during render" pattern) the moment a not-yet-visited id becomes active, not a 
render later via an effect. Switching from Explorer to an extension panel and 
back no longer resets Explorer's expanded tree nodes or search text, and 
switching away from Settings mid-edit and back preserves the unsaved drag order.
   
   The one deliberate exception is the built-in Explorer, which is additionally 
keyed by the **active query editor tab's** id. Its registered view 
(`ExplorerContainerView` in `builtins.tsx`) reads the active query editor from 
Redux itself — since a registered view is rendered with no props — and renders 
`<TabExplorer key={activeQEId} queryEditorId={activeQEId} />`. This is 
orthogonal to the rail-switch persistence above: Explorer shows a given SQL 
tab's own database/catalog/schema selection, so it's expected to reset when 
*that* changes; an extension panel has no such per-tab concept and is never 
keyed this way.
   
   ### Layout states (`AppLayout/index.tsx`)
   
   | State | Panel size | Rail | Content | Reached by |
   | :---- | :---- | :---- | :---- | :---- |
   | Expanded | `leftWidth` (≥400px, resizable) | visible | visible | default, 
or dragging open |
   | Rail-collapsed | 0, rigid (drag disabled) | visible | hidden | clicking 
the active rail icon again |
   | Fully hidden | 0, resizable | visible | hidden | dragging the Splitter 
fully closed, or its own collapsible icon |
   
   Both collapse states pin the panel at zero width, but are tracked by two 
independent flags — the rail's own `contentCollapsed`, and the Splitter's 
`leftWidth` — reconciled in `onSidebarChange`: while rail-collapsed, only a 
*nonzero* reported size (the Splitter's collapsible icon restoring it) is 
honored and clears `contentCollapsed`; a `0` report from an unrelated resize 
(e.g. dragging the right sidebar) is ignored, so it can't clobber the stored 
expanded width. `min` stays at `SQL_EDITOR_LEFTBAR_WIDTH` and the Splitter's 
own collapse chevron stays visible and functional in every state; 
`getLeftPanelLayout` returns just `{ size, min, resizable }`, unchanged from 
the previous revision. `AppLayout` passes `useLeftBarLayout`'s 
`mainTabs`/`settingsTab` split to `LeftBarRail` as `items`/`pinnedItems`, 
respectively.
   
   ### Persisted state
   
   | localStorage key | Shape | Scope |
   | :---- | :---- | :---- |
   | `SqllabLeftbarState` | `{ activeViewId: string; contentCollapsed: boolean 
}` | Global |
   | `SqllabLeftbarViewSettings` | `{ order: string[]; hidden: string[] }` | 
Global |
   
   Both are global, not per query-editor tab, and both resolve staleness by 
pure derivation rather than an effect — a persisted `activeViewId` naming a 
since-unregistered or since-hidden container falls back to whichever tab now 
leads the list; a persisted `contentCollapsed: true` from a session that had 
containers registered is neutralized to `false` if none are registered now.
   
   ### Reference implementation
   
   `~/workspaces/superset-extensions/hello-world` exercises the full path 
end-to-end, loaded through `LOCAL_EXTENSIONS`:
   
   ```
   import { views } from "@apache-superset/core";
   
   const HelloWorldTrigger = () => <span>👋</span>;
   const HelloWorldPanel = () => <p>Hello World</p>;
   
   const HELLO_WORLD_ID = "justin-park.hello-world.example";
   
   views.registerViewContainer("sqllab.leftSidebar", {
     id: HELLO_WORLD_ID,
     name: "Hello World",
     icon: HelloWorldTrigger,
   });
   
   views.registerView(
     { id: HELLO_WORLD_ID, name: "Hello World" },
     HELLO_WORLD_ID,
     HelloWorldPanel,
   );
   ```
   
   ## Considerations
   
   * No public `onDidRegisterViewContainer`/`onDidUnregisterViewContainer` 
events exist yet — see [Design](#viewsregisterviewcontainer--viewsregisterview) 
for why, and add them if a real cross-extension use case appears.  
   * No overflow handling for a large rail. `overflow-y: auto` on the rail is 
the cheap mitigation if this becomes a problem in practice; a VS Code–style "…" 
menu is the fuller fix.  
   * Duplicate container `id`s overwrite nothing — first registration wins, 
with a logged warning — rather than being rejected outright.  
   * `FeatureFlag.EnableExtensions` is checked once, upstream, in 
`ExtensionsStartup` (the only reachable path to registration) — the registry 
itself has no redundant flag check, so it can't silently hide a future 
first-party built-in view that isn't extension-gated.  
   * Accessibility: `aria-expanded` on the active rail item, and an 
announcement for the collapse/expand transition, are not implemented.  
   * **Migration note:** the previous revision's 
`sqlLab.registerLeftBarView`/`getLeftBarViews` and the 
`sqllab.leftSidebar`\-specific `throw` in `registerView` are both gone. There 
is no compatibility shim — this SIP shipped before any third-party extension 
depended on the earlier API, so the break has no external impact; the in-repo 
`hello-world` reference extension is the only caller and was migrated alongside 
this change.
   
   ### Migration Plan and Compatibility
   
   None
   
   ### Rejected Alternatives
   
   N/A


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to