feat: pro: unify split panes under a single tab bar

This commit is contained in:
Linus Rath
2026-05-18 19:39:57 +02:00
parent ecd0467ffa
commit 2b3094a4ef
2 changed files with 142 additions and 104 deletions
+123 -82
View File
@@ -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 (
<div
className="flex flex-1 flex-col overflow-hidden min-w-0 min-h-0"
className="relative flex flex-1 flex-col overflow-hidden min-w-0 min-h-0"
onMouseDownCapture={() => { if (!isFocused) onPaneFocus(paneId); }}
>
<ProTabBar
tabs={tabs}
activeTabId={activeTabId}
paneId={paneId}
onActivate={onActivate}
onClose={onClose}
onDragStateChange={onDragStateChange}
/>
<div className="relative flex-1 min-h-0">
{allTabs
.filter((tab) => tab.paneId === paneId && loadedTabIds.includes(tab.id))
.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
className={cn("absolute inset-0 overflow-hidden", !isActive && "hidden")}
aria-hidden={!isActive}
>
{renderTabBody(tab)}
</div>
);
})}
</div>
{tabs
.filter((tab) => loadedTabIds.includes(tab.id))
.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
className={cn("absolute inset-0 overflow-hidden", !isActive && "hidden")}
aria-hidden={!isActive}
>
{renderTabBody(tab)}
</div>
);
})}
</div>
);
}
@@ -134,6 +117,8 @@ export default function ProHome() {
const [isTabDragging, setIsTabDragging] = useState(false);
const [splitDropTarget, setSplitDropTarget] = useState<DropTarget>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 = (
<Pane
paneId="main"
tabs={mainTabs}
activeTabId={activeMainTabId}
loadedTabIds={loadedTabIds}
allTabs={tabs}
onActivate={setActiveTab}
onClose={closeTab}
onDragStateChange={setIsTabDragging}
onPaneFocus={setFocusedPane}
isFocused={focusedPaneId === 'main'}
/>
@@ -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 && (
<>
<DropZone active={splitDropTarget === 'left'} side="left" />
<DropZone active={splitDropTarget === 'right'} side="right" />
<DropZone active={splitDropTarget === 'top'} side="top" />
<DropZone active={splitDropTarget === 'bottom'} side="bottom" />
</>
)}
{isSplit && splitOrientation === 'vertical' && (
<>
<DropZoneHalf active={splitDropTarget === 'left'} axis="x" side="leading" />
<DropZoneHalf active={splitDropTarget === 'right'} axis="x" side="trailing" />
</>
)}
{isSplit && splitOrientation === 'horizontal' && (
<>
<DropZoneHalf active={splitDropTarget === 'top'} axis="y" side="leading" />
<DropZoneHalf active={splitDropTarget === 'bottom'} axis="y" side="trailing" />
</>
)}
</>
) : null;
return (
<EmbeddedContext.Provider value={true}>
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
@@ -337,31 +359,35 @@ export default function ProHome() {
)}
{!inlineApp && (
<div
className={cn(
"relative flex flex-1 overflow-hidden min-w-0",
isSplit && splitOrientation === 'horizontal' ? "flex-col" : "flex-row",
)}
onDragOver={handleBodyDragOver}
onDragLeave={handleBodyDragLeave}
onDrop={handleBodyDrop}
>
{isSplit
? (splitLeading
? <>{splitPane}{splitDivider}{mainPane}</>
: <>{mainPane}{splitDivider}{splitPane}</>)
: mainPane}
<div className="flex flex-1 flex-col overflow-hidden min-w-0">
{/* Single, unified tab bar above both panes. */}
<ProTabBar
tabs={tabs}
activeMainTabId={activeMainTabId}
activeSplitTabId={activeSplitTabId}
onActivate={setActiveTab}
onClose={closeTab}
onDragStateChange={setIsTabDragging}
/>
{/* Split-creation drop zones — shown only while a tab is being
dragged and the body isn't already split. */}
{isTabDragging && !isSplit && (
<>
<DropZone active={splitDropTarget === 'left'} side="left" />
<DropZone active={splitDropTarget === 'right'} side="right" />
<DropZone active={splitDropTarget === 'top'} side="top" />
<DropZone active={splitDropTarget === 'bottom'} side="bottom" />
</>
)}
{/* Panes container — accepts body drops for split/move. */}
<div
className={cn(
"relative flex flex-1 overflow-hidden min-w-0",
isSplit && splitOrientation === 'horizontal' ? "flex-col" : "flex-row",
)}
onDragOver={handleBodyDragOver}
onDragLeave={handleBodyDragLeave}
onDrop={handleBodyDrop}
>
{isSplit
? (splitLeading
? <>{splitPane}{splitDivider}{mainPane}</>
: <>{mainPane}{splitDivider}{splitPane}</>)
: mainPane}
{dropZones}
</div>
</div>
)}
</div>
@@ -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 (
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute z-10 transition-colors duration-100",
axis === 'x'
? cn("top-0 bottom-0 w-1/2", side === 'leading' ? "left-0" : "right-0")
: cn("left-0 right-0 h-1/2", side === 'leading' ? "top-0" : "bottom-0"),
active ? "bg-primary/10 ring-2 ring-primary/40 ring-inset" : "bg-transparent",
)}
/>
);
}