feat: improve terminal scrollbar and pane focus handling
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ 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 { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
|
||||||
import {
|
import {
|
||||||
computeTabDropPreview,
|
computeTabDropPreview,
|
||||||
type TabDropPreview,
|
type TabDropPreview,
|
||||||
@@ -67,7 +68,7 @@ interface SplitContainerProps {
|
|||||||
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||||
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||||
onSelectNewTabOption: (selection: { optionId: "__new_tab_agent__"; paneId?: string }) => void;
|
onSelectNewTabOption: (selection: { optionId: "__new_tab_agent__"; paneId?: string }) => void;
|
||||||
onNewTerminalTab: () => void;
|
onNewTerminalTab: (input: { paneId?: string }) => void;
|
||||||
newTabAgentOptionId?: "__new_tab_agent__";
|
newTabAgentOptionId?: "__new_tab_agent__";
|
||||||
buildPaneContentModel: (input: {
|
buildPaneContentModel: (input: {
|
||||||
paneId: string;
|
paneId: string;
|
||||||
@@ -768,16 +769,26 @@ function SplitPaneView({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePaneInteraction = () => {
|
const handlePanePointerDown = (event: PointerEvent) => {
|
||||||
|
if (!shouldFocusPaneFromEventTarget(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
onFocusPane(pane.id);
|
onFocusPane(pane.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
paneElement.addEventListener("pointerdown", handlePaneInteraction, true);
|
const handlePaneFocusIn = (event: FocusEvent) => {
|
||||||
paneElement.addEventListener("focusin", handlePaneInteraction, true);
|
if (!shouldFocusPaneFromEventTarget(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onFocusPane(pane.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
paneElement.addEventListener("pointerdown", handlePanePointerDown, true);
|
||||||
|
paneElement.addEventListener("focusin", handlePaneFocusIn, true);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
paneElement.removeEventListener("pointerdown", handlePaneInteraction, true);
|
paneElement.removeEventListener("pointerdown", handlePanePointerDown, true);
|
||||||
paneElement.removeEventListener("focusin", handlePaneInteraction, true);
|
paneElement.removeEventListener("focusin", handlePaneFocusIn, true);
|
||||||
};
|
};
|
||||||
}, [onFocusPane, pane.id]);
|
}, [onFocusPane, pane.id]);
|
||||||
|
|
||||||
@@ -786,12 +797,6 @@ function SplitPaneView({
|
|||||||
ref={paneRef}
|
ref={paneRef}
|
||||||
collapsable={false}
|
collapsable={false}
|
||||||
style={styles.pane}
|
style={styles.pane}
|
||||||
onFocus={() => {
|
|
||||||
onFocusPane(pane.id);
|
|
||||||
}}
|
|
||||||
onPointerDownCapture={() => {
|
|
||||||
onFocusPane(pane.id);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<View style={styles.paneTabs}>
|
<View style={styles.paneTabs}>
|
||||||
<WorkspaceDesktopTabsRow
|
<WorkspaceDesktopTabsRow
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function SplitDropZone({
|
|||||||
preview,
|
preview,
|
||||||
}: SplitDropZoneProps) {
|
}: SplitDropZoneProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { setNodeRef, isOver } = useDroppable({
|
const { setNodeRef } = useDroppable({
|
||||||
id: buildSplitDropZoneId(paneId),
|
id: buildSplitDropZoneId(paneId),
|
||||||
disabled: !active,
|
disabled: !active,
|
||||||
data: {
|
data: {
|
||||||
@@ -38,17 +38,27 @@ export function SplitDropZone({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const previewStyle = useMemo(() => {
|
const previewStyles = useMemo(() => {
|
||||||
if (!preview || preview.paneId !== paneId) {
|
if (!preview || preview.paneId !== paneId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return [
|
return {
|
||||||
styles.preview,
|
overlay: [
|
||||||
getPreviewStyle(preview.position),
|
styles.previewOverlay,
|
||||||
{
|
getPreviewOverlayStyle(preview.position),
|
||||||
backgroundColor: theme.colors.accent,
|
{
|
||||||
},
|
backgroundColor: theme.colors.accent,
|
||||||
];
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
frame: [
|
||||||
|
styles.previewFrame,
|
||||||
|
getPreviewFrameStyle(preview.position),
|
||||||
|
{
|
||||||
|
borderColor: theme.colors.accent,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
}, [paneId, preview, theme.colors.accent]);
|
}, [paneId, preview, theme.colors.accent]);
|
||||||
|
|
||||||
if (!active) {
|
if (!active) {
|
||||||
@@ -58,10 +68,15 @@ export function SplitDropZone({
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
ref={setNodeRef as any}
|
ref={setNodeRef as any}
|
||||||
style={[styles.overlay, isOver && styles.overlayActive]}
|
style={styles.overlay}
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
>
|
>
|
||||||
{previewStyle ? <View pointerEvents="none" style={previewStyle} /> : null}
|
{previewStyles ? (
|
||||||
|
<>
|
||||||
|
<View pointerEvents="none" style={previewStyles.overlay} />
|
||||||
|
<View pointerEvents="none" style={previewStyles.frame} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -108,7 +123,7 @@ export function resolveSplitDropPosition(input: {
|
|||||||
return distances[0]?.position ?? "center";
|
return distances[0]?.position ?? "center";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPreviewStyle(position: SplitDropZonePosition) {
|
function getPreviewOverlayStyle(position: SplitDropZonePosition) {
|
||||||
if (position === "left") {
|
if (position === "left") {
|
||||||
return styles.previewLeft;
|
return styles.previewLeft;
|
||||||
}
|
}
|
||||||
@@ -121,7 +136,23 @@ function getPreviewStyle(position: SplitDropZonePosition) {
|
|||||||
if (position === "bottom") {
|
if (position === "bottom") {
|
||||||
return styles.previewBottom;
|
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) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
@@ -129,14 +160,14 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFillObject,
|
||||||
zIndex: 40,
|
zIndex: 40,
|
||||||
},
|
},
|
||||||
overlayActive: {
|
previewOverlay: {
|
||||||
backgroundColor: theme.colors.surface0,
|
|
||||||
opacity: 0.03,
|
|
||||||
},
|
|
||||||
preview: {
|
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
opacity: 0.2,
|
},
|
||||||
|
previewFrame: {
|
||||||
|
position: "absolute",
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
borderWidth: 2,
|
||||||
},
|
},
|
||||||
previewLeft: {
|
previewLeft: {
|
||||||
left: 0,
|
left: 0,
|
||||||
@@ -162,10 +193,16 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
bottom: 0,
|
bottom: 0,
|
||||||
height: "50%",
|
height: "50%",
|
||||||
},
|
},
|
||||||
previewCenter: {
|
previewCenterOverlay: {
|
||||||
left: "30%",
|
left: theme.spacing[2],
|
||||||
top: "30%",
|
top: theme.spacing[2],
|
||||||
right: "30%",
|
right: theme.spacing[2],
|
||||||
bottom: "30%",
|
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";
|
"use dom";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { DOMProps } from "expo/dom";
|
import type { DOMProps } from "expo/dom";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import type { ITheme } from "@xterm/xterm";
|
import type { ITheme } from "@xterm/xterm";
|
||||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||||
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
||||||
import { focusWithRetries } from "../utils/web-focus";
|
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 {
|
function buildXtermThemeKey(theme: ITheme): string {
|
||||||
const values: Array<string> = [
|
const values: Array<string> = [
|
||||||
@@ -70,6 +94,34 @@ declare global {
|
|||||||
interface Window {}
|
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({
|
export default function TerminalEmulator({
|
||||||
streamKey,
|
streamKey,
|
||||||
initialOutputText,
|
initialOutputText,
|
||||||
@@ -100,13 +152,32 @@ export default function TerminalEmulator({
|
|||||||
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
|
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
|
||||||
const appliedChunkSequenceRef = useRef(0);
|
const appliedChunkSequenceRef = useRef(0);
|
||||||
const mountedThemeRef = useRef<ITheme>(xtermTheme);
|
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 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(() => {
|
useEffect(() => {
|
||||||
mountedThemeRef.current = xtermTheme;
|
mountedThemeRef.current = xtermTheme;
|
||||||
runtimeRef.current?.setTheme({ theme: xtermTheme });
|
runtimeRef.current?.setTheme({ theme: xtermTheme });
|
||||||
}, [themeKey]);
|
}, [themeKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ensureTerminalScrollbarStyle();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = rootRef.current;
|
const root = rootRef.current;
|
||||||
if (!root || !swipeGesturesEnabled) {
|
if (!root || !swipeGesturesEnabled) {
|
||||||
@@ -337,10 +408,206 @@ export default function TerminalEmulator({
|
|||||||
runtimeRef.current?.resize({ force: true });
|
runtimeRef.current?.resize({ force: true });
|
||||||
}, [resizeRequestToken]);
|
}, [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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
|
data-terminal-scrollbar-root="true"
|
||||||
style={{
|
style={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -370,6 +637,76 @@ export default function TerminalEmulator({
|
|||||||
padding: 8,
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ type WorkspaceDesktopTabsRowProps = {
|
|||||||
onSelectNewTabOption: (selection: NewTabSelection) => void;
|
onSelectNewTabOption: (selection: NewTabSelection) => void;
|
||||||
newTabAgentOptionId: NewTabOptionId;
|
newTabAgentOptionId: NewTabOptionId;
|
||||||
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
|
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
|
||||||
onNewTerminalTab: () => void;
|
onNewTerminalTab: (input: { paneId?: string }) => void;
|
||||||
onSplitRight: () => void;
|
onSplitRight: () => void;
|
||||||
onSplitDown: () => void;
|
onSplitDown: () => void;
|
||||||
externalDndContext?: boolean;
|
externalDndContext?: boolean;
|
||||||
@@ -346,7 +346,7 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
data={tabs}
|
data={tabs}
|
||||||
keyExtractor={(tab) => `${tab.tab.key}:${tab.tab.kind}`}
|
keyExtractor={(tab) => `${tab.tab.key}:${tab.tab.kind}`}
|
||||||
useDragHandle
|
useDragHandle
|
||||||
disabled={tabs.length < 2}
|
disabled={!externalDndContext && tabs.length < 2}
|
||||||
onDragEnd={(nextTabs) => onReorderTabs(nextTabs.map((tab) => tab.tab))}
|
onDragEnd={(nextTabs) => onReorderTabs(nextTabs.map((tab) => tab.tab))}
|
||||||
externalDndContext={externalDndContext}
|
externalDndContext={externalDndContext}
|
||||||
activeId={activeDragTabId}
|
activeId={activeDragTabId}
|
||||||
@@ -428,7 +428,7 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
onPress={onNewTerminalTab}
|
onPress={() => onNewTerminalTab({ paneId })}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="New terminal tab"
|
accessibilityLabel="New terminal tab"
|
||||||
style={({ hovered, pressed }) => [
|
style={({ hovered, pressed }) => [
|
||||||
|
|||||||
@@ -554,13 +554,13 @@ function WorkspaceScreenContent({
|
|||||||
});
|
});
|
||||||
const terminals = terminalsQuery.data?.terminals ?? [];
|
const terminals = terminalsQuery.data?.terminals ?? [];
|
||||||
const createTerminalMutation = useMutation({
|
const createTerminalMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async (input?: { paneId?: string }) => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
}
|
}
|
||||||
return await client.createTerminal(normalizedWorkspaceId);
|
return await client.createTerminal(normalizedWorkspaceId);
|
||||||
},
|
},
|
||||||
onSuccess: (payload) => {
|
onSuccess: (payload, input) => {
|
||||||
const createdTerminal = payload.terminal;
|
const createdTerminal = payload.terminal;
|
||||||
if (createdTerminal) {
|
if (createdTerminal) {
|
||||||
queryClient.setQueryData<ListTerminalsPayload>(
|
queryClient.setQueryData<ListTerminalsPayload>(
|
||||||
@@ -588,6 +588,9 @@ function WorkspaceScreenContent({
|
|||||||
if (!workspaceKey) {
|
if (!workspaceKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (input?.paneId) {
|
||||||
|
focusWorkspacePane(workspaceKey, input.paneId);
|
||||||
|
}
|
||||||
const tabId = useWorkspaceLayoutStore
|
const tabId = useWorkspaceLayoutStore
|
||||||
.getState()
|
.getState()
|
||||||
.openTab(workspaceKey, { kind: "terminal", terminalId: createdTerminal.id });
|
.openTab(workspaceKey, { kind: "terminal", terminalId: createdTerminal.id });
|
||||||
@@ -1133,14 +1136,14 @@ function WorkspaceScreenContent({
|
|||||||
openWorkspaceDraftTab();
|
openWorkspaceDraftTab();
|
||||||
}, [openWorkspaceDraftTab]);
|
}, [openWorkspaceDraftTab]);
|
||||||
|
|
||||||
const handleCreateTerminal = useCallback(() => {
|
const handleCreateTerminal = useCallback((input?: { paneId?: string }) => {
|
||||||
if (createTerminalMutation.isPending) {
|
if (createTerminalMutation.isPending) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!normalizedWorkspaceId.startsWith("/")) {
|
if (!normalizedWorkspaceId.startsWith("/")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
createTerminalMutation.mutate();
|
createTerminalMutation.mutate(input);
|
||||||
}, [createTerminalMutation, normalizedWorkspaceId]);
|
}, [createTerminalMutation, normalizedWorkspaceId]);
|
||||||
|
|
||||||
const handleSelectSwitcherTab = useCallback(
|
const handleSelectSwitcherTab = useCallback(
|
||||||
@@ -1162,6 +1165,23 @@ function WorkspaceScreenContent({
|
|||||||
[focusWorkspacePane, handleCreateDraftTab, persistenceKey]
|
[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(
|
const runCloseFlowForTab = useCallback(
|
||||||
async (input: { tabId: string; action: () => Promise<void> }): Promise<void> => {
|
async (input: { tabId: string; action: () => Promise<void> }): Promise<void> => {
|
||||||
const normalizedTabId = input.tabId.trim();
|
const normalizedTabId = input.tabId.trim();
|
||||||
@@ -1569,26 +1589,18 @@ function WorkspaceScreenContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action.id === "workspace.pane.split.right") {
|
if (action.id === "workspace.pane.split.right") {
|
||||||
const activePaneTabId = focusedPaneTabState.activeTabId;
|
handleCreateDraftSplit({
|
||||||
if (activePaneTabId) {
|
targetPaneId: focusedPane.id,
|
||||||
splitWorkspacePane(persistenceKey, {
|
position: "right",
|
||||||
tabId: activePaneTabId,
|
});
|
||||||
targetPaneId: focusedPane.id,
|
|
||||||
position: "right",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.id === "workspace.pane.split.down") {
|
if (action.id === "workspace.pane.split.down") {
|
||||||
const activePaneTabId = focusedPaneTabState.activeTabId;
|
handleCreateDraftSplit({
|
||||||
if (activePaneTabId) {
|
targetPaneId: focusedPane.id,
|
||||||
splitWorkspacePane(persistenceKey, {
|
position: "bottom",
|
||||||
tabId: activePaneTabId,
|
});
|
||||||
targetPaneId: focusedPane.id,
|
|
||||||
position: "bottom",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1647,9 +1659,9 @@ function WorkspaceScreenContent({
|
|||||||
[
|
[
|
||||||
closeWorkspaceTab,
|
closeWorkspaceTab,
|
||||||
focusWorkspacePane,
|
focusWorkspacePane,
|
||||||
|
handleCreateDraftSplit,
|
||||||
moveWorkspaceTabToPane,
|
moveWorkspaceTabToPane,
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
splitWorkspacePane,
|
|
||||||
focusedPaneTabState.activeTabId,
|
focusedPaneTabState.activeTabId,
|
||||||
focusedPaneTabState.pane,
|
focusedPaneTabState.pane,
|
||||||
workspaceLayout,
|
workspaceLayout,
|
||||||
@@ -2023,7 +2035,7 @@ function WorkspaceScreenContent({
|
|||||||
splitWorkspacePane(persistenceKey, input);
|
splitWorkspacePane(persistenceKey, input);
|
||||||
}}
|
}}
|
||||||
onSplitPaneEmpty={(input) => {
|
onSplitPaneEmpty={(input) => {
|
||||||
splitWorkspacePaneEmpty(persistenceKey, input);
|
handleCreateDraftSplit(input);
|
||||||
}}
|
}}
|
||||||
onMoveTabToPane={(tabId, toPaneId) => {
|
onMoveTabToPane={(tabId, toPaneId) => {
|
||||||
moveWorkspaceTabToPane(persistenceKey, 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", () => {
|
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", {
|
||||||
@@ -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", () => {
|
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,38 +51,6 @@ 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.
|
||||||
*
|
*
|
||||||
@@ -198,15 +166,12 @@ export function parseAssistantFileLink(
|
|||||||
|
|
||||||
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
|
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
|
||||||
if (windowsPathMatch) {
|
if (windowsPathMatch) {
|
||||||
const rawWindowsPath = windowsPathMatch[1] ?? "";
|
const normalizedPath = normalizePathToken(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 = windowsPathWithLine ?? parseLineFragment(windowsPathMatch[2] ?? "");
|
const lines = parseLineFragment(windowsPathMatch[2] ?? "");
|
||||||
if (!lines) {
|
if (!lines) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -229,9 +194,7 @@ export function parseAssistantFileLink(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawPosixPath = decodeURIComponent(parsedUrl.pathname);
|
const normalizedPath = normalizePathToken(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;
|
||||||
}
|
}
|
||||||
@@ -240,7 +203,7 @@ export function parseAssistantFileLink(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = posixPathWithLine ?? parseLineFragment(parsedUrl.hash);
|
const lines = parseLineFragment(parsedUrl.hash);
|
||||||
if (!lines) {
|
if (!lines) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user