FN-6737: preserve terminal Ctrl shortcuts
Keep terminal shortcut focus stable so Ctrl combinations emit control bytes reliably. - Prevent pointer, mouse, and touch activation on terminal shortcut buttons from stealing xterm focus. - Expand terminal shortcut coverage for Ctrl control codes, touch focus preservation, and platform copy modifiers. - Add a patch changeset for the published CLI package. Files changed: .changeset/fn-6737-terminal-ctrl-shortcuts.md | 5 +++ .../dashboard/app/components/TerminalModal.tsx | 31 +++++++++++++-- .../components/__tests__/TerminalModal.test.tsx | 46 +++++++++++++++++++--- 3 files changed, 73 insertions(+), 9 deletions(-) Fusion-Task-Id: FN-6737 Fusion-Task-Lineage: e27439e0-b2c5-44c1-8584-848e61d41e1b
This commit is contained in:
5
.changeset/fn-6737-terminal-ctrl-shortcuts.md
Normal file
5
.changeset/fn-6737-terminal-ctrl-shortcuts.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix terminal shortcut focus preservation so on-screen Ctrl combinations emit control bytes reliably on touch and pointer devices while keeping physical Ctrl behavior intact.
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
useCallback,
|
||||
type CSSProperties,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type TouchEvent as ReactTouchEvent,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
@@ -1256,10 +1258,21 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
/*
|
||||
FNXC:Terminal 2026-06-19-05:05:
|
||||
FN-6697 root cause: shortcut-bar buttons took browser focus on hardware-keyboard surfaces before their click handlers injected bytes, leaving xterm's helper textarea blurred even though the active session's sendInput path was correct. Preserve focus on mousedown and refocus xterm after every shortcut action so sticky modifiers, literal keys, arrows, and Ctrl-letter shortcuts deliver input without stranding subsequent hardware-keyboard typing across desktop and touch surfaces.
|
||||
|
||||
FNXC:Terminal 2026-06-19-10:38:
|
||||
FN-6737 root cause: touch-primary Ctrl shortcuts still allowed the browser's touchstart default action on shortcut buttons, so a tap on sticky Ctrl could move focus away from xterm's helper textarea before the composed Ctrl-letter byte reached the active PTY. Prevent the focus-taking default for mouse and touch activation, then keep the existing xterm refocus path so Ctrl control codes work from the sticky shortcut panel and physical Ctrl key paths on desktop, touch, and touch-with-hardware-keyboard surfaces.
|
||||
*/
|
||||
const preserveShortcutFocus = useCallback((event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
}, []);
|
||||
const preserveShortcutFocus = useCallback(
|
||||
(
|
||||
event:
|
||||
| ReactMouseEvent<HTMLButtonElement>
|
||||
| ReactPointerEvent<HTMLButtonElement>
|
||||
| ReactTouchEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const refocusTerminalAfterShortcut = useCallback(() => {
|
||||
xtermRef.current?.focus();
|
||||
@@ -1569,7 +1582,9 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
stickyModifier === "ctrl" ? "is-active" : ""
|
||||
}`}
|
||||
data-testid="terminal-modifier-ctrl"
|
||||
onPointerDown={preserveShortcutFocus}
|
||||
onMouseDown={preserveShortcutFocus}
|
||||
onTouchStart={preserveShortcutFocus}
|
||||
onClick={() => toggleModifier("ctrl")}
|
||||
aria-pressed={stickyModifier === "ctrl"}
|
||||
>
|
||||
@@ -1581,7 +1596,9 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
stickyModifier === "alt" ? "is-active" : ""
|
||||
}`}
|
||||
data-testid="terminal-modifier-alt"
|
||||
onPointerDown={preserveShortcutFocus}
|
||||
onMouseDown={preserveShortcutFocus}
|
||||
onTouchStart={preserveShortcutFocus}
|
||||
onClick={() => toggleModifier("alt")}
|
||||
aria-pressed={stickyModifier === "alt"}
|
||||
>
|
||||
@@ -1590,7 +1607,9 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
<button
|
||||
type="button"
|
||||
className="terminal-shortcut-btn"
|
||||
onPointerDown={preserveShortcutFocus}
|
||||
onMouseDown={preserveShortcutFocus}
|
||||
onTouchStart={preserveShortcutFocus}
|
||||
onClick={() => sendLiteralShortcut("\x1b")}
|
||||
>
|
||||
ESC
|
||||
@@ -1598,7 +1617,9 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
<button
|
||||
type="button"
|
||||
className="terminal-shortcut-btn"
|
||||
onPointerDown={preserveShortcutFocus}
|
||||
onMouseDown={preserveShortcutFocus}
|
||||
onTouchStart={preserveShortcutFocus}
|
||||
onClick={() => sendLiteralShortcut("\t")}
|
||||
>
|
||||
Tab
|
||||
@@ -1616,7 +1637,9 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
className="terminal-shortcut-btn"
|
||||
data-testid={arrow.testId}
|
||||
aria-label={arrow.ariaLabel}
|
||||
onPointerDown={preserveShortcutFocus}
|
||||
onMouseDown={preserveShortcutFocus}
|
||||
onTouchStart={preserveShortcutFocus}
|
||||
onClick={() => sendLiteralShortcut(arrow.sequence)}
|
||||
>
|
||||
{arrow.label}
|
||||
@@ -1628,7 +1651,9 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
key={shortcut.label}
|
||||
type="button"
|
||||
className="terminal-shortcut-btn"
|
||||
onPointerDown={preserveShortcutFocus}
|
||||
onMouseDown={preserveShortcutFocus}
|
||||
onTouchStart={preserveShortcutFocus}
|
||||
onClick={() => sendShortcutKey(shortcut.key)}
|
||||
title={shortcut.description}
|
||||
>
|
||||
|
||||
@@ -696,6 +696,21 @@ describe("TerminalModal", () => {
|
||||
expect(mockSendInput).toHaveBeenCalledWith("\x03");
|
||||
expect(ctrlBtn.getAttribute("aria-pressed")).toBe("false");
|
||||
|
||||
fireEvent.click(ctrlBtn);
|
||||
fireEvent.click(screen.getByRole("button", { name: "D" }));
|
||||
expect(mockSendInput).toHaveBeenCalledWith("\x04");
|
||||
expect(ctrlBtn.getAttribute("aria-pressed")).toBe("false");
|
||||
|
||||
fireEvent.click(ctrlBtn);
|
||||
fireEvent.click(screen.getByRole("button", { name: "L" }));
|
||||
expect(mockSendInput).toHaveBeenCalledWith("\x0c");
|
||||
expect(ctrlBtn.getAttribute("aria-pressed")).toBe("false");
|
||||
|
||||
fireEvent.click(ctrlBtn);
|
||||
fireEvent.click(screen.getByRole("button", { name: "." }));
|
||||
expect(mockSendInput).toHaveBeenCalledWith(".");
|
||||
expect(ctrlBtn.getAttribute("aria-pressed")).toBe("false");
|
||||
|
||||
fireEvent.click(altBtn);
|
||||
fireEvent.click(screen.getByRole("button", { name: "D" }));
|
||||
expect(mockSendInput).toHaveBeenCalledWith("\x1bd");
|
||||
@@ -811,7 +826,19 @@ describe("TerminalModal", () => {
|
||||
helperTextarea.focus();
|
||||
|
||||
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
|
||||
const ctrlButton = screen.getByTestId("terminal-modifier-ctrl");
|
||||
const arrowUpButton = screen.getByTestId("terminal-arrow-up");
|
||||
fireEvent.touchStart(ctrlButton);
|
||||
expect(document.activeElement).toBe(helperTextarea);
|
||||
|
||||
const pointerDown = new PointerEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
pointerType: "touch",
|
||||
});
|
||||
ctrlButton.dispatchEvent(pointerDown);
|
||||
expect(pointerDown.defaultPrevented).toBe(true);
|
||||
expect(document.activeElement).toBe(helperTextarea);
|
||||
const mouseDown = new MouseEvent("mousedown", { bubbles: true, cancelable: true });
|
||||
arrowUpButton.dispatchEvent(mouseDown);
|
||||
expect(mouseDown.defaultPrevented).toBe(true);
|
||||
@@ -4876,10 +4903,13 @@ describe("TerminalModal — xterm focus initialization (FN-1602)", () => {
|
||||
expect(mockSendInput).toHaveBeenCalledWith("echo hello\r");
|
||||
});
|
||||
|
||||
it("copies selected terminal text on ctrl+c and blocks sigint", async () => {
|
||||
it.each([
|
||||
["mac", "MacIntel", { metaKey: true }],
|
||||
["non-mac", "Win32", { ctrlKey: true }],
|
||||
] as const)("copies selected terminal text on platform copy modifier+c and blocks sigint on %s", async (_name, platform, modifier) => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "platform", {
|
||||
value: "Win32",
|
||||
value: platform,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
@@ -4896,7 +4926,7 @@ describe("TerminalModal — xterm focus initialization (FN-1602)", () => {
|
||||
});
|
||||
|
||||
const handled = terminalKeyEventHandler?.(
|
||||
new KeyboardEvent("keydown", { key: "c", ctrlKey: true }),
|
||||
new KeyboardEvent("keydown", { key: "c", ...modifier }),
|
||||
);
|
||||
|
||||
expect(handled).toBe(false);
|
||||
@@ -4904,9 +4934,13 @@ describe("TerminalModal — xterm focus initialization (FN-1602)", () => {
|
||||
expect(mockSendInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves sigint on ctrl+c when nothing is selected", async () => {
|
||||
it.each([
|
||||
["mac ctrl", "MacIntel", { ctrlKey: true }],
|
||||
["mac platform copy modifier", "MacIntel", { metaKey: true }],
|
||||
["non-mac ctrl", "Win32", { ctrlKey: true }],
|
||||
] as const)("preserves sigint on %s+c when nothing is selected", async (_name, platform, modifier) => {
|
||||
Object.defineProperty(navigator, "platform", {
|
||||
value: "Win32",
|
||||
value: platform,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
@@ -4922,7 +4956,7 @@ describe("TerminalModal — xterm focus initialization (FN-1602)", () => {
|
||||
});
|
||||
|
||||
const handled = terminalKeyEventHandler?.(
|
||||
new KeyboardEvent("keydown", { key: "c", ctrlKey: true }),
|
||||
new KeyboardEvent("keydown", { key: "c", ...modifier }),
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
|
||||
Reference in New Issue
Block a user