feat: add tab drop preview indicator for split pane drag-and-drop

This commit is contained in:
Mohamed Boudra
2026-03-18 20:09:28 +07:00
parent b763d4358e
commit ed9a15c0bd
7 changed files with 329 additions and 38 deletions

View File

@@ -61,10 +61,9 @@ function SortableItem<T>({
// This is a no-op but matches the mobile API
}, []);
// When using an external DndContext (e.g. split pane container), hide the
// original item during drag so the DragOverlay renders the floating copy.
// In standalone mode, keep the existing in-place drag visual.
const baseTransform = externalDndContext && isDragging
// External DnD contexts render their own insertion affordance, so keep the
// tab row static and let the DragOverlay carry the moving chip.
const baseTransform = externalDndContext
? undefined
: CSS.Transform.toString(
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { computeTabDropPreview } from "@/components/split-container-tab-drop-preview";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
function tab(tabId: string): WorkspaceTabDescriptor {
return {
key: tabId,
tabId,
kind: "draft",
target: {
kind: "draft",
draftId: tabId,
},
};
}
describe("computeTabDropPreview", () => {
const targetTabs = [tab("a"), tab("b"), tab("c"), tab("d")];
it("returns a before-target insertion index for cross-pane drops on the left half", () => {
expect(
computeTabDropPreview({
activePaneId: "source",
activeTabId: "x",
overPaneId: "target",
overTabId: "c",
targetTabs,
activeRect: { left: 180, width: 40 },
overRect: { left: 200, width: 100 },
})
).toEqual({
paneId: "target",
insertionIndex: 2,
indicatorIndex: 2,
});
});
it("returns an after-target insertion index for cross-pane drops on the right half", () => {
expect(
computeTabDropPreview({
activePaneId: "source",
activeTabId: "x",
overPaneId: "target",
overTabId: "c",
targetTabs,
activeRect: { left: 280, width: 40 },
overRect: { left: 200, width: 100 },
})
).toEqual({
paneId: "target",
insertionIndex: 3,
indicatorIndex: 3,
});
});
it("adjusts same-pane drops so insertion indexes match arrayMove semantics", () => {
expect(
computeTabDropPreview({
activePaneId: "pane",
activeTabId: "b",
overPaneId: "pane",
overTabId: "d",
targetTabs,
activeRect: { left: 460, width: 40 },
overRect: { left: 400, width: 100 },
})
).toEqual({
paneId: "pane",
insertionIndex: 3,
indicatorIndex: 4,
});
});
});

View File

@@ -0,0 +1,55 @@
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
export interface TabDropPreview {
paneId: string;
insertionIndex: number;
indicatorIndex: number;
}
interface ComputeTabDropPreviewInput {
activePaneId: string;
activeTabId: string;
overPaneId: string;
overTabId: string;
targetTabs: WorkspaceTabDescriptor[];
activeRect: {
left: number;
width: number;
};
overRect: {
left: number;
width: number;
};
}
export function computeTabDropPreview(
input: ComputeTabDropPreviewInput
): TabDropPreview | null {
const targetIndex = input.targetTabs.findIndex((tab) => tab.tabId === input.overTabId);
if (targetIndex < 0 || input.overRect.width <= 0) {
return null;
}
const activeCenterX = input.activeRect.left + input.activeRect.width / 2;
const overCenterX = input.overRect.left + input.overRect.width / 2;
const insertAfterTarget = activeCenterX >= overCenterX;
const indicatorIndex = targetIndex + (insertAfterTarget ? 1 : 0);
let insertionIndex = indicatorIndex;
if (input.activePaneId === input.overPaneId) {
const sourceIndex = input.targetTabs.findIndex((tab) => tab.tabId === input.activeTabId);
if (sourceIndex < 0) {
return null;
}
if (sourceIndex < insertionIndex) {
insertionIndex -= 1;
}
insertionIndex = Math.max(0, Math.min(input.targetTabs.length - 1, insertionIndex));
}
return {
paneId: input.overPaneId,
insertionIndex,
indicatorIndex,
};
}

View File

@@ -18,6 +18,10 @@ import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { Platform, View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle";
import {
computeTabDropPreview,
type TabDropPreview,
} from "@/components/split-container-tab-drop-preview";
import {
SplitDropZone,
resolveSplitDropPosition,
@@ -104,6 +108,7 @@ interface SplitNodeViewProps
activeDragTabId: string | null;
showDropZones: boolean;
dropPreview: SplitDropZoneHover | null;
tabDropPreview: TabDropPreview | null;
}
interface SplitPaneViewProps
@@ -124,6 +129,7 @@ interface SplitPaneViewProps
activeDragTabId: string | null;
showDropZones: boolean;
dropPreview: SplitDropZoneHover | null;
tabDropPreview: TabDropPreview | null;
}
const dropCollisionDetection: CollisionDetection = (args) => {
@@ -178,6 +184,7 @@ export function SplitContainer({
}: SplitContainerProps) {
const [activeDragTabId, setActiveDragTabId] = useState<string | null>(null);
const [dropPreview, setDropPreview] = useState<SplitDropZoneHover | null>(null);
const [tabDropPreview, setTabDropPreview] = useState<TabDropPreview | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -197,6 +204,7 @@ export function SplitContainer({
if (data?.kind !== "workspace-tab") {
setActiveDragTabId(null);
setDropPreview(null);
setTabDropPreview(null);
return;
}
setActiveDragTabId(data.tabId);
@@ -205,6 +213,7 @@ export function SplitContainer({
const handleDragCancel = useCallback(() => {
setActiveDragTabId(null);
setDropPreview(null);
setTabDropPreview(null);
}, []);
const updateDropPreview = useCallback(
@@ -221,17 +230,53 @@ export function SplitContainer({
if (activeData?.kind !== "workspace-tab") {
setDropPreview(null);
return;
}
if (overData?.kind !== "split-pane-drop") {
setDropPreview(null);
setTabDropPreview(null);
return;
}
const translatedRect = event.active.rect.current.translated;
const overRect = event.over?.rect;
if (!translatedRect || !overRect || overRect.width <= 0 || overRect.height <= 0) {
setDropPreview(null);
setTabDropPreview(null);
return;
}
if (overData?.kind === "workspace-tab") {
const targetPane = panesById.get(overData.paneId) ?? null;
if (!targetPane) {
setDropPreview(null);
setTabDropPreview(null);
return;
}
const targetTabs = getWorkspacePaneDescriptors({
pane: targetPane,
tabs: uiTabs,
});
setDropPreview(null);
setTabDropPreview(
computeTabDropPreview({
activePaneId: activeData.paneId,
activeTabId: activeData.tabId,
overPaneId: overData.paneId,
overTabId: overData.tabId,
targetTabs,
activeRect: {
left: translatedRect.left,
width: translatedRect.width,
},
overRect: {
left: overRect.left,
width: overRect.width,
},
})
);
return;
}
setTabDropPreview(null);
if (overData?.kind !== "split-pane-drop") {
setDropPreview(null);
return;
}
@@ -262,7 +307,7 @@ export function SplitContainer({
}),
});
},
[]
[panesById, uiTabs]
);
const handleDragEnd = useCallback(
@@ -277,6 +322,7 @@ export function SplitContainer({
if (activeData?.kind !== "workspace-tab" || !event.over) {
setDropPreview(null);
setTabDropPreview(null);
return;
}
@@ -285,32 +331,37 @@ export function SplitContainer({
const targetPane = panesById.get(overData.paneId) ?? null;
if (!sourcePane || !targetPane) {
setDropPreview(null);
setTabDropPreview(null);
return;
}
const sourceTabs = getWorkspacePaneDescriptors({ pane: sourcePane, tabs: uiTabs });
const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs });
const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId);
const targetIndex = targetTabs.findIndex((tab) => tab.tabId === overData.tabId);
if (sourceIndex < 0 || targetIndex < 0) {
const resolvedTabDropPreview =
tabDropPreview?.paneId === overData.paneId ? tabDropPreview : null;
if (sourceIndex < 0 || !resolvedTabDropPreview) {
setDropPreview(null);
setTabDropPreview(null);
return;
}
if (activeData.paneId === overData.paneId) {
if (sourceIndex !== targetIndex) {
const nextTabs = arrayMove(sourceTabs, sourceIndex, targetIndex);
if (sourceIndex !== resolvedTabDropPreview.insertionIndex) {
const nextTabs = arrayMove(sourceTabs, sourceIndex, resolvedTabDropPreview.insertionIndex);
onReorderTabsInPane(activeData.paneId, nextTabs.map((tab) => tab.tabId));
}
setDropPreview(null);
setTabDropPreview(null);
return;
}
const nextTargetTabIds = targetTabs.map((tab) => tab.tabId);
nextTargetTabIds.splice(targetIndex, 0, activeData.tabId);
nextTargetTabIds.splice(resolvedTabDropPreview.insertionIndex, 0, activeData.tabId);
onMoveTabToPane(activeData.tabId, overData.paneId);
onReorderTabsInPane(overData.paneId, nextTargetTabIds);
setDropPreview(null);
setTabDropPreview(null);
return;
}
@@ -320,6 +371,7 @@ export function SplitContainer({
onMoveTabToPane(activeData.tabId, overData.paneId);
}
setDropPreview(null);
setTabDropPreview(null);
return;
}
@@ -331,8 +383,9 @@ export function SplitContainer({
}
setDropPreview(null);
setTabDropPreview(null);
},
[dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, uiTabs]
[dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, tabDropPreview, uiTabs]
);
return (
@@ -379,6 +432,7 @@ export function SplitContainer({
activeDragTabId={activeDragTabId}
showDropZones={activeDragTabId !== null}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
/>
<DragOverlay dropAnimation={null}>
{activeDragTabId ? (
@@ -500,6 +554,7 @@ function SplitNodeView({
activeDragTabId,
showDropZones,
dropPreview,
tabDropPreview,
}: SplitNodeViewProps) {
if (node.kind === "pane") {
return (
@@ -534,6 +589,7 @@ function SplitNodeView({
activeDragTabId={activeDragTabId}
showDropZones={showDropZones}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
/>
);
}
@@ -586,6 +642,7 @@ function SplitNodeView({
activeDragTabId={activeDragTabId}
showDropZones={showDropZones}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
/>
</View>
{index < node.group.children.length - 1 ? (
@@ -634,6 +691,7 @@ function SplitPaneView({
activeDragTabId,
showDropZones,
dropPreview,
tabDropPreview,
}: SplitPaneViewProps) {
const { theme } = useUnistyles();
const paneRef = useRef<View | null>(null);
@@ -761,6 +819,7 @@ function SplitPaneView({
onSplitDown={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "bottom" })}
externalDndContext
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null}
/>
</View>

View File

@@ -62,6 +62,7 @@ type WorkspaceDesktopTabsRowProps = {
onSplitDown: () => void;
externalDndContext?: boolean;
activeDragTabId?: string | null;
tabDropPreviewIndex?: number | null;
};
function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
@@ -278,6 +279,7 @@ export function WorkspaceDesktopTabsRow({
onSplitDown,
externalDndContext = false,
activeDragTabId = null,
tabDropPreviewIndex = null,
}: WorkspaceDesktopTabsRowProps) {
const { theme } = useUnistyles();
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
@@ -362,6 +364,12 @@ export function WorkspaceDesktopTabsRow({
const layoutItem = layout.items[index] ?? null;
const resolvedTabWidth = layoutItem?.width ?? 150;
const showLabel = layoutItem?.showLabel ?? true;
const showDropIndicatorBefore =
activeDragTabId !== null && tabDropPreviewIndex === index;
const showDropIndicatorAfter =
activeDragTabId !== null &&
tabDropPreviewIndex === tabs.length &&
index === tabs.length - 1;
return (
<ResolvedDesktopTabChip
@@ -385,6 +393,8 @@ export function WorkspaceDesktopTabsRow({
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
dragHandleProps={dragHandleProps}
showDropIndicatorBefore={showDropIndicatorBefore}
showDropIndicatorAfter={showDropIndicatorAfter}
/>
);
}}
@@ -489,6 +499,8 @@ function ResolvedDesktopTabChip({
onNavigateTab,
onCloseTab,
dragHandleProps,
showDropIndicatorBefore,
showDropIndicatorAfter,
}: {
item: WorkspaceDesktopTabRowItem;
isFocused: boolean;
@@ -509,6 +521,8 @@ function ResolvedDesktopTabChip({
onNavigateTab: (tabId: string) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
dragHandleProps: any;
showDropIndicatorBefore: boolean;
showDropIndicatorAfter: boolean;
}) {
const presentation = useWorkspaceTabPresentation({
tab: item.tab,
@@ -544,24 +558,32 @@ function ResolvedDesktopTabChip({
);
return (
<TabChip
tab={item.tab}
isActive={item.isActive}
isFocused={isFocused}
resolvedTabWidth={resolvedTabWidth}
showLabel={showLabel}
showCloseButton={showCloseButton}
isCloseHovered={item.isCloseHovered}
isClosingTab={item.isClosingTab}
presentation={presentation}
tooltipLabel={tooltipLabel}
resolvedTab={resolvedTab}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
dragHandleProps={dragHandleProps}
/>
<View style={styles.tabSlot}>
{showDropIndicatorBefore ? (
<View style={[styles.tabDropIndicator, styles.tabDropIndicatorBefore]} />
) : null}
<TabChip
tab={item.tab}
isActive={item.isActive}
isFocused={isFocused}
resolvedTabWidth={resolvedTabWidth}
showLabel={showLabel}
showCloseButton={showCloseButton}
isCloseHovered={item.isCloseHovered}
isClosingTab={item.isClosingTab}
presentation={presentation}
tooltipLabel={tooltipLabel}
resolvedTab={resolvedTab}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
dragHandleProps={dragHandleProps}
/>
{showDropIndicatorAfter ? (
<View style={[styles.tabDropIndicator, styles.tabDropIndicatorAfter]} />
) : null}
</View>
);
}
@@ -604,6 +626,10 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1],
userSelect: "none",
},
tabSlot: {
position: "relative",
overflow: "visible",
},
tabHandle: {
flexDirection: "row",
alignItems: "center",
@@ -626,6 +652,22 @@ const styles = StyleSheet.create((theme) => ({
tabFocusIndicatorUnfocused: {
backgroundColor: theme.colors.borderAccent,
},
tabDropIndicator: {
position: "absolute",
top: theme.spacing[2],
bottom: theme.spacing[2],
width: 5,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
zIndex: 10,
pointerEvents: "none",
},
tabDropIndicatorBefore: {
left: -3,
},
tabDropIndicatorAfter: {
right: -3,
},
tabLabel: {
flexShrink: 1,
minWidth: 0,

View File

@@ -92,6 +92,19 @@ describe("parseAssistantFileLink", () => {
});
});
it("parses absolute POSIX hrefs that use :line:column suffixes", () => {
expect(
parseAssistantFileLink("/Users/test/project/src/app.tsx:33:1", {
workspaceRoot: "/Users/test/project",
})
).toEqual({
raw: "/Users/test/project/src/app.tsx:33:1",
path: "/Users/test/project/src/app.tsx",
lineStart: 33,
lineEnd: undefined,
});
});
it("parses absolute Windows hrefs inside the active workspace", () => {
expect(
parseAssistantFileLink("C:/repo/src/app.tsx#L12-L20", {
@@ -105,6 +118,19 @@ describe("parseAssistantFileLink", () => {
});
});
it("parses absolute Windows hrefs that use :line:column suffixes", () => {
expect(
parseAssistantFileLink("C:/repo/src/app.tsx:12:4", {
workspaceRoot: "C:/repo",
})
).toEqual({
raw: "C:/repo/src/app.tsx:12:4",
path: "C:/repo/src/app.tsx",
lineStart: 12,
lineEnd: undefined,
});
});
it("allows file URLs even when they are outside the workspace root", () => {
expect(
parseAssistantFileLink("file:///tmp/outside.txt", {

View File

@@ -51,6 +51,38 @@ function parseLineFragment(value: string): Pick<InlinePathTarget, "lineStart" |
return { lineStart, lineEnd };
}
function parseTrailingLineSuffix(pathValue: string): {
path: string;
lineStart?: number;
lineEnd?: number;
} | null {
const match = pathValue.match(/^(.*?):([0-9]+)(?::([0-9]+))?$/);
if (!match) {
return null;
}
const normalizedPath = normalizePathToken(match[1] ?? "");
if (!normalizedPath) {
return null;
}
const lineStart = parseInt(match[2] ?? "", 10);
if (!Number.isFinite(lineStart) || lineStart <= 0) {
return null;
}
const column = match[3] ? parseInt(match[3], 10) : undefined;
if (column !== undefined && (!Number.isFinite(column) || column <= 0)) {
return null;
}
return {
path: normalizedPath,
lineStart,
lineEnd: undefined,
};
}
/**
* Strict VSCode-style markers only.
*
@@ -166,12 +198,15 @@ export function parseAssistantFileLink(
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
if (windowsPathMatch) {
const normalizedPath = normalizePathToken(windowsPathMatch[1] ?? "");
const rawWindowsPath = windowsPathMatch[1] ?? "";
const windowsPathWithLine = parseTrailingLineSuffix(rawWindowsPath);
const normalizedPath =
windowsPathWithLine?.path ?? normalizePathToken(rawWindowsPath);
if (!normalizedPath || !isAllowedAbsolutePath(normalizedPath, options.workspaceRoot)) {
return null;
}
const lines = parseLineFragment(windowsPathMatch[2] ?? "");
const lines = windowsPathWithLine ?? parseLineFragment(windowsPathMatch[2] ?? "");
if (!lines) {
return null;
}
@@ -194,7 +229,9 @@ export function parseAssistantFileLink(
return null;
}
const normalizedPath = normalizePathToken(decodeURIComponent(parsedUrl.pathname));
const rawPosixPath = decodeURIComponent(parsedUrl.pathname);
const posixPathWithLine = parseTrailingLineSuffix(rawPosixPath);
const normalizedPath = posixPathWithLine?.path ?? normalizePathToken(rawPosixPath);
if (!normalizedPath || !normalizedPath.startsWith("/")) {
return null;
}
@@ -203,7 +240,7 @@ export function parseAssistantFileLink(
return null;
}
const lines = parseLineFragment(parsedUrl.hash);
const lines = posixPathWithLine ?? parseLineFragment(parsedUrl.hash);
if (!lines) {
return null;
}