From 303386806fff18df6d78b61d476428de0cd0e453 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 17:45:44 +0800 Subject: [PATCH] feat: Add drag-and-drop split screen functionality for tabs Implements intuitive drag-and-drop split screen feature for tabs: Core Functionality: - Drag a splittable tab (Terminal, File Manager, or Server) onto another splittable tab - Automatically triggers split screen mode for the dragged tab - Target tab becomes the active focused tab - Works alongside existing tab reordering drag-and-drop Visual Feedback: - Valid drop targets show blue border (border-blue-400/50) when dragging begins - Hovered drop target shows enhanced highlight with blue ring and background - Clear visual distinction between reorder drag vs split screen drop - Smooth transitions and hover states Smart Detection: - Only allows split screen between compatible tab types (terminal/server/file_manager) - Prevents splitting onto tabs already in split screen mode - Respects 3-tab split screen limit - Automatically falls back to reorder if dropped between tabs instead of on a tab Implementation Details: - Added hoverTabIndex to dragState to track which tab is being hovered - New findHoveredTab() function detects mouse position over tabs - Modified handleDrop() to prioritize split screen over reorder - Added isValidDropTarget and isHoveredDropTarget props to Tab component - Updated Tab.tsx styles with conditional classes for drop target states User Experience: - Drag tab to empty space between tabs = reorder (existing behavior) - Drag tab directly onto another tab = split screen (new behavior) - Visual feedback guides user to valid drop targets - Seamless integration with existing tab management --- src/ui/desktop/navigation/TopNavbar.tsx | 101 ++++++++++++++++++++++++ src/ui/desktop/navigation/tabs/Tab.tsx | 13 +++ 2 files changed, 114 insertions(+) diff --git a/src/ui/desktop/navigation/TopNavbar.tsx b/src/ui/desktop/navigation/TopNavbar.tsx index 2ad540f8..aa820f71 100644 --- a/src/ui/desktop/navigation/TopNavbar.tsx +++ b/src/ui/desktop/navigation/TopNavbar.tsx @@ -66,12 +66,14 @@ export function TopNavbar({ currentX: number; startX: number; targetIndex: number | null; + hoverTabIndex: number | null; }>({ draggedId: null, draggedIndex: null, currentX: 0, startX: 0, targetIndex: null, + hoverTabIndex: null, }); const containerRef = React.useRef(null); const tabRefs = React.useRef>(new Map()); @@ -123,6 +125,7 @@ export function TopNavbar({ startX: e.clientX, currentX: e.clientX, targetIndex: index, + hoverTabIndex: null, }); }; @@ -207,6 +210,22 @@ export function TopNavbar({ return newTargetIndex; }; + const findHoveredTab = (clientX: number, clientY: number): number | null => { + for (const [index, tabEl] of tabRefs.current.entries()) { + if (!tabEl) continue; + const rect = tabEl.getBoundingClientRect(); + if ( + clientX >= rect.left && + clientX <= rect.right && + clientY >= rect.top && + clientY <= rect.bottom + ) { + return index; + } + } + return null; + }; + const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); @@ -222,6 +241,14 @@ export function TopNavbar({ })); } + const hoveredTabIndex = findHoveredTab(e.clientX, e.clientY); + if (hoveredTabIndex !== dragState.hoverTabIndex) { + setDragState((prev) => ({ + ...prev, + hoverTabIndex: hoveredTabIndex, + })); + } + const newTargetIndex = calculateTargetIndex(); if (newTargetIndex !== null && newTargetIndex !== dragState.targetIndex) { setDragState((prev) => ({ @@ -240,7 +267,57 @@ export function TopNavbar({ const fromIndex = dragState.draggedIndex; const toIndex = dragState.targetIndex; const draggedId = dragState.draggedId; + const hoverTabIndex = dragState.hoverTabIndex; + // Check if dropping onto another tab for split screen + if ( + fromIndex !== null && + hoverTabIndex !== null && + fromIndex !== hoverTabIndex && + draggedId !== null + ) { + const draggedTab = tabs[fromIndex]; + const targetTab = tabs[hoverTabIndex]; + + const isDraggedSplittable = + draggedTab.type === "terminal" || + draggedTab.type === "server" || + draggedTab.type === "file_manager"; + const isTargetSplittable = + targetTab.type === "terminal" || + targetTab.type === "server" || + targetTab.type === "file_manager"; + + // Both tabs must be splittable and target must not already be in split screen + if ( + isDraggedSplittable && + isTargetSplittable && + !allSplitScreenTab.includes(targetTab.id) && + allSplitScreenTab.length < 3 + ) { + // Trigger split screen for the dragged tab + setSplitScreenTab(draggedId); + setCurrentTab(targetTab.id); + + setDragState({ + draggedId: null, + draggedIndex: null, + startX: 0, + currentX: 0, + targetIndex: null, + hoverTabIndex: null, + }); + + setTimeout(() => { + isProcessingDropRef.current = false; + setIsInDropAnimation(false); + }, 50); + + return; + } + } + + // Original reorder logic if (fromIndex !== null && toIndex !== null && fromIndex !== toIndex) { prevTabsRef.current = tabs; @@ -252,6 +329,7 @@ export function TopNavbar({ startX: 0, currentX: 0, targetIndex: null, + hoverTabIndex: null, }); }); @@ -267,6 +345,7 @@ export function TopNavbar({ startX: 0, currentX: 0, targetIndex: null, + hoverTabIndex: null, }); } @@ -284,6 +363,7 @@ export function TopNavbar({ startX: 0, currentX: 0, targetIndex: null, + hoverTabIndex: null, }); }; @@ -351,6 +431,25 @@ export function TopNavbar({ ? dragState.currentX - dragState.startX : 0; + // Check if this tab is a valid drop target for split screen + const draggedTab = + dragState.draggedIndex !== null + ? tabs[dragState.draggedIndex] + : null; + const isDraggedSplittable = + draggedTab && + (draggedTab.type === "terminal" || + draggedTab.type === "server" || + draggedTab.type === "file_manager"); + const isValidDropTarget = + isDraggedSplittable && + isSplittable && + !isDraggingThisTab && + !isSplit && + allSplitScreenTab.length < 3; + const isHoveredDropTarget = + isValidDropTarget && dragState.hoverTabIndex === index; + let transform = ""; if (!isInDropAnimation) { @@ -466,6 +565,8 @@ export function TopNavbar({ disableClose={disableClose} isDragging={isDraggingThisTab} isDragOver={false} + isValidDropTarget={isValidDropTarget} + isHoveredDropTarget={isHoveredDropTarget} /> ); diff --git a/src/ui/desktop/navigation/tabs/Tab.tsx b/src/ui/desktop/navigation/tabs/Tab.tsx index 03a21bbb..ba35e918 100644 --- a/src/ui/desktop/navigation/tabs/Tab.tsx +++ b/src/ui/desktop/navigation/tabs/Tab.tsx @@ -27,6 +27,8 @@ interface TabProps { disableClose?: boolean; isDragging?: boolean; isDragOver?: boolean; + isValidDropTarget?: boolean; + isHoveredDropTarget?: boolean; } export function Tab({ @@ -44,6 +46,8 @@ export function Tab({ disableClose = false, isDragging = false, isDragOver = false, + isValidDropTarget = false, + isHoveredDropTarget = false, }: TabProps): React.ReactElement { const { t } = useTranslation(); @@ -54,12 +58,21 @@ export function Tab({ isDragOver && "bg-background/40 text-muted-foreground border-border opacity-60", isDragging && "opacity-70", + isHoveredDropTarget && + "bg-blue-500/20 border-blue-500 ring-2 ring-blue-500/50", + !isHoveredDropTarget && + isValidDropTarget && + "border-blue-400/50 bg-background/90", !isDragOver && !isDragging && + !isValidDropTarget && + !isHoveredDropTarget && isActive && "bg-background text-foreground border-border z-10", !isDragOver && !isDragging && + !isValidDropTarget && + !isHoveredDropTarget && !isActive && "bg-background/80 text-muted-foreground border-border hover:bg-background/90", );