diff --git a/packages/app/src/components/sortable-inline-list.web.tsx b/packages/app/src/components/sortable-inline-list.web.tsx index 7b7556e1d..f8ac76cdd 100644 --- a/packages/app/src/components/sortable-inline-list.web.tsx +++ b/packages/app/src/components/sortable-inline-list.web.tsx @@ -61,10 +61,9 @@ function SortableItem({ // 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 diff --git a/packages/app/src/components/split-container-tab-drop-preview.test.ts b/packages/app/src/components/split-container-tab-drop-preview.test.ts new file mode 100644 index 000000000..87e5666c8 --- /dev/null +++ b/packages/app/src/components/split-container-tab-drop-preview.test.ts @@ -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, + }); + }); +}); diff --git a/packages/app/src/components/split-container-tab-drop-preview.ts b/packages/app/src/components/split-container-tab-drop-preview.ts new file mode 100644 index 000000000..4abfd724d --- /dev/null +++ b/packages/app/src/components/split-container-tab-drop-preview.ts @@ -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, + }; +} diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index d4c271e1e..8ffb982fe 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -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(null); const [dropPreview, setDropPreview] = useState(null); + const [tabDropPreview, setTabDropPreview] = useState(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} /> {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} /> {index < node.group.children.length - 1 ? ( @@ -634,6 +691,7 @@ function SplitPaneView({ activeDragTabId, showDropZones, dropPreview, + tabDropPreview, }: SplitPaneViewProps) { const { theme } = useUnistyles(); const paneRef = useRef(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} /> diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index ffda56982..577f2db9b 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -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(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 ( ); }} @@ -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; dragHandleProps: any; + showDropIndicatorBefore: boolean; + showDropIndicatorAfter: boolean; }) { const presentation = useWorkspaceTabPresentation({ tab: item.tab, @@ -544,24 +558,32 @@ function ResolvedDesktopTabChip({ ); return ( - + + {showDropIndicatorBefore ? ( + + ) : null} + + {showDropIndicatorAfter ? ( + + ) : null} + ); } @@ -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, diff --git a/packages/app/src/utils/inline-path.test.ts b/packages/app/src/utils/inline-path.test.ts index cab631240..86258163a 100644 --- a/packages/app/src/utils/inline-path.test.ts +++ b/packages/app/src/utils/inline-path.test.ts @@ -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", { diff --git a/packages/app/src/utils/inline-path.ts b/packages/app/src/utils/inline-path.ts index d11f1c19f..4a14a89bb 100644 --- a/packages/app/src/utils/inline-path.ts +++ b/packages/app/src/utils/inline-path.ts @@ -51,6 +51,38 @@ function parseLineFragment(value: string): Pick