diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 88d8e394878..2d9e5373828 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -1,9 +1,6 @@ import type { PaneRegistry, RendererContext } from "@superset/panes"; import { FileCode2, Globe, MessageSquare, TerminalSquare } from "lucide-react"; import { useMemo } from "react"; -import { ChatPane } from "./components/ChatPane"; -import { WorkspaceFilePreview } from "./components/FilesPane/components/WorkspaceFilePreview/WorkspaceFilePreview"; -import { TerminalPane } from "./components/TerminalPane"; import type { BrowserPaneData, ChatPaneData, @@ -11,6 +8,9 @@ import type { FilePaneData, PaneViewerData, } from "../../types"; +import { ChatPane } from "./components/ChatPane"; +import { WorkspaceFilePreview } from "./components/FilesPane/components/WorkspaceFilePreview/WorkspaceFilePreview"; +import { TerminalPane } from "./components/TerminalPane"; function getFileTitle(filePath: string): string { return filePath.split("/").pop() ?? filePath; @@ -40,9 +40,7 @@ export function usePaneRegistry( terminal: { getIcon: () => , getTitle: () => "Terminal", - renderPane: () => ( - - ), + renderPane: () => , }, browser: { getIcon: () => , diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspacePaneLayout/useV2WorkspacePaneLayout.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspacePaneLayout/useV2WorkspacePaneLayout.ts index 6e4debee6bb..088c332ccda 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspacePaneLayout/useV2WorkspacePaneLayout.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspacePaneLayout/useV2WorkspacePaneLayout.ts @@ -1,7 +1,4 @@ -import { - createWorkspaceStore, - type WorkspaceState, -} from "@superset/panes"; +import { createWorkspaceStore, type WorkspaceState } from "@superset/panes"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { useEffect, useMemo, useRef, useState } from "react"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index bc7dddc3bb4..fedbe7a2b74 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -1,10 +1,11 @@ -import { Workspace, type PaneActionConfig } from "@superset/panes"; +import { type PaneActionConfig, Workspace } from "@superset/panes"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { HiMiniXMark } from "react-icons/hi2"; import { TbLayoutColumns, TbLayoutRows } from "react-icons/tb"; +import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { @@ -12,7 +13,6 @@ import { useCommandPalette, } from "renderer/screens/main/components/CommandPalette"; import { PresetsBar } from "renderer/screens/main/components/WorkspaceView/ContentView/components/PresetsBar"; -import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; import { useAppHotkey } from "renderer/stores/hotkeys"; import { AddTabMenu } from "./components/AddTabMenu"; import { WorkspaceEmptyState } from "./components/WorkspaceEmptyState"; @@ -79,22 +79,24 @@ function WorkspaceContent({ const utils = electronTrpc.useUtils(); const { data: showPresetsBar, isLoading: isLoadingPresetsBar } = electronTrpc.settings.getShowPresetsBar.useQuery(); - const setShowPresetsBar = electronTrpc.settings.setShowPresetsBar.useMutation({ - onMutate: async ({ enabled }) => { - await utils.settings.getShowPresetsBar.cancel(); - const previous = utils.settings.getShowPresetsBar.getData(); - utils.settings.getShowPresetsBar.setData(undefined, enabled); - return { previous }; - }, - onError: (_error, _variables, context) => { - if (context?.previous !== undefined) { - utils.settings.getShowPresetsBar.setData(undefined, context.previous); - } - }, - onSettled: () => { - utils.settings.getShowPresetsBar.invalidate(); + const setShowPresetsBar = electronTrpc.settings.setShowPresetsBar.useMutation( + { + onMutate: async ({ enabled }) => { + await utils.settings.getShowPresetsBar.cancel(); + const previous = utils.settings.getShowPresetsBar.getData(); + utils.settings.getShowPresetsBar.setData(undefined, enabled); + return { previous }; + }, + onError: (_error, _variables, context) => { + if (context?.previous !== undefined) { + utils.settings.getShowPresetsBar.setData(undefined, context.previous); + } + }, + onSettled: () => { + utils.settings.getShowPresetsBar.invalidate(); + }, }, - }); + ); const openFilePane = useCallback( (filePath: string) => { @@ -206,10 +208,7 @@ function WorkspaceContent({ key: "close", icon: , tooltip: ( - + ), onClick: (ctx) => ctx.actions.close(), }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts index cf55cd37fe7..1ab72fb083a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts @@ -62,7 +62,11 @@ function ensureSidebarWorkspaceRecord( tabOrder: getNextTabOrder(topLevelOrders), sectionId: null, }, - paneLayout: { version: 1, tabs: [], activeTabId: null } satisfies WorkspaceState, + paneLayout: { + version: 1, + tabs: [], + activeTabId: null, + } satisfies WorkspaceState, }); } diff --git a/bun.lock b/bun.lock index f129a709e4c..4d4a05c0bff 100644 --- a/bun.lock +++ b/bun.lock @@ -766,6 +766,8 @@ "dependencies": { "@superset/ui": "workspace:*", "lucide-react": "^0.563.0", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "zustand": "^5.0.8", }, "devDependencies": { diff --git a/packages/panes/package.json b/packages/panes/package.json index 7af0a4daf99..fea2ba390d5 100644 --- a/packages/panes/package.json +++ b/packages/panes/package.json @@ -17,6 +17,8 @@ "dependencies": { "@superset/ui": "workspace:*", "lucide-react": "^0.563.0", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "zustand": "^5.0.8" }, "devDependencies": { diff --git a/packages/panes/src/core/store/store.test.ts b/packages/panes/src/core/store/store.test.ts index 66258b8b39f..a1b60cd4b84 100644 --- a/packages/panes/src/core/store/store.test.ts +++ b/packages/panes/src/core/store/store.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import type { WorkspaceState } from "../../types"; +import type { Tab, WorkspaceState } from "../../types"; import type { CreatePaneInput } from "./store"; import { createWorkspaceStore } from "./store"; @@ -577,6 +577,148 @@ describe("openPane", () => { }); }); +describe("movePaneToSplit", () => { + it("moves a pane within the same tab", () => { + const store = makeStore({ + version: 1, + tabs: [ + { + id: "t1", + createdAt: Date.now(), + activePaneId: "p1", + layout: { + type: "split", + id: "s1", + direction: "horizontal", + children: [ + { type: "pane", paneId: "p1" }, + { type: "pane", paneId: "p2" }, + ], + weights: [1, 1], + }, + panes: { + p1: { id: "p1", kind: "test", data: { label: "p1" } }, + p2: { id: "p2", kind: "test", data: { label: "p2" } }, + }, + }, + ], + activeTabId: "t1", + }); + + store.getState().movePaneToSplit({ + sourcePaneId: "p1", + targetPaneId: "p2", + position: "bottom", + }); + + const tab = store.getState().tabs[0]; + expect(tab).toBeDefined(); + // p1 should now be split below p2 + expect(tab?.panes.p1).toBeDefined(); + expect(tab?.panes.p2).toBeDefined(); + expect(tab?.activePaneId).toBe("p1"); + }); + + it("moves a pane across tabs", () => { + const store = makeStore({ + version: 1, + tabs: [ + { + id: "t1", + createdAt: Date.now(), + activePaneId: "p1", + layout: { + type: "split", + id: "s1", + direction: "horizontal", + children: [ + { type: "pane", paneId: "p1" }, + { type: "pane", paneId: "p2" }, + ], + weights: [1, 1], + }, + panes: { + p1: { id: "p1", kind: "test", data: { label: "p1" } }, + p2: { id: "p2", kind: "test", data: { label: "p2" } }, + }, + }, + { + id: "t2", + createdAt: Date.now(), + activePaneId: "p3", + layout: { type: "pane", paneId: "p3" }, + panes: { + p3: { id: "p3", kind: "test", data: { label: "p3" } }, + }, + }, + ], + activeTabId: "t1", + }); + + store.getState().movePaneToSplit({ + sourcePaneId: "p1", + targetPaneId: "p3", + position: "right", + }); + + // Source tab should have p2 only + const t1 = store.getState().tabs.find((t) => t.id === "t1"); + expect(t1?.panes.p1).toBeUndefined(); + expect(t1?.layout).toEqual({ type: "pane", paneId: "p2" }); + + // Target tab should have p3 + p1 + const t2 = store.getState().tabs.find((t) => t.id === "t2"); + expect(t2?.panes.p1).toBeDefined(); + expect(t2?.panes.p3).toBeDefined(); + expect(t2?.activePaneId).toBe("p1"); + expect(store.getState().activeTabId).toBe("t2"); + }); + + it("removes source tab when last pane is moved out", () => { + const store = makeStore(); + store.getState().addTab({ id: "t1", panes: [tp("p1")] }); + store.getState().addTab({ id: "t2", panes: [tp("p2")] }); + + const tab1 = store.getState().tabs[0] as Tab; + const tab2 = store.getState().tabs[1] as Tab; + const p1Id = Object.keys(tab1.panes)[0] as string; + const p2Id = Object.keys(tab2.panes)[0] as string; + + store.getState().movePaneToSplit({ + sourcePaneId: p1Id, + targetPaneId: p2Id, + position: "right", + }); + + expect(store.getState().tabs).toHaveLength(1); + }); + + it("is a no-op when dropping on self", () => { + const store = makeStore(); + store.getState().addTab({ id: "t1", panes: [tp("p1")] }); + + const tab0 = store.getState().tabs[0] as Tab; + const p1Id = Object.keys(tab0.panes)[0] as string; + const before = structuredClone({ + version: store.getState().version, + tabs: store.getState().tabs, + activeTabId: store.getState().activeTabId, + }); + + store.getState().movePaneToSplit({ + sourcePaneId: p1Id, + targetPaneId: p1Id, + position: "right", + }); + + expect({ + version: store.getState().version, + tabs: store.getState().tabs, + activeTabId: store.getState().activeTabId, + }).toEqual(before); + }); +}); + describe("edge cases", () => { it("invalid IDs are no-ops", () => { const store = makeStore(); diff --git a/packages/panes/src/core/store/store.ts b/packages/panes/src/core/store/store.ts index f128afdf115..c10f542b3fe 100644 --- a/packages/panes/src/core/store/store.ts +++ b/packages/panes/src/core/store/store.ts @@ -139,6 +139,12 @@ export interface WorkspaceStore extends WorkspaceState { }) => void; equalizeSplit: (args: { tabId: string; splitId: string }) => void; + movePaneToSplit: (args: { + sourcePaneId: string; + targetPaneId: string; + position: SplitPosition; + }) => void; + replaceState: ( next: | WorkspaceState @@ -618,6 +624,91 @@ export function createWorkspaceStore( }); }, + movePaneToSplit: (args) => { + set((s) => { + // Find source and target tabs by pane ID + let sourceTab: Tab | undefined; + let sourcePane: Pane | undefined; + let targetTab: Tab | undefined; + for (const t of s.tabs) { + if (t.panes[args.sourcePaneId]) { + sourceTab = t; + sourcePane = t.panes[args.sourcePaneId]; + } + if (t.panes[args.targetPaneId]) { + targetTab = t; + } + } + if (!sourceTab || !sourcePane) return s; + if (!targetTab || !targetTab.layout) return s; + if (!findPaneInLayout(targetTab.layout, args.targetPaneId)) return s; + + // Don't drop on self + if (args.sourcePaneId === args.targetPaneId) return s; + + // Remove from source layout + const nextSourceLayout = removePaneFromLayout( + sourceTab.layout, + args.sourcePaneId, + ); + const { [args.sourcePaneId]: _, ...nextSourcePanes } = sourceTab.panes; + + // Insert into target layout + const nextTargetLayout = splitPaneInLayout( + // If same tab, use the already-modified layout + sourceTab.id === targetTab.id && nextSourceLayout + ? nextSourceLayout + : targetTab.layout, + args.targetPaneId, + sourcePane.id, + args.position, + ); + + const nextTabs = s.tabs + .map((t) => { + if (sourceTab.id === targetTab.id && t.id === sourceTab.id) { + // Same-tab move + if (!nextSourceLayout) return null; // shouldn't happen since we check targetPaneId != sourcePaneId + return { + ...t, + layout: nextTargetLayout, + panes: { ...nextSourcePanes, [sourcePane.id]: sourcePane }, + activePaneId: sourcePane.id, + }; + } + if (t.id === sourceTab.id) { + // Source tab — pane removed + if (!nextSourceLayout) return null; // last pane removed, tab will be filtered + return { + ...t, + layout: nextSourceLayout, + panes: nextSourcePanes, + activePaneId: + t.activePaneId === args.sourcePaneId + ? findFirstPaneId(nextSourceLayout) + : t.activePaneId, + }; + } + if (t.id === targetTab.id) { + // Target tab — pane added + return { + ...t, + layout: nextTargetLayout, + panes: { ...t.panes, [sourcePane.id]: sourcePane }, + activePaneId: sourcePane.id, + }; + } + return t; + }) + .filter((t): t is Tab => t !== null); + + return { + tabs: nextTabs, + activeTabId: targetTab.id, + }; + }); + }, + replaceState: (next) => { set((s) => { const resolved = diff --git a/packages/panes/src/react/components/Workspace/Workspace.tsx b/packages/panes/src/react/components/Workspace/Workspace.tsx index 15a56a38be3..c7ecec5b5c6 100644 --- a/packages/panes/src/react/components/Workspace/Workspace.tsx +++ b/packages/panes/src/react/components/Workspace/Workspace.tsx @@ -1,4 +1,6 @@ import { cn } from "@superset/ui/utils"; +import { DndProvider } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; import { useStore } from "zustand"; import type { WorkspaceProps } from "../../types"; import { Tab } from "./components/Tab"; @@ -29,46 +31,50 @@ export function Workspace({ }; return ( -
- store.getState().setActiveTab(tabId)} - onCloseTab={closeTab} - onCloseOtherTabs={(tabId) => { - for (const tab of tabs) { - if (tab.id !== tabId) closeTab(tab.id); + +
+ store.getState().setActiveTab(tabId)} + onCloseTab={closeTab} + onCloseOtherTabs={(tabId) => { + for (const tab of tabs) { + if (tab.id !== tabId) closeTab(tab.id); + } + }} + onCloseAllTabs={() => { + for (const tab of tabs) { + closeTab(tab.id); + } + }} + onRenameTab={(tabId, title) => + store + .getState() + .setTabTitleOverride({ tabId, titleOverride: title }) } - }} - onCloseAllTabs={() => { - for (const tab of tabs) { - closeTab(tab.id); - } - }} - onRenameTab={(tabId, title) => - store.getState().setTabTitleOverride({ tabId, titleOverride: title }) - } - getTabTitle={(tab) => tab.titleOverride ?? tab.id} - renderAddTabMenu={renderAddTabMenu} - renderTabAccessory={renderTabAccessory} - /> - {activeTab ? ( - tab.titleOverride ?? tab.id} + renderAddTabMenu={renderAddTabMenu} + renderTabAccessory={renderTabAccessory} /> - ) : ( -
- {renderEmptyState?.() ?? "No tabs open"} -
- )} -
+ {activeTab ? ( + + ) : ( +
+ {renderEmptyState?.() ?? "No tabs open"} +
+ )} +
+ ); } diff --git a/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/Pane.tsx b/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/Pane.tsx index 797d7d6c37f..bff2a9fc3a0 100644 --- a/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/Pane.tsx +++ b/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/Pane.tsx @@ -1,15 +1,21 @@ -import { useMemo } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { useDrop } from "react-dnd"; import type { StoreApi } from "zustand/vanilla"; import type { WorkspaceStore } from "../../../../../../../core/store"; -import type { Pane as PaneType, Tab } from "../../../../../../../types"; +import type { + Pane as PaneType, + SplitPosition, + Tab, +} from "../../../../../../../types"; import type { PaneActionConfig, PaneRegistry, RendererContext, } from "../../../../../../types"; import { PaneHeaderActions } from "../../../../../PaneHeaderActions"; +import { DropZoneOverlay } from "./components/DropZoneOverlay"; import { PaneContent } from "./components/PaneContent"; -import { PaneHeader } from "./components/PaneHeader"; +import { PANE_DRAG_TYPE, PaneHeader } from "./components/PaneHeader"; interface PaneComponentProps { store: StoreApi>; @@ -39,6 +45,21 @@ function resolveActions( return config; } +function getDropPosition( + clientX: number, + clientY: number, + rect: DOMRect, +): SplitPosition { + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + const dx = clientX - cx; + const dy = clientY - cy; + if (Math.abs(dx) > Math.abs(dy)) { + return dx > 0 ? "right" : "left"; + } + return dy > 0 ? "bottom" : "top"; +} + export function Pane({ store, tab, @@ -118,6 +139,57 @@ export function Pane({ tabPosition, ]); + const dropPositionRef = useRef(null); + const [dropPosition, setDropPosition] = useState(null); + const dropRef = useRef(null); + + const [{ isOver, canDrop }, connectDrop] = useDrop( + () => ({ + accept: PANE_DRAG_TYPE, + canDrop: (item: { paneId: string }) => item.paneId !== pane.id, + hover: (_item, monitor) => { + const offset = monitor.getClientOffset(); + const el = dropRef.current; + if (!offset || !el) return; + const rect = el.getBoundingClientRect(); + const pos = getDropPosition(offset.x, offset.y, rect); + if (pos !== dropPositionRef.current) { + dropPositionRef.current = pos; + setDropPosition(pos); + } + }, + drop: (item: { paneId: string }) => { + const pos = dropPositionRef.current; + if (!pos) return; + store.getState().movePaneToSplit({ + sourcePaneId: item.paneId, + targetPaneId: pane.id, + position: pos, + }); + }, + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + }), + [pane.id, tab.id, store], + ); + + // Merge refs: connectDrop needs a node, and we need dropRef for rect calculations + const setRefs = useCallback( + (node: HTMLDivElement | null) => { + (dropRef as React.MutableRefObject).current = node; + connectDrop(node); + }, + [connectDrop], + ); + + // Clear drop position when not hovering + if (!isOver && dropPositionRef.current !== null) { + dropPositionRef.current = null; + if (dropPosition !== null) setDropPosition(null); + } + const title = definition ? (pane.titleOverride ?? definition.getTitle?.(context) ?? pane.id) : `Unknown: ${pane.kind}`; @@ -126,10 +198,13 @@ export function Pane({ const headerExtras = definition?.renderHeaderExtras?.(context); const toolbar = definition?.renderToolbar?.(context); + const isDropTarget = isOver && canDrop; + return ( // biome-ignore lint/a11y/noStaticElementInteractions: clicking anywhere in a pane focuses it (standard IDE behavior)
({ headerExtras={headerExtras} toolbar={toolbar} actionsContent={} + paneId={pane.id} /> {definition ? ( @@ -150,6 +226,7 @@ export function Pane({
)} + {isDropTarget && } ); } diff --git a/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/DropZoneOverlay/DropZoneOverlay.tsx b/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/DropZoneOverlay/DropZoneOverlay.tsx new file mode 100644 index 00000000000..f015b3b0a94 --- /dev/null +++ b/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/DropZoneOverlay/DropZoneOverlay.tsx @@ -0,0 +1,28 @@ +import type { SplitPosition } from "../../../../../../../../../types"; + +interface DropZoneOverlayProps { + position: SplitPosition | null; +} + +const ZONE_STYLES: Record = { + top: { top: 0, left: 0, width: "100%", height: "50%" }, + bottom: { top: "50%", left: 0, width: "100%", height: "50%" }, + left: { top: 0, left: 0, width: "50%", height: "100%" }, + right: { top: 0, left: "50%", width: "50%", height: "100%" }, +}; + +export function DropZoneOverlay({ position }: DropZoneOverlayProps) { + if (!position) return null; + + return ( +
+
+
+ ); +} diff --git a/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/DropZoneOverlay/index.ts b/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/DropZoneOverlay/index.ts new file mode 100644 index 00000000000..0cd0919ade9 --- /dev/null +++ b/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/DropZoneOverlay/index.ts @@ -0,0 +1 @@ +export { DropZoneOverlay } from "./DropZoneOverlay"; diff --git a/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneHeader/PaneHeader.tsx b/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneHeader/PaneHeader.tsx index 12807bf21ce..640a2dda3dc 100644 --- a/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneHeader/PaneHeader.tsx +++ b/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneHeader/PaneHeader.tsx @@ -1,5 +1,7 @@ import { cn } from "@superset/ui/utils"; -import type { ReactNode } from "react"; +import { type ReactNode, useCallback, useRef } from "react"; +import { useDrag } from "react-dnd"; +import { DefaultHeaderContent } from "./components/DefaultHeaderContent"; interface PaneHeaderProps { title: ReactNode; @@ -9,8 +11,11 @@ interface PaneHeaderProps { headerExtras?: ReactNode; actionsContent: ReactNode; toolbar?: ReactNode; + paneId?: string; } +export const PANE_DRAG_TYPE = "pane"; + export function PaneHeader({ title, icon, @@ -19,41 +24,48 @@ export function PaneHeader({ headerExtras, actionsContent, toolbar, + paneId, }: PaneHeaderProps) { - const chrome = cn( - "flex h-7 shrink-0 items-center transition-[background-color] duration-150", - isActive ? "bg-secondary" : "bg-tertiary", + const [{ isDragging }, connectDrag] = useDrag( + () => ({ + type: PANE_DRAG_TYPE, + item: { paneId }, + canDrag: !!paneId, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }), + [paneId], ); - // Full eject — pane owns the entire toolbar content - if (toolbar) { - return
{toolbar}
; - } + const nodeRef = useRef(null); + const setRef = useCallback( + (node: HTMLDivElement | null) => { + (nodeRef as React.MutableRefObject).current = node; + connectDrag(node); + }, + [connectDrag], + ); - // Default layout — matches v1 BasePaneWindow toolbar pattern return ( -
-
-
- {titleContent ?? ( - <> - {icon && {icon}} - - {title} - - - )} -
-
- {headerExtras} - {actionsContent} -
-
+
+ {toolbar ?? ( + + )}
); } diff --git a/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneHeader/components/DefaultHeaderContent/DefaultHeaderContent.tsx b/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneHeader/components/DefaultHeaderContent/DefaultHeaderContent.tsx new file mode 100644 index 00000000000..14261228da5 --- /dev/null +++ b/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneHeader/components/DefaultHeaderContent/DefaultHeaderContent.tsx @@ -0,0 +1,48 @@ +import { cn } from "@superset/ui/utils"; +import type { ReactNode } from "react"; + +interface DefaultHeaderContentProps { + title: ReactNode; + icon?: ReactNode; + isActive: boolean; + titleContent?: ReactNode; + headerExtras?: ReactNode; + actionsContent: ReactNode; +} + +export function DefaultHeaderContent({ + title, + icon, + isActive, + titleContent, + headerExtras, + actionsContent, +}: DefaultHeaderContentProps) { + return ( +
+
+ {titleContent ?? ( + <> + {icon && {icon}} + + {title} + + + )} +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: stop drag from starting on action buttons */} +
e.stopPropagation()} + > + {headerExtras} + {actionsContent} +
+
+ ); +} diff --git a/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneHeader/components/DefaultHeaderContent/index.ts b/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneHeader/components/DefaultHeaderContent/index.ts new file mode 100644 index 00000000000..63f7459df15 --- /dev/null +++ b/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneHeader/components/DefaultHeaderContent/index.ts @@ -0,0 +1 @@ +export { DefaultHeaderContent } from "./DefaultHeaderContent"; diff --git a/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneHeader/index.ts b/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneHeader/index.ts index 6aee6da8df3..19afc34df92 100644 --- a/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneHeader/index.ts +++ b/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneHeader/index.ts @@ -1 +1 @@ -export { PaneHeader } from "./PaneHeader"; +export { PANE_DRAG_TYPE, PaneHeader } from "./PaneHeader"; diff --git a/packages/panes/src/react/components/Workspace/components/TabBar/components/TabItem/TabItem.tsx b/packages/panes/src/react/components/Workspace/components/TabBar/components/TabItem/TabItem.tsx index 4cbec17bece..5ae1981a990 100644 --- a/packages/panes/src/react/components/Workspace/components/TabBar/components/TabItem/TabItem.tsx +++ b/packages/panes/src/react/components/Workspace/components/TabBar/components/TabItem/TabItem.tsx @@ -9,8 +9,10 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { PencilIcon, XIcon } from "lucide-react"; -import { type ReactNode, useState } from "react"; +import { type ReactNode, useCallback, useRef, useState } from "react"; +import { useDrop } from "react-dnd"; import type { Tab } from "../../../../../../../types"; +import { PANE_DRAG_TYPE } from "../../../Tab/components/Pane/components/PaneHeader"; import { TabRenameInput } from "./components/TabRenameInput"; interface TabItemProps { @@ -57,10 +59,38 @@ export function TabItem({ stopEditing(); }; + const [{ isOver }, connectDrop] = useDrop( + () => ({ + accept: PANE_DRAG_TYPE, + hover: () => { + if (!isActive) onSelect(); + }, + collect: (monitor) => ({ + isOver: monitor.isOver(), + }), + }), + [isActive, onSelect], + ); + + const nodeRef = useRef(null); + const setDropRef = useCallback( + (node: HTMLDivElement | null) => { + (nodeRef as React.MutableRefObject).current = node; + connectDrop(node); + }, + [connectDrop], + ); + return ( -
+
{isEditing ? (