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 { 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
+61 -24
View File
@@ -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", {
+4 -41
View File
@@ -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;
} }