mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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
|
||||
}, []);
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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 { 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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user