feat: add tab drop preview indicator for split pane drag-and-drop
This commit is contained in:
@@ -61,10 +61,9 @@ function SortableItem<T>({
|
|||||||
// This is a no-op but matches the mobile API
|
// This is a no-op but matches the mobile API
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// When using an external DndContext (e.g. split pane container), hide the
|
// External DnD contexts render their own insertion affordance, so keep the
|
||||||
// original item during drag so the DragOverlay renders the floating copy.
|
// tab row static and let the DragOverlay carry the moving chip.
|
||||||
// In standalone mode, keep the existing in-place drag visual.
|
const baseTransform = externalDndContext
|
||||||
const baseTransform = externalDndContext && isDragging
|
|
||||||
? undefined
|
? undefined
|
||||||
: CSS.Transform.toString(
|
: CSS.Transform.toString(
|
||||||
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform
|
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -18,6 +18,10 @@ import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
|||||||
import { Platform, View, Text } from "react-native";
|
import { Platform, View, Text } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { ResizeHandle } from "@/components/resize-handle";
|
import { ResizeHandle } from "@/components/resize-handle";
|
||||||
|
import {
|
||||||
|
computeTabDropPreview,
|
||||||
|
type TabDropPreview,
|
||||||
|
} from "@/components/split-container-tab-drop-preview";
|
||||||
import {
|
import {
|
||||||
SplitDropZone,
|
SplitDropZone,
|
||||||
resolveSplitDropPosition,
|
resolveSplitDropPosition,
|
||||||
@@ -104,6 +108,7 @@ interface SplitNodeViewProps
|
|||||||
activeDragTabId: string | null;
|
activeDragTabId: string | null;
|
||||||
showDropZones: boolean;
|
showDropZones: boolean;
|
||||||
dropPreview: SplitDropZoneHover | null;
|
dropPreview: SplitDropZoneHover | null;
|
||||||
|
tabDropPreview: TabDropPreview | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SplitPaneViewProps
|
interface SplitPaneViewProps
|
||||||
@@ -124,6 +129,7 @@ interface SplitPaneViewProps
|
|||||||
activeDragTabId: string | null;
|
activeDragTabId: string | null;
|
||||||
showDropZones: boolean;
|
showDropZones: boolean;
|
||||||
dropPreview: SplitDropZoneHover | null;
|
dropPreview: SplitDropZoneHover | null;
|
||||||
|
tabDropPreview: TabDropPreview | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dropCollisionDetection: CollisionDetection = (args) => {
|
const dropCollisionDetection: CollisionDetection = (args) => {
|
||||||
@@ -178,6 +184,7 @@ export function SplitContainer({
|
|||||||
}: SplitContainerProps) {
|
}: SplitContainerProps) {
|
||||||
const [activeDragTabId, setActiveDragTabId] = useState<string | null>(null);
|
const [activeDragTabId, setActiveDragTabId] = useState<string | null>(null);
|
||||||
const [dropPreview, setDropPreview] = useState<SplitDropZoneHover | null>(null);
|
const [dropPreview, setDropPreview] = useState<SplitDropZoneHover | null>(null);
|
||||||
|
const [tabDropPreview, setTabDropPreview] = useState<TabDropPreview | null>(null);
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
@@ -197,6 +204,7 @@ export function SplitContainer({
|
|||||||
if (data?.kind !== "workspace-tab") {
|
if (data?.kind !== "workspace-tab") {
|
||||||
setActiveDragTabId(null);
|
setActiveDragTabId(null);
|
||||||
setDropPreview(null);
|
setDropPreview(null);
|
||||||
|
setTabDropPreview(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setActiveDragTabId(data.tabId);
|
setActiveDragTabId(data.tabId);
|
||||||
@@ -205,6 +213,7 @@ export function SplitContainer({
|
|||||||
const handleDragCancel = useCallback(() => {
|
const handleDragCancel = useCallback(() => {
|
||||||
setActiveDragTabId(null);
|
setActiveDragTabId(null);
|
||||||
setDropPreview(null);
|
setDropPreview(null);
|
||||||
|
setTabDropPreview(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateDropPreview = useCallback(
|
const updateDropPreview = useCallback(
|
||||||
@@ -221,17 +230,53 @@ export function SplitContainer({
|
|||||||
|
|
||||||
if (activeData?.kind !== "workspace-tab") {
|
if (activeData?.kind !== "workspace-tab") {
|
||||||
setDropPreview(null);
|
setDropPreview(null);
|
||||||
return;
|
setTabDropPreview(null);
|
||||||
}
|
|
||||||
|
|
||||||
if (overData?.kind !== "split-pane-drop") {
|
|
||||||
setDropPreview(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const translatedRect = event.active.rect.current.translated;
|
const translatedRect = event.active.rect.current.translated;
|
||||||
const overRect = event.over?.rect;
|
const overRect = event.over?.rect;
|
||||||
if (!translatedRect || !overRect || overRect.width <= 0 || overRect.height <= 0) {
|
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);
|
setDropPreview(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -262,7 +307,7 @@ export function SplitContainer({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[]
|
[panesById, uiTabs]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
@@ -277,6 +322,7 @@ export function SplitContainer({
|
|||||||
|
|
||||||
if (activeData?.kind !== "workspace-tab" || !event.over) {
|
if (activeData?.kind !== "workspace-tab" || !event.over) {
|
||||||
setDropPreview(null);
|
setDropPreview(null);
|
||||||
|
setTabDropPreview(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,32 +331,37 @@ export function SplitContainer({
|
|||||||
const targetPane = panesById.get(overData.paneId) ?? null;
|
const targetPane = panesById.get(overData.paneId) ?? null;
|
||||||
if (!sourcePane || !targetPane) {
|
if (!sourcePane || !targetPane) {
|
||||||
setDropPreview(null);
|
setDropPreview(null);
|
||||||
|
setTabDropPreview(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceTabs = getWorkspacePaneDescriptors({ pane: sourcePane, tabs: uiTabs });
|
const sourceTabs = getWorkspacePaneDescriptors({ pane: sourcePane, tabs: uiTabs });
|
||||||
const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs });
|
const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs });
|
||||||
const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId);
|
const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId);
|
||||||
const targetIndex = targetTabs.findIndex((tab) => tab.tabId === overData.tabId);
|
const resolvedTabDropPreview =
|
||||||
if (sourceIndex < 0 || targetIndex < 0) {
|
tabDropPreview?.paneId === overData.paneId ? tabDropPreview : null;
|
||||||
|
if (sourceIndex < 0 || !resolvedTabDropPreview) {
|
||||||
setDropPreview(null);
|
setDropPreview(null);
|
||||||
|
setTabDropPreview(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeData.paneId === overData.paneId) {
|
if (activeData.paneId === overData.paneId) {
|
||||||
if (sourceIndex !== targetIndex) {
|
if (sourceIndex !== resolvedTabDropPreview.insertionIndex) {
|
||||||
const nextTabs = arrayMove(sourceTabs, sourceIndex, targetIndex);
|
const nextTabs = arrayMove(sourceTabs, sourceIndex, resolvedTabDropPreview.insertionIndex);
|
||||||
onReorderTabsInPane(activeData.paneId, nextTabs.map((tab) => tab.tabId));
|
onReorderTabsInPane(activeData.paneId, nextTabs.map((tab) => tab.tabId));
|
||||||
}
|
}
|
||||||
setDropPreview(null);
|
setDropPreview(null);
|
||||||
|
setTabDropPreview(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextTargetTabIds = targetTabs.map((tab) => tab.tabId);
|
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);
|
onMoveTabToPane(activeData.tabId, overData.paneId);
|
||||||
onReorderTabsInPane(overData.paneId, nextTargetTabIds);
|
onReorderTabsInPane(overData.paneId, nextTargetTabIds);
|
||||||
setDropPreview(null);
|
setDropPreview(null);
|
||||||
|
setTabDropPreview(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,6 +371,7 @@ export function SplitContainer({
|
|||||||
onMoveTabToPane(activeData.tabId, overData.paneId);
|
onMoveTabToPane(activeData.tabId, overData.paneId);
|
||||||
}
|
}
|
||||||
setDropPreview(null);
|
setDropPreview(null);
|
||||||
|
setTabDropPreview(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,8 +383,9 @@ export function SplitContainer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setDropPreview(null);
|
setDropPreview(null);
|
||||||
|
setTabDropPreview(null);
|
||||||
},
|
},
|
||||||
[dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, uiTabs]
|
[dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, tabDropPreview, uiTabs]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -379,6 +432,7 @@ export function SplitContainer({
|
|||||||
activeDragTabId={activeDragTabId}
|
activeDragTabId={activeDragTabId}
|
||||||
showDropZones={activeDragTabId !== null}
|
showDropZones={activeDragTabId !== null}
|
||||||
dropPreview={dropPreview}
|
dropPreview={dropPreview}
|
||||||
|
tabDropPreview={tabDropPreview}
|
||||||
/>
|
/>
|
||||||
<DragOverlay dropAnimation={null}>
|
<DragOverlay dropAnimation={null}>
|
||||||
{activeDragTabId ? (
|
{activeDragTabId ? (
|
||||||
@@ -500,6 +554,7 @@ function SplitNodeView({
|
|||||||
activeDragTabId,
|
activeDragTabId,
|
||||||
showDropZones,
|
showDropZones,
|
||||||
dropPreview,
|
dropPreview,
|
||||||
|
tabDropPreview,
|
||||||
}: SplitNodeViewProps) {
|
}: SplitNodeViewProps) {
|
||||||
if (node.kind === "pane") {
|
if (node.kind === "pane") {
|
||||||
return (
|
return (
|
||||||
@@ -534,6 +589,7 @@ function SplitNodeView({
|
|||||||
activeDragTabId={activeDragTabId}
|
activeDragTabId={activeDragTabId}
|
||||||
showDropZones={showDropZones}
|
showDropZones={showDropZones}
|
||||||
dropPreview={dropPreview}
|
dropPreview={dropPreview}
|
||||||
|
tabDropPreview={tabDropPreview}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -586,6 +642,7 @@ function SplitNodeView({
|
|||||||
activeDragTabId={activeDragTabId}
|
activeDragTabId={activeDragTabId}
|
||||||
showDropZones={showDropZones}
|
showDropZones={showDropZones}
|
||||||
dropPreview={dropPreview}
|
dropPreview={dropPreview}
|
||||||
|
tabDropPreview={tabDropPreview}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{index < node.group.children.length - 1 ? (
|
{index < node.group.children.length - 1 ? (
|
||||||
@@ -634,6 +691,7 @@ function SplitPaneView({
|
|||||||
activeDragTabId,
|
activeDragTabId,
|
||||||
showDropZones,
|
showDropZones,
|
||||||
dropPreview,
|
dropPreview,
|
||||||
|
tabDropPreview,
|
||||||
}: SplitPaneViewProps) {
|
}: SplitPaneViewProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const paneRef = useRef<View | null>(null);
|
const paneRef = useRef<View | null>(null);
|
||||||
@@ -761,6 +819,7 @@ function SplitPaneView({
|
|||||||
onSplitDown={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "bottom" })}
|
onSplitDown={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "bottom" })}
|
||||||
externalDndContext
|
externalDndContext
|
||||||
activeDragTabId={activeDragTabId}
|
activeDragTabId={activeDragTabId}
|
||||||
|
tabDropPreviewIndex={tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ type WorkspaceDesktopTabsRowProps = {
|
|||||||
onSplitDown: () => void;
|
onSplitDown: () => void;
|
||||||
externalDndContext?: boolean;
|
externalDndContext?: boolean;
|
||||||
activeDragTabId?: string | null;
|
activeDragTabId?: string | null;
|
||||||
|
tabDropPreviewIndex?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
|
function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
|
||||||
@@ -278,6 +279,7 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
onSplitDown,
|
onSplitDown,
|
||||||
externalDndContext = false,
|
externalDndContext = false,
|
||||||
activeDragTabId = null,
|
activeDragTabId = null,
|
||||||
|
tabDropPreviewIndex = null,
|
||||||
}: WorkspaceDesktopTabsRowProps) {
|
}: WorkspaceDesktopTabsRowProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
|
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
|
||||||
@@ -362,6 +364,12 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
const layoutItem = layout.items[index] ?? null;
|
const layoutItem = layout.items[index] ?? null;
|
||||||
const resolvedTabWidth = layoutItem?.width ?? 150;
|
const resolvedTabWidth = layoutItem?.width ?? 150;
|
||||||
const showLabel = layoutItem?.showLabel ?? true;
|
const showLabel = layoutItem?.showLabel ?? true;
|
||||||
|
const showDropIndicatorBefore =
|
||||||
|
activeDragTabId !== null && tabDropPreviewIndex === index;
|
||||||
|
const showDropIndicatorAfter =
|
||||||
|
activeDragTabId !== null &&
|
||||||
|
tabDropPreviewIndex === tabs.length &&
|
||||||
|
index === tabs.length - 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResolvedDesktopTabChip
|
<ResolvedDesktopTabChip
|
||||||
@@ -385,6 +393,8 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
onNavigateTab={onNavigateTab}
|
onNavigateTab={onNavigateTab}
|
||||||
onCloseTab={onCloseTab}
|
onCloseTab={onCloseTab}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
|
showDropIndicatorBefore={showDropIndicatorBefore}
|
||||||
|
showDropIndicatorAfter={showDropIndicatorAfter}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -489,6 +499,8 @@ function ResolvedDesktopTabChip({
|
|||||||
onNavigateTab,
|
onNavigateTab,
|
||||||
onCloseTab,
|
onCloseTab,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
|
showDropIndicatorBefore,
|
||||||
|
showDropIndicatorAfter,
|
||||||
}: {
|
}: {
|
||||||
item: WorkspaceDesktopTabRowItem;
|
item: WorkspaceDesktopTabRowItem;
|
||||||
isFocused: boolean;
|
isFocused: boolean;
|
||||||
@@ -509,6 +521,8 @@ function ResolvedDesktopTabChip({
|
|||||||
onNavigateTab: (tabId: string) => void;
|
onNavigateTab: (tabId: string) => void;
|
||||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||||
dragHandleProps: any;
|
dragHandleProps: any;
|
||||||
|
showDropIndicatorBefore: boolean;
|
||||||
|
showDropIndicatorAfter: boolean;
|
||||||
}) {
|
}) {
|
||||||
const presentation = useWorkspaceTabPresentation({
|
const presentation = useWorkspaceTabPresentation({
|
||||||
tab: item.tab,
|
tab: item.tab,
|
||||||
@@ -544,24 +558,32 @@ function ResolvedDesktopTabChip({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabChip
|
<View style={styles.tabSlot}>
|
||||||
tab={item.tab}
|
{showDropIndicatorBefore ? (
|
||||||
isActive={item.isActive}
|
<View style={[styles.tabDropIndicator, styles.tabDropIndicatorBefore]} />
|
||||||
isFocused={isFocused}
|
) : null}
|
||||||
resolvedTabWidth={resolvedTabWidth}
|
<TabChip
|
||||||
showLabel={showLabel}
|
tab={item.tab}
|
||||||
showCloseButton={showCloseButton}
|
isActive={item.isActive}
|
||||||
isCloseHovered={item.isCloseHovered}
|
isFocused={isFocused}
|
||||||
isClosingTab={item.isClosingTab}
|
resolvedTabWidth={resolvedTabWidth}
|
||||||
presentation={presentation}
|
showLabel={showLabel}
|
||||||
tooltipLabel={tooltipLabel}
|
showCloseButton={showCloseButton}
|
||||||
resolvedTab={resolvedTab}
|
isCloseHovered={item.isCloseHovered}
|
||||||
setHoveredTabKey={setHoveredTabKey}
|
isClosingTab={item.isClosingTab}
|
||||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
presentation={presentation}
|
||||||
onNavigateTab={onNavigateTab}
|
tooltipLabel={tooltipLabel}
|
||||||
onCloseTab={onCloseTab}
|
resolvedTab={resolvedTab}
|
||||||
dragHandleProps={dragHandleProps}
|
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],
|
gap: theme.spacing[1],
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
},
|
},
|
||||||
|
tabSlot: {
|
||||||
|
position: "relative",
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
tabHandle: {
|
tabHandle: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -626,6 +652,22 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
tabFocusIndicatorUnfocused: {
|
tabFocusIndicatorUnfocused: {
|
||||||
backgroundColor: theme.colors.borderAccent,
|
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: {
|
tabLabel: {
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
|
|||||||
@@ -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", () => {
|
it("parses absolute Windows hrefs inside the active workspace", () => {
|
||||||
expect(
|
expect(
|
||||||
parseAssistantFileLink("C:/repo/src/app.tsx#L12-L20", {
|
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", () => {
|
it("allows file URLs even when they are outside the workspace root", () => {
|
||||||
expect(
|
expect(
|
||||||
parseAssistantFileLink("file:///tmp/outside.txt", {
|
parseAssistantFileLink("file:///tmp/outside.txt", {
|
||||||
|
|||||||
@@ -51,6 +51,38 @@ function parseLineFragment(value: string): Pick<InlinePathTarget, "lineStart" |
|
|||||||
return { lineStart, lineEnd };
|
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.
|
* Strict VSCode-style markers only.
|
||||||
*
|
*
|
||||||
@@ -166,12 +198,15 @@ export function parseAssistantFileLink(
|
|||||||
|
|
||||||
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
|
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
|
||||||
if (windowsPathMatch) {
|
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)) {
|
if (!normalizedPath || !isAllowedAbsolutePath(normalizedPath, options.workspaceRoot)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = parseLineFragment(windowsPathMatch[2] ?? "");
|
const lines = windowsPathWithLine ?? parseLineFragment(windowsPathMatch[2] ?? "");
|
||||||
if (!lines) {
|
if (!lines) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -194,7 +229,9 @@ export function parseAssistantFileLink(
|
|||||||
return null;
|
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("/")) {
|
if (!normalizedPath || !normalizedPath.startsWith("/")) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -203,7 +240,7 @@ export function parseAssistantFileLink(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = parseLineFragment(parsedUrl.hash);
|
const lines = posixPathWithLine ?? parseLineFragment(parsedUrl.hash);
|
||||||
if (!lines) {
|
if (!lines) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user