diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx index e5be43c3685..d5073b1c5ac 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx @@ -2,8 +2,11 @@ import { Button } from "@superset/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { useEffect, useRef, useState } from "react"; +import { useDrop } from "react-dnd"; import { HiMiniXMark } from "react-icons/hi2"; +import { MosaicDragType } from "react-mosaic-component"; import { StatusIndicator } from "renderer/screens/main/components/StatusIndicator"; +import { useDraggingPaneStore } from "renderer/stores/tabs/dragging-pane"; import type { PaneStatus, Tab } from "renderer/stores/tabs/types"; import { getTabDisplayName } from "renderer/stores/tabs/utils"; @@ -14,6 +17,7 @@ interface GroupItemProps { onSelect: () => void; onClose: () => void; onRename: (newName: string) => void; + onPaneDrop?: (paneId: string) => void; } export function GroupItem({ @@ -23,12 +27,43 @@ export function GroupItem({ onSelect, onClose, onRename, + onPaneDrop, }: GroupItemProps) { const displayName = getTabDisplayName(tab); const [isEditing, setIsEditing] = useState(false); const [editValue, setEditValue] = useState(""); const inputRef = useRef(null); + const [{ isOver, canDrop }, dropRef] = useDrop< + unknown, + { handled: true }, + { isOver: boolean; canDrop: boolean } + >( + () => ({ + accept: MosaicDragType.WINDOW, + drop: () => { + // Get fresh state at drop time to avoid stale closures + const { draggingPaneId, draggingTabId, setDraggingPane } = + useDraggingPaneStore.getState(); + if (draggingPaneId && onPaneDrop && draggingTabId !== tab.id) { + onPaneDrop(draggingPaneId); + } + setDraggingPane(null, null); + return { handled: true }; + }, + canDrop: () => { + const { draggingPaneId, draggingTabId } = + useDraggingPaneStore.getState(); + return !!onPaneDrop && !!draggingPaneId && draggingTabId !== tab.id; + }, + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + }), + [onPaneDrop, tab.id], + ); + useEffect(() => { if (isEditing && inputRef.current) { inputRef.current.focus(); @@ -66,8 +101,18 @@ export function GroupItem({ : "text-muted-foreground/70 hover:text-muted-foreground hover:bg-tertiary/20", ); + const isDragActive = isOver && canDrop; + return ( -
+
{ + dropRef(node); + }} + className={cn( + "group relative flex items-center shrink-0 h-full border-r border-border transition-colors", + isDragActive && "bg-accent/50 ring-2 ring-inset ring-accent", + )} + > {isEditing ? (
({ + isDragging: monitor.isDragging(), + })); + + useEffect(() => { + if (!isDragging) { + const { draggingPaneId, setDraggingPane } = + useDraggingPaneStore.getState(); + if (draggingPaneId) { + setDraggingPane(null, null); + } + } + }, [isDragging]); + const tabs = useMemo( () => activeWorkspaceId @@ -64,7 +84,6 @@ export function GroupStrip() { }); }, [activeWorkspaceId, activeTabIds, allTabs, tabHistoryStacks]); - // Compute aggregate status per tab using shared priority logic const tabStatusMap = useMemo(() => { const result = new Map(); for (const pane of Object.values(panes)) { @@ -116,8 +135,103 @@ export function GroupStrip() { renameTab(tabId, newName); }; + const resolveDropPaneId = () => { + const { draggingPaneId, draggingTabId } = useDraggingPaneStore.getState(); + const { + activeTabIds: currentActiveTabIds, + tabHistoryStacks: currentTabHistoryStacks, + tabs: currentTabs, + panes: currentPanes, + focusedPaneIds: currentFocusedPaneIds, + } = useTabsStore.getState(); + + if (draggingPaneId) { + const pane = currentPanes[draggingPaneId]; + if (!draggingTabId || pane?.tabId === draggingTabId) { + return draggingPaneId; + } + } + + if (!activeWorkspaceId) return null; + const activeTabIdForWorkspace = resolveActiveTabIdForWorkspace({ + workspaceId: activeWorkspaceId, + tabs: currentTabs, + activeTabIds: currentActiveTabIds, + tabHistoryStacks: currentTabHistoryStacks, + }); + if (!activeTabIdForWorkspace) return null; + return currentFocusedPaneIds[activeTabIdForWorkspace] ?? null; + }; + + // Get fresh state at call time to avoid stale closures + const handlePaneDropToTab = (paneId: string, targetTabId: string) => { + const { panes, tabs, movePaneToTab } = useTabsStore.getState(); + const pane = panes[paneId]; + if (!pane || pane.tabId === targetTabId) return; + + const targetTab = tabs.find((t) => t.id === targetTabId); + const sourceTab = tabs.find((t) => t.id === pane.tabId); + if (!targetTab || !sourceTab) return; + if (targetTab.workspaceId !== sourceTab.workspaceId) return; + + movePaneToTab(paneId, targetTabId); + }; + + const [{ isOver: isOverStrip, canDrop: canDropStrip }, stripDropRef] = + useDrop( + () => ({ + accept: MosaicDragType.WINDOW, + drop: (_item, monitor) => { + // Skip if a nested drop target (GroupItem) already handled it + if (monitor.didDrop()) return; + if (!monitor.isOver({ shallow: true })) return; + + // Get fresh state at drop time to avoid stale closures + const { setDraggingPane } = useDraggingPaneStore.getState(); + const paneId = resolveDropPaneId(); + if (!paneId) return; + + const { panes, tabs, movePaneToNewTab } = useTabsStore.getState(); + const pane = panes[paneId]; + if (!pane) return; + + const sourceTab = tabs.find((t) => t.id === pane.tabId); + if (sourceTab?.workspaceId !== activeWorkspaceId) return; + + movePaneToNewTab(paneId); + setDraggingPane(null, null); + }, + canDrop: () => { + const paneId = resolveDropPaneId(); + if (!paneId) return false; + + const { panes, tabs } = useTabsStore.getState(); + const pane = panes[paneId]; + if (!pane) return false; + + const sourceTab = tabs.find((t) => t.id === pane.tabId); + return sourceTab?.workspaceId === activeWorkspaceId; + }, + collect: (monitor) => ({ + isOver: monitor.isOver({ shallow: true }), + canDrop: monitor.canDrop(), + }), + }), + [activeWorkspaceId], // Only stable values in deps + ); + + const isStripDropActive = isOverStrip && canDropStrip; + return ( -
+
{ + stripDropRef(node); + }} + className={cn( + "flex items-center h-10 flex-1 min-w-0 transition-colors", + isStripDropActive && "bg-accent/30", + )} + > {tabs.length > 0 && (
handleSelectGroup(tab.id)} onClose={() => handleCloseGroup(tab.id)} onRename={(newName) => handleRenameGroup(tab.id, newName)} + onPaneDrop={(paneId) => handlePaneDropToTab(paneId, tab.id)} />
))} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/BasePaneWindow/BasePaneWindow.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/BasePaneWindow/BasePaneWindow.tsx index 7e7c3d0b2a8..ac731c4f756 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/BasePaneWindow/BasePaneWindow.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/BasePaneWindow/BasePaneWindow.tsx @@ -1,6 +1,7 @@ -import { useRef } from "react"; +import { useCallback, useRef } from "react"; import type { MosaicBranch } from "react-mosaic-component"; import { MosaicWindow } from "react-mosaic-component"; +import { useDraggingPaneStore } from "renderer/stores/tabs/dragging-pane"; import type { SplitOrientation } from "../../hooks"; import { useSplitOrientation } from "../../hooks"; @@ -43,6 +44,16 @@ export function BasePaneWindow({ }: BasePaneWindowProps) { const containerRef = useRef(null); const splitOrientation = useSplitOrientation(containerRef); + const setDraggingPane = useDraggingPaneStore((s) => s.setDraggingPane); + + const handleDragStart = useCallback(() => { + setFocusedPane(tabId, paneId); + setDraggingPane(paneId, tabId); + }, [paneId, tabId, setDraggingPane, setFocusedPane]); + + const handleDragEnd = useCallback(() => { + setDraggingPane(null, null); + }, [setDraggingPane]); const handleFocus = () => { setFocusedPane(tabId, paneId); @@ -75,6 +86,8 @@ export function BasePaneWindow({ title="" renderToolbar={() => renderToolbar(handlers)} className={isActive ? "mosaic-window-focused" : ""} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} > {/* biome-ignore lint/a11y/useKeyWithClickEvents lint/a11y/noStaticElementInteractions: Focus handler for pane */}
void; +} + +export const useDraggingPaneStore = create((set) => ({ + draggingPaneId: null, + draggingTabId: null, + setDraggingPane: (paneId, tabId) => + set({ draggingPaneId: paneId, draggingTabId: tabId }), +}));