feat: improve terminal scrollbar and pane focus handling

This commit is contained in:
Mohamed Boudra
2026-03-18 20:39:16 +07:00
parent ed9a15c0bd
commit 7e0a220fde
9 changed files with 503 additions and 129 deletions
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
describe("shouldFocusPaneFromEventTarget", () => {
it("returns false for links and buttons", () => {
expect(
shouldFocusPaneFromEventTarget({
closest: () => ({ tagName: "A" } as Element),
} as unknown as EventTarget)
).toBe(false);
expect(
shouldFocusPaneFromEventTarget({
closest: () => ({ tagName: "BUTTON" } as Element),
} as unknown as EventTarget)
).toBe(false);
});
it("returns true for non-interactive pane content", () => {
expect(
shouldFocusPaneFromEventTarget({
closest: () => null,
} as unknown as EventTarget)
).toBe(true);
expect(shouldFocusPaneFromEventTarget(null)).toBe(true);
});
});
@@ -0,0 +1,20 @@
const INTERACTIVE_TARGET_SELECTOR = [
"a",
"button",
"input",
"select",
"textarea",
"[role='button']",
"[role='link']",
"[contenteditable='true']",
"[data-paseo-pane-focus-exempt='true']",
].join(", ");
export function shouldFocusPaneFromEventTarget(target: EventTarget | null): boolean {
const candidate = target as unknown as { closest?: (selector: string) => Element | null } | null;
if (!candidate || typeof candidate.closest !== "function") {
return true;
}
return !candidate.closest(INTERACTIVE_TARGET_SELECTOR);
}
+17 -12
View File
@@ -18,6 +18,7 @@ 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 { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
import {
computeTabDropPreview,
type TabDropPreview,
@@ -67,7 +68,7 @@ interface SplitContainerProps {
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onSelectNewTabOption: (selection: { optionId: "__new_tab_agent__"; paneId?: string }) => void;
onNewTerminalTab: () => void;
onNewTerminalTab: (input: { paneId?: string }) => void;
newTabAgentOptionId?: "__new_tab_agent__";
buildPaneContentModel: (input: {
paneId: string;
@@ -768,16 +769,26 @@ function SplitPaneView({
return;
}
const handlePaneInteraction = () => {
const handlePanePointerDown = (event: PointerEvent) => {
if (!shouldFocusPaneFromEventTarget(event.target)) {
return;
}
onFocusPane(pane.id);
};
paneElement.addEventListener("pointerdown", handlePaneInteraction, true);
paneElement.addEventListener("focusin", handlePaneInteraction, true);
const handlePaneFocusIn = (event: FocusEvent) => {
if (!shouldFocusPaneFromEventTarget(event.target)) {
return;
}
onFocusPane(pane.id);
};
paneElement.addEventListener("pointerdown", handlePanePointerDown, true);
paneElement.addEventListener("focusin", handlePaneFocusIn, true);
return () => {
paneElement.removeEventListener("pointerdown", handlePaneInteraction, true);
paneElement.removeEventListener("focusin", handlePaneInteraction, true);
paneElement.removeEventListener("pointerdown", handlePanePointerDown, true);
paneElement.removeEventListener("focusin", handlePaneFocusIn, true);
};
}, [onFocusPane, pane.id]);
@@ -786,12 +797,6 @@ function SplitPaneView({
ref={paneRef}
collapsable={false}
style={styles.pane}
onFocus={() => {
onFocusPane(pane.id);
}}
onPointerDownCapture={() => {
onFocusPane(pane.id);
}}
>
<View style={styles.paneTabs}>
<WorkspaceDesktopTabsRow
+61 -24
View File
@@ -29,7 +29,7 @@ export function SplitDropZone({
preview,
}: SplitDropZoneProps) {
const { theme } = useUnistyles();
const { setNodeRef, isOver } = useDroppable({
const { setNodeRef } = useDroppable({
id: buildSplitDropZoneId(paneId),
disabled: !active,
data: {
@@ -38,17 +38,27 @@ export function SplitDropZone({
},
});
const previewStyle = useMemo(() => {
const previewStyles = useMemo(() => {
if (!preview || preview.paneId !== paneId) {
return null;
}
return [
styles.preview,
getPreviewStyle(preview.position),
{
backgroundColor: theme.colors.accent,
},
];
return {
overlay: [
styles.previewOverlay,
getPreviewOverlayStyle(preview.position),
{
backgroundColor: theme.colors.accent,
opacity: 0.6,
},
],
frame: [
styles.previewFrame,
getPreviewFrameStyle(preview.position),
{
borderColor: theme.colors.accent,
},
],
};
}, [paneId, preview, theme.colors.accent]);
if (!active) {
@@ -58,10 +68,15 @@ export function SplitDropZone({
return (
<View
ref={setNodeRef as any}
style={[styles.overlay, isOver && styles.overlayActive]}
style={styles.overlay}
pointerEvents="none"
>
{previewStyle ? <View pointerEvents="none" style={previewStyle} /> : null}
{previewStyles ? (
<>
<View pointerEvents="none" style={previewStyles.overlay} />
<View pointerEvents="none" style={previewStyles.frame} />
</>
) : null}
</View>
);
}
@@ -108,7 +123,7 @@ export function resolveSplitDropPosition(input: {
return distances[0]?.position ?? "center";
}
function getPreviewStyle(position: SplitDropZonePosition) {
function getPreviewOverlayStyle(position: SplitDropZonePosition) {
if (position === "left") {
return styles.previewLeft;
}
@@ -121,7 +136,23 @@ function getPreviewStyle(position: SplitDropZonePosition) {
if (position === "bottom") {
return styles.previewBottom;
}
return styles.previewCenter;
return styles.previewCenterOverlay;
}
function getPreviewFrameStyle(position: SplitDropZonePosition) {
if (position === "left") {
return styles.previewLeft;
}
if (position === "right") {
return styles.previewRight;
}
if (position === "top") {
return styles.previewTop;
}
if (position === "bottom") {
return styles.previewBottom;
}
return styles.previewCenterFrame;
}
const styles = StyleSheet.create((theme) => ({
@@ -129,14 +160,14 @@ const styles = StyleSheet.create((theme) => ({
...StyleSheet.absoluteFillObject,
zIndex: 40,
},
overlayActive: {
backgroundColor: theme.colors.surface0,
opacity: 0.03,
},
preview: {
previewOverlay: {
position: "absolute",
borderRadius: theme.borderRadius.md,
opacity: 0.2,
},
previewFrame: {
position: "absolute",
borderRadius: theme.borderRadius.md,
borderWidth: 2,
},
previewLeft: {
left: 0,
@@ -162,10 +193,16 @@ const styles = StyleSheet.create((theme) => ({
bottom: 0,
height: "50%",
},
previewCenter: {
left: "30%",
top: "30%",
right: "30%",
bottom: "30%",
previewCenterOverlay: {
left: theme.spacing[2],
top: theme.spacing[2],
right: theme.spacing[2],
bottom: theme.spacing[2],
},
previewCenterFrame: {
left: theme.spacing[2],
top: theme.spacing[2],
right: theme.spacing[2],
bottom: theme.spacing[2],
},
}));
@@ -1,12 +1,36 @@
"use dom";
import { useEffect, useMemo, useRef } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { DOMProps } from "expo/dom";
import "@xterm/xterm/css/xterm.css";
import type { ITheme } from "@xterm/xterm";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
import { focusWithRetries } from "../utils/web-focus";
import {
computeScrollOffsetFromDragDelta,
computeVerticalScrollbarGeometry,
} from "./web-desktop-scrollbar.math";
const SCROLLBAR_HANDLE_WIDTH_IDLE = 6;
const SCROLLBAR_HANDLE_WIDTH_ACTIVE = 9;
const SCROLLBAR_HANDLE_GRAB_WIDTH = 18;
const SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING = 8;
const SCROLLBAR_HANDLE_OPACITY_VISIBLE = 0.62;
const SCROLLBAR_HANDLE_OPACITY_HOVERED = 0.78;
const SCROLLBAR_HANDLE_OPACITY_DRAGGING = 0.9;
const SCROLLBAR_HANDLE_FADE_DURATION_MS = 220;
const SCROLLBAR_HANDLE_WIDTH_TRANSITION_DURATION_MS = 240;
const SCROLLBAR_HANDLE_TRAVEL_DURATION_MS = 90;
const SCROLLBAR_HANDLE_SCROLL_VISIBILITY_MS = 1_200;
const SCROLLBAR_HANDLE_SCROLL_ACTIVE_MS = 110;
const WEBKIT_SCROLLBAR_STYLE_ID = "terminal-emulator-webkit-scrollbar-style";
type ViewportMetrics = {
offset: number;
viewportSize: number;
contentSize: number;
};
function buildXtermThemeKey(theme: ITheme): string {
const values: Array<string> = [
@@ -70,6 +94,34 @@ declare global {
interface Window {}
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function ensureTerminalScrollbarStyle(): void {
if (typeof document === "undefined") {
return;
}
if (document.getElementById(WEBKIT_SCROLLBAR_STYLE_ID)) {
return;
}
const styleElement = document.createElement("style");
styleElement.id = WEBKIT_SCROLLBAR_STYLE_ID;
styleElement.textContent = `
[data-terminal-scrollbar-root="true"] .xterm-viewport {
scrollbar-width: none;
-ms-overflow-style: none;
}
[data-terminal-scrollbar-root="true"] .xterm-viewport::-webkit-scrollbar {
width: 0;
height: 0;
}
`;
document.head.appendChild(styleElement);
}
export default function TerminalEmulator({
streamKey,
initialOutputText,
@@ -100,13 +152,32 @@ export default function TerminalEmulator({
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
const appliedChunkSequenceRef = useRef(0);
const mountedThemeRef = useRef<ITheme>(xtermTheme);
const viewportRef = useRef<HTMLElement | null>(null);
const dragStartOffsetRef = useRef(0);
const dragStartClientYRef = useRef(0);
const scrollVisibilityTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const scrollActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastObservedOffsetRef = useRef<number | null>(null);
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
const [viewportMetrics, setViewportMetrics] = useState<ViewportMetrics>({
offset: 0,
viewportSize: 0,
contentSize: 0,
});
const [isHandleHovered, setIsHandleHovered] = useState(false);
const [isDraggingScrollbar, setIsDraggingScrollbar] = useState(false);
const [isScrollVisible, setIsScrollVisible] = useState(false);
const [isScrollActive, setIsScrollActive] = useState(false);
useEffect(() => {
mountedThemeRef.current = xtermTheme;
runtimeRef.current?.setTheme({ theme: xtermTheme });
}, [themeKey]);
useEffect(() => {
ensureTerminalScrollbarStyle();
}, []);
useEffect(() => {
const root = rootRef.current;
if (!root || !swipeGesturesEnabled) {
@@ -337,10 +408,206 @@ export default function TerminalEmulator({
runtimeRef.current?.resize({ force: true });
}, [resizeRequestToken]);
useEffect(() => {
const host = hostRef.current;
if (!host) {
return;
}
const viewportElement = host.querySelector<HTMLElement>(".xterm-viewport");
if (!viewportElement) {
viewportRef.current = null;
setViewportMetrics({ offset: 0, viewportSize: 0, contentSize: 0 });
return;
}
viewportRef.current = viewportElement;
const updateViewportMetrics = () => {
setViewportMetrics({
offset: Math.max(0, viewportElement.scrollTop),
viewportSize: Math.max(0, viewportElement.clientHeight),
contentSize: Math.max(0, viewportElement.scrollHeight),
});
};
updateViewportMetrics();
const handleViewportScroll = () => {
updateViewportMetrics();
};
const resizeObserver = new ResizeObserver(() => {
updateViewportMetrics();
});
resizeObserver.observe(viewportElement);
const scrollAreaElement = host.querySelector<HTMLElement>(".xterm-scroll-area");
if (scrollAreaElement) {
resizeObserver.observe(scrollAreaElement);
}
const mutationObserver = new MutationObserver(() => {
updateViewportMetrics();
});
mutationObserver.observe(host, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["style", "class"],
});
viewportElement.addEventListener("scroll", handleViewportScroll, { passive: true });
return () => {
viewportElement.removeEventListener("scroll", handleViewportScroll);
resizeObserver.disconnect();
mutationObserver.disconnect();
if (viewportRef.current === viewportElement) {
viewportRef.current = null;
}
};
}, [streamKey]);
useEffect(() => {
const maxScrollOffset = Math.max(
0,
viewportMetrics.contentSize - viewportMetrics.viewportSize
);
const normalizedOffset = clamp(viewportMetrics.offset, 0, maxScrollOffset);
if (maxScrollOffset <= 0 || viewportMetrics.viewportSize <= 0) {
setIsScrollVisible(false);
setIsScrollActive(false);
lastObservedOffsetRef.current = null;
return;
}
const previousOffset = lastObservedOffsetRef.current;
lastObservedOffsetRef.current = normalizedOffset;
if (previousOffset === null || Math.abs(previousOffset - normalizedOffset) <= 0.5) {
return;
}
setIsScrollVisible(true);
if (scrollVisibilityTimeoutRef.current !== null) {
clearTimeout(scrollVisibilityTimeoutRef.current);
}
scrollVisibilityTimeoutRef.current = setTimeout(() => {
setIsScrollVisible(false);
scrollVisibilityTimeoutRef.current = null;
}, SCROLLBAR_HANDLE_SCROLL_VISIBILITY_MS);
setIsScrollActive(true);
if (scrollActiveTimeoutRef.current !== null) {
clearTimeout(scrollActiveTimeoutRef.current);
}
scrollActiveTimeoutRef.current = setTimeout(() => {
setIsScrollActive(false);
scrollActiveTimeoutRef.current = null;
}, SCROLLBAR_HANDLE_SCROLL_ACTIVE_MS);
}, [
viewportMetrics.contentSize,
viewportMetrics.offset,
viewportMetrics.viewportSize,
]);
useEffect(() => {
return () => {
if (scrollVisibilityTimeoutRef.current !== null) {
clearTimeout(scrollVisibilityTimeoutRef.current);
}
if (scrollActiveTimeoutRef.current !== null) {
clearTimeout(scrollActiveTimeoutRef.current);
}
};
}, []);
const scrollbarGeometry = useMemo(
() =>
computeVerticalScrollbarGeometry({
viewportSize: viewportMetrics.viewportSize,
contentSize: viewportMetrics.contentSize,
offset: viewportMetrics.offset,
}),
[viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize]
);
useEffect(() => {
if (!isDraggingScrollbar) {
return;
}
const handlePointerMove = (event: PointerEvent) => {
const dragDelta = event.clientY - dragStartClientYRef.current;
const nextOffset = computeScrollOffsetFromDragDelta({
startOffset: dragStartOffsetRef.current,
dragDelta,
maxScrollOffset: scrollbarGeometry.maxScrollOffset,
maxHandleOffset: scrollbarGeometry.maxHandleOffset,
});
const viewportElement = viewportRef.current;
if (!viewportElement) {
return;
}
viewportElement.scrollTop = nextOffset;
setViewportMetrics({
offset: nextOffset,
viewportSize: Math.max(0, viewportElement.clientHeight),
contentSize: Math.max(0, viewportElement.scrollHeight),
});
};
const stopDragging = () => {
setIsDraggingScrollbar(false);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", stopDragging);
window.addEventListener("pointercancel", stopDragging);
return () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", stopDragging);
window.removeEventListener("pointercancel", stopDragging);
};
}, [
isDraggingScrollbar,
scrollbarGeometry.maxHandleOffset,
scrollbarGeometry.maxScrollOffset,
]);
const handleVisible =
scrollbarGeometry.isVisible && (isDraggingScrollbar || isScrollVisible || isHandleHovered);
const handleOpacity = isDraggingScrollbar
? SCROLLBAR_HANDLE_OPACITY_DRAGGING
: isHandleHovered
? SCROLLBAR_HANDLE_OPACITY_HOVERED
: isScrollVisible
? SCROLLBAR_HANDLE_OPACITY_VISIBLE
: 0;
const handleWidth =
isDraggingScrollbar || isHandleHovered
? SCROLLBAR_HANDLE_WIDTH_ACTIVE
: SCROLLBAR_HANDLE_WIDTH_IDLE;
const thumbRegionOffset = Math.max(
0,
scrollbarGeometry.handleOffset - SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING
);
const thumbRegionHeight = Math.min(
viewportMetrics.viewportSize - thumbRegionOffset,
scrollbarGeometry.handleSize + SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING * 2
);
const handleInsetTop = Math.max(
0,
(thumbRegionHeight - scrollbarGeometry.handleSize) / 2
);
const handleTravelDurationMs =
isDraggingScrollbar || isScrollActive ? 0 : SCROLLBAR_HANDLE_TRAVEL_DURATION_MS;
return (
<div
ref={rootRef}
data-testid={testId}
data-terminal-scrollbar-root="true"
style={{
position: "relative",
display: "flex",
@@ -370,6 +637,76 @@ export default function TerminalEmulator({
padding: 8,
}}
/>
{scrollbarGeometry.isVisible ? (
<div
style={{
position: "absolute",
top: 0,
right: 0,
bottom: 0,
width: 12,
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
zIndex: 10,
pointerEvents: "none",
}}
>
<div
style={{
position: "absolute",
top: 0,
right: -3,
width: SCROLLBAR_HANDLE_GRAB_WIDTH,
height: thumbRegionHeight,
transform: `translateY(${thumbRegionOffset}px)`,
cursor: isDraggingScrollbar ? "grabbing" : "grab",
touchAction: "none",
userSelect: "none",
transitionProperty: "transform",
transitionDuration: `${handleTravelDurationMs}ms`,
transitionTimingFunction: "linear",
pointerEvents: handleVisible ? "auto" : "none",
}}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
dragStartOffsetRef.current = clamp(
viewportMetrics.offset,
0,
scrollbarGeometry.maxScrollOffset
);
dragStartClientYRef.current = event.clientY;
setIsDraggingScrollbar(true);
}}
onPointerEnter={() => {
if (!isScrollVisible && !isDraggingScrollbar) {
return;
}
setIsHandleHovered(true);
}}
onPointerLeave={() => {
setIsHandleHovered(false);
}}
>
<div
style={{
marginTop: handleInsetTop,
height: scrollbarGeometry.handleSize,
width: handleWidth,
borderRadius: 999,
alignSelf: "center",
backgroundColor: "rgba(113, 113, 122, 1)",
opacity: handleOpacity,
transitionProperty: "opacity, width, background-color",
transitionDuration: `${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms, ${SCROLLBAR_HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms`,
transitionTimingFunction:
"ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
}}
/>
</div>
</div>
) : null}
</div>
);
}
@@ -57,7 +57,7 @@ type WorkspaceDesktopTabsRowProps = {
onSelectNewTabOption: (selection: NewTabSelection) => void;
newTabAgentOptionId: NewTabOptionId;
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
onNewTerminalTab: () => void;
onNewTerminalTab: (input: { paneId?: string }) => void;
onSplitRight: () => void;
onSplitDown: () => void;
externalDndContext?: boolean;
@@ -346,7 +346,7 @@ export function WorkspaceDesktopTabsRow({
data={tabs}
keyExtractor={(tab) => `${tab.tab.key}:${tab.tab.kind}`}
useDragHandle
disabled={tabs.length < 2}
disabled={!externalDndContext && tabs.length < 2}
onDragEnd={(nextTabs) => onReorderTabs(nextTabs.map((tab) => tab.tab))}
externalDndContext={externalDndContext}
activeId={activeDragTabId}
@@ -428,7 +428,7 @@ export function WorkspaceDesktopTabsRow({
</Tooltip>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={onNewTerminalTab}
onPress={() => onNewTerminalTab({ paneId })}
accessibilityRole="button"
accessibilityLabel="New terminal tab"
style={({ hovered, pressed }) => [
@@ -554,13 +554,13 @@ function WorkspaceScreenContent({
});
const terminals = terminalsQuery.data?.terminals ?? [];
const createTerminalMutation = useMutation({
mutationFn: async () => {
mutationFn: async (input?: { paneId?: string }) => {
if (!client) {
throw new Error("Host is not connected");
}
return await client.createTerminal(normalizedWorkspaceId);
},
onSuccess: (payload) => {
onSuccess: (payload, input) => {
const createdTerminal = payload.terminal;
if (createdTerminal) {
queryClient.setQueryData<ListTerminalsPayload>(
@@ -588,6 +588,9 @@ function WorkspaceScreenContent({
if (!workspaceKey) {
return;
}
if (input?.paneId) {
focusWorkspacePane(workspaceKey, input.paneId);
}
const tabId = useWorkspaceLayoutStore
.getState()
.openTab(workspaceKey, { kind: "terminal", terminalId: createdTerminal.id });
@@ -1133,14 +1136,14 @@ function WorkspaceScreenContent({
openWorkspaceDraftTab();
}, [openWorkspaceDraftTab]);
const handleCreateTerminal = useCallback(() => {
const handleCreateTerminal = useCallback((input?: { paneId?: string }) => {
if (createTerminalMutation.isPending) {
return;
}
if (!normalizedWorkspaceId.startsWith("/")) {
return;
}
createTerminalMutation.mutate();
createTerminalMutation.mutate(input);
}, [createTerminalMutation, normalizedWorkspaceId]);
const handleSelectSwitcherTab = useCallback(
@@ -1162,6 +1165,23 @@ function WorkspaceScreenContent({
[focusWorkspacePane, handleCreateDraftTab, persistenceKey]
);
const handleCreateDraftSplit = useCallback(
(input: { targetPaneId: string; position: "left" | "right" | "top" | "bottom" }) => {
if (!persistenceKey) {
return;
}
const paneId = splitWorkspacePaneEmpty(persistenceKey, input);
if (!paneId) {
return;
}
focusWorkspacePane(persistenceKey, paneId);
openWorkspaceDraftTab();
},
[focusWorkspacePane, openWorkspaceDraftTab, persistenceKey, splitWorkspacePaneEmpty]
);
const runCloseFlowForTab = useCallback(
async (input: { tabId: string; action: () => Promise<void> }): Promise<void> => {
const normalizedTabId = input.tabId.trim();
@@ -1569,26 +1589,18 @@ function WorkspaceScreenContent({
}
if (action.id === "workspace.pane.split.right") {
const activePaneTabId = focusedPaneTabState.activeTabId;
if (activePaneTabId) {
splitWorkspacePane(persistenceKey, {
tabId: activePaneTabId,
targetPaneId: focusedPane.id,
position: "right",
});
}
handleCreateDraftSplit({
targetPaneId: focusedPane.id,
position: "right",
});
return true;
}
if (action.id === "workspace.pane.split.down") {
const activePaneTabId = focusedPaneTabState.activeTabId;
if (activePaneTabId) {
splitWorkspacePane(persistenceKey, {
tabId: activePaneTabId,
targetPaneId: focusedPane.id,
position: "bottom",
});
}
handleCreateDraftSplit({
targetPaneId: focusedPane.id,
position: "bottom",
});
return true;
}
@@ -1647,9 +1659,9 @@ function WorkspaceScreenContent({
[
closeWorkspaceTab,
focusWorkspacePane,
handleCreateDraftSplit,
moveWorkspaceTabToPane,
persistenceKey,
splitWorkspacePane,
focusedPaneTabState.activeTabId,
focusedPaneTabState.pane,
workspaceLayout,
@@ -2023,7 +2035,7 @@ function WorkspaceScreenContent({
splitWorkspacePane(persistenceKey, input);
}}
onSplitPaneEmpty={(input) => {
splitWorkspacePaneEmpty(persistenceKey, input);
handleCreateDraftSplit(input);
}}
onMoveTabToPane={(tabId, toPaneId) => {
moveWorkspaceTabToPane(persistenceKey, tabId, toPaneId);
@@ -92,19 +92,6 @@ 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", {
@@ -118,19 +105,6 @@ 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", {
+4 -41
View File
@@ -51,38 +51,6 @@ 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.
*
@@ -198,15 +166,12 @@ export function parseAssistantFileLink(
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
if (windowsPathMatch) {
const rawWindowsPath = windowsPathMatch[1] ?? "";
const windowsPathWithLine = parseTrailingLineSuffix(rawWindowsPath);
const normalizedPath =
windowsPathWithLine?.path ?? normalizePathToken(rawWindowsPath);
const normalizedPath = normalizePathToken(windowsPathMatch[1] ?? "");
if (!normalizedPath || !isAllowedAbsolutePath(normalizedPath, options.workspaceRoot)) {
return null;
}
const lines = windowsPathWithLine ?? parseLineFragment(windowsPathMatch[2] ?? "");
const lines = parseLineFragment(windowsPathMatch[2] ?? "");
if (!lines) {
return null;
}
@@ -229,9 +194,7 @@ export function parseAssistantFileLink(
return null;
}
const rawPosixPath = decodeURIComponent(parsedUrl.pathname);
const posixPathWithLine = parseTrailingLineSuffix(rawPosixPath);
const normalizedPath = posixPathWithLine?.path ?? normalizePathToken(rawPosixPath);
const normalizedPath = normalizePathToken(decodeURIComponent(parsedUrl.pathname));
if (!normalizedPath || !normalizedPath.startsWith("/")) {
return null;
}
@@ -240,7 +203,7 @@ export function parseAssistantFileLink(
return null;
}
const lines = posixPathWithLine ?? parseLineFragment(parsedUrl.hash);
const lines = parseLineFragment(parsedUrl.hash);
if (!lines) {
return null;
}