From 2b3094a4ef5409ca8174b18e5400b5d6309b74cc Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 18 May 2026 19:39:57 +0200 Subject: [PATCH] feat: pro: unify split panes under a single tab bar --- app/[locale]/pro/page.tsx | 205 ++++++++++++++++++++------------- components/pro/pro-tab-bar.tsx | 41 +++---- 2 files changed, 142 insertions(+), 104 deletions(-) diff --git a/app/[locale]/pro/page.tsx b/app/[locale]/pro/page.tsx index b8a9aa95..eebb7288 100644 --- a/app/[locale]/pro/page.tsx +++ b/app/[locale]/pro/page.tsx @@ -49,47 +49,30 @@ interface PaneProps { tabs: ProTab[]; activeTabId: string | null; loadedTabIds: string[]; - allTabs: ProTab[]; - onActivate: (id: string) => void; - onClose: (id: string) => void; - onDragStateChange: (dragging: boolean) => void; onPaneFocus: (paneId: ProPaneId) => void; isFocused: boolean; } -function Pane({ - paneId, tabs, activeTabId, loadedTabIds, allTabs, - onActivate, onClose, onDragStateChange, onPaneFocus, isFocused, -}: PaneProps) { +function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused }: PaneProps) { return (
{ if (!isFocused) onPaneFocus(paneId); }} > - -
- {allTabs - .filter((tab) => tab.paneId === paneId && loadedTabIds.includes(tab.id)) - .map((tab) => { - const isActive = tab.id === activeTabId; - return ( -
- {renderTabBody(tab)} -
- ); - })} -
+ {tabs + .filter((tab) => loadedTabIds.includes(tab.id)) + .map((tab) => { + const isActive = tab.id === activeTabId; + return ( +
+ {renderTabBody(tab)} +
+ ); + })}
); } @@ -134,6 +117,8 @@ export default function ProHome() { const [isTabDragging, setIsTabDragging] = useState(false); const [splitDropTarget, setSplitDropTarget] = useState(null); + /** Whether the split pane visually renders before (true) or after (false) main. */ + const [splitLeading, setSplitLeading] = useState(false); // Auth bootstrap (mirrors standard page) useEffect(() => { @@ -153,7 +138,6 @@ export default function ProHome() { } }, [initialCheckDone, isAuthenticated, authLoading]); - // Pro is desktop-only — fall back to standard on mobile/tablet useEffect(() => { if (initialCheckDone && (isMobile || isTablet) && typeof window !== "undefined") { window.location.replace("/"); @@ -180,7 +164,9 @@ export default function ProHome() { || focusedActiveTab.kind === 'settings' ) ? focusedActiveTab.kind : null; - // ---- Split drop zones ---- + const isSplit = splitOrientation !== null && splitTabs.length > 0; + + // ---- Body-level drop targets ---- const isProTabDrag = (e: DragEvent) => e.dataTransfer.types.includes(PRO_TAB_DRAG_MIME); @@ -190,8 +176,15 @@ export default function ProHome() { const y = e.clientY - rect.top; const xFrac = x / rect.width; const yFrac = y / rect.height; - // Edges: outer 22% of the body becomes a split drop target. The cursor's - // dominant axis decides which side activates. + + if (isSplit) { + // When split: each pane gets half the body as its drop zone. + if (splitOrientation === 'vertical') { + return xFrac < 0.5 ? 'left' : 'right'; + } + return yFrac < 0.5 ? 'top' : 'bottom'; + } + // No split: only the outer 22% of each edge creates a split. const fromLeft = xFrac; const fromRight = 1 - xFrac; const fromTop = yFrac; @@ -204,11 +197,25 @@ export default function ProHome() { return 'top'; }; + const targetPaneFromDrop = (target: DropTarget): ProPaneId | null => { + if (!target || !isSplit) return null; + if (splitOrientation === 'vertical') { + const leftIsSplit = splitLeading; + if (target === 'left') return leftIsSplit ? 'split' : 'main'; + if (target === 'right') return leftIsSplit ? 'main' : 'split'; + } + if (splitOrientation === 'horizontal') { + const topIsSplit = splitLeading; + if (target === 'top') return topIsSplit ? 'split' : 'main'; + if (target === 'bottom') return topIsSplit ? 'main' : 'split'; + } + return null; + }; + const handleBodyDragOver = (e: DragEvent) => { if (!isProTabDrag(e)) return; - if (splitOrientation !== null) return; // already split — body drops disabled e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; + e.dataTransfer.dropEffect = "move"; const next = computeDropTarget(e); if (next !== splitDropTarget) setSplitDropTarget(next); }; @@ -221,7 +228,6 @@ export default function ProHome() { const handleBodyDrop = (e: DragEvent) => { if (!isProTabDrag(e)) return; - if (splitOrientation !== null) return; const target = computeDropTarget(e); setSplitDropTarget(null); setIsTabDragging(false); @@ -229,19 +235,19 @@ export default function ProHome() { e.preventDefault(); const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME); if (!draggedId) return; - // The dragged tab must currently be in 'main' (the only pane right now). - // Moving it to 'split' creates the split. + + if (isSplit) { + // Move tab to whichever pane occupies the dropped side. + const destPane = targetPaneFromDrop(target); + if (destPane) moveTabToPane(draggedId, destPane); + return; + } + // Create a new split. const orientation = (target === 'left' || target === 'right') ? 'vertical' : 'horizontal'; moveTabToPane(draggedId, 'split', orientation); - // 'left'/'top' targets put the split pane on the leading edge — flipped - // visually by swapping the rendered order below. We track it via the - // splitLeading flag derived from the last drop. setSplitLeading(target === 'left' || target === 'top'); }; - // Whether the split pane renders before (true) or after (false) the main pane. - const [splitLeading, setSplitLeading] = useState(false); - // Loading state (matches standard page exactly) if (!initialCheckDone || authLoading || !isAuthenticated || !client) { return ( @@ -256,18 +262,12 @@ export default function ProHome() { if (!isDesktop) return null; - const isSplit = splitOrientation !== null && splitTabs.length > 0; - const mainPane = ( @@ -279,10 +279,6 @@ export default function ProHome() { tabs={splitTabs} activeTabId={activeSplitTabId} loadedTabIds={loadedTabIds} - allTabs={tabs} - onActivate={setActiveTab} - onClose={closeTab} - onDragStateChange={setIsTabDragging} onPaneFocus={setFocusedPane} isFocused={focusedPaneId === 'split'} /> @@ -303,6 +299,32 @@ export default function ProHome() { /> ) : null; + // Drop-zone overlays: 4 edges when not split, 2 panes when split. + const dropZones = isTabDragging ? ( + <> + {!isSplit && ( + <> + + + + + + )} + {isSplit && splitOrientation === 'vertical' && ( + <> + + + + )} + {isSplit && splitOrientation === 'horizontal' && ( + <> + + + + )} + + ) : null; + return (
@@ -337,31 +359,35 @@ export default function ProHome() { )} {!inlineApp && ( -
- {isSplit - ? (splitLeading - ? <>{splitPane}{splitDivider}{mainPane} - : <>{mainPane}{splitDivider}{splitPane}) - : mainPane} +
+ {/* Single, unified tab bar above both panes. */} + - {/* Split-creation drop zones — shown only while a tab is being - dragged and the body isn't already split. */} - {isTabDragging && !isSplit && ( - <> - - - - - - )} + {/* Panes container — accepts body drops for split/move. */} +
+ {isSplit + ? (splitLeading + ? <>{splitPane}{splitDivider}{mainPane} + : <>{mainPane}{splitDivider}{splitPane}) + : mainPane} + + {dropZones} +
)}
@@ -395,3 +421,18 @@ function DropZone({ active, side }: { active: boolean; side: 'left' | 'right' | /> ); } + +function DropZoneHalf({ active, axis, side }: { active: boolean; axis: 'x' | 'y'; side: 'leading' | 'trailing' }) { + return ( +