FN-7262: restore terminal shortcut handling
Restore reliable copy, paste, and input gating for embedded session terminals. - Add platform-aware copy handling so selected text copies while no-selection Ctrl+C reaches the shell. - Gate xterm stdin, mobile controls, cursor blink, and input sends on authoritative attach-ticket writability. - Keep mobile send submission on one form path and cover shortcut/input behavior across desktop, modal, and mobile tests. - Document embedded terminal shortcut semantics and add a patch changeset for @runfusion/fusion. Files changed: .changeset/fn-7262-terminal-shortcuts.md | 7 ++ docs/dashboard-guide.md | 2 +- .../dashboard/app/components/SessionTerminal.tsx | 76 +++++++++++++---- .../__tests__/SessionTerminal.mobile.test.tsx | 34 ++++++-- .../components/__tests__/SessionTerminal.test.tsx | 96 +++++++++++++++++++++- .../components/__tests__/TerminalModal.test.tsx | 33 ++++++++ 6 files changed, 221 insertions(+), 27 deletions(-) Fusion-Task-Id: FN-7262 Fusion-Task-Lineage: 8a7af0a4-0b52-44fe-b949-63486068fa97 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7262-terminal-shortcuts.md
Normal file
7
.changeset/fn-7262-terminal-shortcuts.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Restore reliable terminal keyboard shortcuts in embedded CLI session terminals.
|
||||||
|
category: fix
|
||||||
|
dev: SessionTerminal now mirrors TerminalModal copy/paste filtering, suppresses prop/read-only-ticket replay input, and keeps mobile composer submit on one path.
|
||||||
@@ -521,7 +521,7 @@ Features:
|
|||||||
- Shortcuts panel buttons preserve terminal focus on the active terminal session during pointer, mouse, and touch activation, so Ctrl combinations reliably emit control bytes to the shell
|
- Shortcuts panel buttons preserve terminal focus on the active terminal session during pointer, mouse, and touch activation, so Ctrl combinations reliably emit control bytes to the shell
|
||||||
- The Preferences panel customizes font family, font size, cursor style, cursor blink, and renderer; changes persist in browser `localStorage` under `kb-terminal-preferences`, with the legacy `kb-terminal-font-size` value migrated automatically
|
- The Preferences panel customizes font family, font size, cursor style, cursor blink, and renderer; changes persist in browser `localStorage` under `kb-terminal-preferences`, with the legacy `kb-terminal-font-size` value migrated automatically
|
||||||
- Font and cursor preferences apply live to the active xterm instance; renderer changes apply the next time the terminal opens, and mobile devices keep the WebGL renderer disabled to avoid glyph artifacts
|
- Font and cursor preferences apply live to the active xterm instance; renderer changes apply the next time the terminal opens, and mobile devices keep the WebGL renderer disabled to avoid glyph artifacts
|
||||||
- Embedded CLI session terminals honor the same saved preferences for live, idle, ended, read-only, and interactive session views. Cursor blink still stays disabled for read-only/replay sessions, renderer changes apply on the next session mount, and WebGL never loads on mobile viewports.
|
- Embedded CLI session terminals honor the same saved preferences and physical copy/paste semantics for live interactive session views: selected text copies with the platform copy modifier, no-selection Ctrl+C stays available to the shell, and paste travels once through xterm's native input path. Idle, ended, and read-only replay views suppress input handlers and mobile accessory controls. Cursor blink still stays disabled for read-only/replay sessions, renderer changes apply on the next session mount, and WebGL never loads on mobile viewports.
|
||||||
- Mobile-aware virtual keyboard handling and auto-refit behavior
|
- Mobile-aware virtual keyboard handling and auto-refit behavior
|
||||||
- Reopen/reconnect/session-recovery flows preserve single-keystroke input forwarding (no duplicate characters, no page refresh required)
|
- Reopen/reconnect/session-recovery flows preserve single-keystroke input forwarding (no duplicate characters, no page refresh required)
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,10 @@ function buildCliWsUrl(sessionId: string, ticket: string): string {
|
|||||||
return appendTokenQuery(base);
|
return appendTokenQuery(base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMacPlatform(): boolean {
|
||||||
|
return /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
|
||||||
|
}
|
||||||
|
|
||||||
function decodeBase64ToString(b64: string): string {
|
function decodeBase64ToString(b64: string): string {
|
||||||
if (typeof window !== "undefined" && typeof window.atob === "function") {
|
if (typeof window !== "undefined" && typeof window.atob === "function") {
|
||||||
// atob → binary string → UTF-8 decode.
|
// atob → binary string → UTF-8 decode.
|
||||||
@@ -187,15 +191,18 @@ export function SessionTerminal({
|
|||||||
// Sticky Ctrl: tap Ctrl, then the next tapped key combines into a control
|
// Sticky Ctrl: tap Ctrl, then the next tapped key combines into a control
|
||||||
// sequence (Ctrl-C → 0x03, Ctrl-D → 0x04, Ctrl-Z → 0x1A).
|
// sequence (Ctrl-C → 0x03, Ctrl-D → 0x04, Ctrl-Z → 0x1A).
|
||||||
const [ctrlSticky, setCtrlSticky] = useState(false);
|
const [ctrlSticky, setCtrlSticky] = useState(false);
|
||||||
|
const [ticketReadOnly, setTicketReadOnly] = useState<boolean | null>(null);
|
||||||
|
const effectiveReadOnly = readOnly || ticketReadOnly === true;
|
||||||
|
const canAcceptInput = !readOnly && ticketReadOnly === false && mode === "live";
|
||||||
|
|
||||||
/** Write raw bytes to the session input path (mobile bar + submit). */
|
/** Write raw bytes to the session input path (mobile bar + submit). */
|
||||||
const sendInput = useCallback((data: string) => {
|
const sendInput = useCallback((data: string) => {
|
||||||
if (!data) return;
|
if (!data || !canAcceptInput) return;
|
||||||
const ws = wsRef.current;
|
const ws = wsRef.current;
|
||||||
if (ws?.readyState === WebSocket.OPEN) {
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify({ type: "input", data }));
|
ws.send(JSON.stringify({ type: "input", data }));
|
||||||
}
|
}
|
||||||
}, []);
|
}, [canAcceptInput]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emit one accessory-bar key. If sticky Ctrl is active and the key has a
|
* Emit one accessory-bar key. If sticky Ctrl is active and the key has a
|
||||||
@@ -277,14 +284,14 @@ export function SessionTerminal({
|
|||||||
);
|
);
|
||||||
terminal.options.fontSize = terminalPreferences.fontSize;
|
terminal.options.fontSize = terminalPreferences.fontSize;
|
||||||
terminal.options.cursorStyle = terminalPreferences.cursorStyle;
|
terminal.options.cursorStyle = terminalPreferences.cursorStyle;
|
||||||
terminal.options.cursorBlink = terminalPreferences.cursorBlink && !readOnly && mode === "live";
|
terminal.options.cursorBlink = terminalPreferences.cursorBlink && canAcceptInput;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
(fitAddonRef.current as { fit?: () => void } | null)?.fit?.();
|
(fitAddonRef.current as { fit?: () => void } | null)?.fit?.();
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore transient measure failures */
|
/* ignore transient measure failures */
|
||||||
}
|
}
|
||||||
}, [mode, readOnly]);
|
}, [canAcceptInput]);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:Terminal 2026-06-17-01:05:
|
FNXC:Terminal 2026-06-17-01:05:
|
||||||
@@ -313,6 +320,7 @@ export function SessionTerminal({
|
|||||||
let resizeObserver: ResizeObserver | null = null;
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let unackedBytes = 0;
|
let unackedBytes = 0;
|
||||||
|
setTicketReadOnly(null);
|
||||||
|
|
||||||
const sendResize = (cols: number, rows: number) => {
|
const sendResize = (cols: number, rows: number) => {
|
||||||
const ws = wsRef.current;
|
const ws = wsRef.current;
|
||||||
@@ -343,6 +351,8 @@ export function SessionTerminal({
|
|||||||
return; // surfaced via the "disconnected" state header below
|
return; // surfaced via the "disconnected" state header below
|
||||||
}
|
}
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
|
setTicketReadOnly(ticketRes.readOnly);
|
||||||
|
const ticketCanAcceptInput = !readOnly && !ticketRes.readOnly && mode === "live";
|
||||||
|
|
||||||
// 2. Lazy-load xterm + addons (out of the main bundle).
|
// 2. Lazy-load xterm + addons (out of the main bundle).
|
||||||
const [{ Terminal }, { FitAddon }, { Unicode11Addon }] = await Promise.all([
|
const [{ Terminal }, { FitAddon }, { Unicode11Addon }] = await Promise.all([
|
||||||
@@ -364,13 +374,16 @@ export function SessionTerminal({
|
|||||||
SessionTerminal shares TerminalModal's recurrence #5 root cause: FN-6638's 66.76px diagnostic compared only symbols-inclusive stacks, so real iOS Safari still let the loaded symbols @font-face pollute xterm's ASCII measurement. Pass only the symbols-free resolved family to xterm on this attach surface too; DOM glyph fallback is scoped to the viewport CSS variable and never to the xterm font option used by DOM/canvas measurement or desktop WebGL.
|
SessionTerminal shares TerminalModal's recurrence #5 root cause: FN-6638's 66.76px diagnostic compared only symbols-inclusive stacks, so real iOS Safari still let the loaded symbols @font-face pollute xterm's ASCII measurement. Pass only the symbols-free resolved family to xterm on this attach surface too; DOM glyph fallback is scoped to the viewport CSS variable and never to the xterm font option used by DOM/canvas measurement or desktop WebGL.
|
||||||
|
|
||||||
FNXC:Terminal 2026-06-17-00:50:
|
FNXC:Terminal 2026-06-17-00:50:
|
||||||
SessionTerminal consumes the shared localStorage terminal preferences for parity with TerminalModal, but replay safety still owns input posture: cursor blink is the user preference AND-gated by !readOnly && mode === "live" so read-only, idle, and ended sessions never blink.
|
SessionTerminal consumes the shared localStorage terminal preferences for parity with TerminalModal, but replay safety still owns input posture: cursor blink is the user preference AND-gated by effective write permission so read-only, idle, ended, and server-downgraded attach sessions never blink.
|
||||||
|
|
||||||
|
FNXC:Terminal 2026-06-30-21:24:
|
||||||
|
Attach tickets are authoritative for replay/permission downgrades. Derive xterm stdin, keyboard handlers, and mobile affordance rendering from both the caller props and ticketRes.readOnly so a server read-only attach cannot accept input even when the mount props still say live+writable.
|
||||||
*/
|
*/
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
convertEol: false,
|
convertEol: false,
|
||||||
cursorBlink: terminalPreferences.cursorBlink && !readOnly && mode === "live",
|
cursorBlink: terminalPreferences.cursorBlink && ticketCanAcceptInput,
|
||||||
cursorStyle: terminalPreferences.cursorStyle,
|
cursorStyle: terminalPreferences.cursorStyle,
|
||||||
disableStdin: readOnly,
|
disableStdin: !ticketCanAcceptInput,
|
||||||
scrollback: 10000,
|
scrollback: 10000,
|
||||||
// Defensive: do NOT register an OSC 52 (clipboard-write) handler. The
|
// Defensive: do NOT register an OSC 52 (clipboard-write) handler. The
|
||||||
// server-side neutralizer (U10) strips it; we add no client handling.
|
// server-side neutralizer (U10) strips it; we add no client handling.
|
||||||
@@ -446,14 +459,46 @@ export function SessionTerminal({
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// term.onData → input frames (skip entirely when read-only).
|
/*
|
||||||
if (!readOnly) {
|
FNXC:Terminal 2026-06-30-00:10:
|
||||||
|
FN-7262 root cause: the embedded SessionTerminal attach surface forwarded raw xterm data but never installed the copy/paste key filter already used by TerminalModal, so physical Ctrl/Cmd+C with a selection could be swallowed by xterm/browser routing inconsistently while replay states still accepted input. Register exactly one handler with the xterm instance for live writable sessions: platform copy+C copies selected text, copy+C without selection stays on the PTY/SIGINT path, and paste is left to xterm's native onData flow so it is delivered once.
|
||||||
|
*/
|
||||||
|
if (ticketCanAcceptInput) {
|
||||||
term.onData((data: string) => {
|
term.onData((data: string) => {
|
||||||
const ws = wsRef.current;
|
const ws = wsRef.current;
|
||||||
if (ws?.readyState === WebSocket.OPEN) {
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify({ type: "input", data }));
|
ws.send(JSON.stringify({ type: "input", data }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
term.attachCustomKeyEventHandler((event: KeyboardEvent) => {
|
||||||
|
if (event.type !== "keydown") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCopyPasteModifier = isMacPlatform() ? event.metaKey : event.ctrlKey;
|
||||||
|
if (!isCopyPasteModifier || event.altKey || event.shiftKey) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
if (key === "c") {
|
||||||
|
const selection = term.hasSelection() ? term.getSelection() : "";
|
||||||
|
if (!selection) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
navigator.clipboard?.writeText(selection).catch(() => {
|
||||||
|
// Ignore clipboard permission/errors so terminal input stays responsive.
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "v") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debounced ResizeObserver → resize frames.
|
// Debounced ResizeObserver → resize frames.
|
||||||
@@ -574,7 +619,7 @@ export function SessionTerminal({
|
|||||||
isMobile && keyboardOpen ? " cli-session-terminal--keyboard-open" : ""
|
isMobile && keyboardOpen ? " cli-session-terminal--keyboard-open" : ""
|
||||||
}`}
|
}`}
|
||||||
data-mode={mode}
|
data-mode={mode}
|
||||||
data-read-only={readOnly}
|
data-read-only={effectiveReadOnly}
|
||||||
data-mobile={isMobile}
|
data-mobile={isMobile}
|
||||||
data-keyboard-open={isMobile && keyboardOpen}
|
data-keyboard-open={isMobile && keyboardOpen}
|
||||||
>
|
>
|
||||||
@@ -631,7 +676,7 @@ export function SessionTerminal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{readOnly && (
|
{effectiveReadOnly && (
|
||||||
<span className="cli-session-terminal__readonly-badge">
|
<span className="cli-session-terminal__readonly-badge">
|
||||||
<Eye size={12} aria-hidden="true" />
|
<Eye size={12} aria-hidden="true" />
|
||||||
{t("cliTerminal.readOnly", "Read-only")}
|
{t("cliTerminal.readOnly", "Read-only")}
|
||||||
@@ -680,7 +725,7 @@ export function SessionTerminal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isMobile && !readOnly && (
|
{isMobile && canAcceptInput && (
|
||||||
<div
|
<div
|
||||||
className={`cli-session-terminal__mobile-bar${
|
className={`cli-session-terminal__mobile-bar${
|
||||||
keyboardOpen ? " cli-session-terminal__mobile-bar--keyboard-open" : ""
|
keyboardOpen ? " cli-session-terminal__mobile-bar--keyboard-open" : ""
|
||||||
@@ -816,11 +861,12 @@ export function SessionTerminal({
|
|||||||
className="cli-session-terminal__mobile-send"
|
className="cli-session-terminal__mobile-send"
|
||||||
data-testid="cli-terminal-mobile-send"
|
data-testid="cli-terminal-mobile-send"
|
||||||
aria-label={t("cliTerminal.mobileSend", "Send")}
|
aria-label={t("cliTerminal.mobileSend", "Send")}
|
||||||
// iOS pattern: act on click, preventDefault on pointer/mouse down
|
/*
|
||||||
// so the input doesn't blur (which dismisses the keyboard).
|
FNXC:Terminal 2026-06-30-22:10:
|
||||||
|
The mobile send affordance must submit through exactly one path. Keep the form submit handler so keyboard Enter and touch activation share one input sequence, while pointer/mouse down still prevents blur on iOS.
|
||||||
|
*/
|
||||||
onPointerDown={keepFocus}
|
onPointerDown={keepFocus}
|
||||||
onMouseDown={keepFocus}
|
onMouseDown={keepFocus}
|
||||||
onClick={() => handleMobileSubmit()}
|
|
||||||
>
|
>
|
||||||
{t("cliTerminal.mobileSend", "Send")}
|
{t("cliTerminal.mobileSend", "Send")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ const mockTerm = {
|
|||||||
loadAddon: vi.fn(),
|
loadAddon: vi.fn(),
|
||||||
open: vi.fn(),
|
open: vi.fn(),
|
||||||
onData: vi.fn(),
|
onData: vi.fn(),
|
||||||
|
attachCustomKeyEventHandler: vi.fn(),
|
||||||
|
hasSelection: vi.fn(() => false),
|
||||||
|
getSelection: vi.fn(() => ""),
|
||||||
write: vi.fn((_data: string, cb?: () => void) => cb?.()),
|
write: vi.fn((_data: string, cb?: () => void) => cb?.()),
|
||||||
dispose: vi.fn(),
|
dispose: vi.fn(),
|
||||||
unicode: { activeVersion: "6" },
|
unicode: { activeVersion: "6" },
|
||||||
@@ -135,6 +138,9 @@ beforeEach(() => {
|
|||||||
mockTerm.loadAddon.mockClear();
|
mockTerm.loadAddon.mockClear();
|
||||||
mockTerm.open.mockClear();
|
mockTerm.open.mockClear();
|
||||||
mockTerm.onData.mockReset();
|
mockTerm.onData.mockReset();
|
||||||
|
mockTerm.attachCustomKeyEventHandler.mockClear();
|
||||||
|
mockTerm.hasSelection.mockReturnValue(false);
|
||||||
|
mockTerm.getSelection.mockReturnValue("");
|
||||||
mockTerm.write.mockClear();
|
mockTerm.write.mockClear();
|
||||||
mockTerm.dispose.mockClear();
|
mockTerm.dispose.mockClear();
|
||||||
mockTerm.options = {};
|
mockTerm.options = {};
|
||||||
@@ -175,12 +181,28 @@ describe("SessionTerminal (mobile)", () => {
|
|||||||
expect(screen.queryByTestId("cli-terminal-mobile-bar")).toBeNull();
|
expect(screen.queryByTestId("cli-terminal-mobile-bar")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not render the mobile bar when read-only", async () => {
|
it.each([
|
||||||
apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: true });
|
["read-only", { readOnly: true }],
|
||||||
await renderMobile({ readOnly: true });
|
["idle", { mode: "idle" as const }],
|
||||||
|
["ended", { mode: "ended" as const }],
|
||||||
|
])("does not render the mobile bar when %s", async (_label, props) => {
|
||||||
|
if (props.readOnly) {
|
||||||
|
apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: true });
|
||||||
|
}
|
||||||
|
await renderMobile(props);
|
||||||
expect(screen.queryByTestId("cli-terminal-mobile-bar")).toBeNull();
|
expect(screen.queryByTestId("cli-terminal-mobile-bar")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not render mobile controls when the attach ticket is read-only", async () => {
|
||||||
|
apiMock.mockResolvedValue({ ticket: "tkt-ro", expiresAt: "", readOnly: true });
|
||||||
|
|
||||||
|
await renderMobile();
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("cli-terminal-mobile-bar")).toBeNull();
|
||||||
|
expect(mockTerm.options.disableStdin).toBe(true);
|
||||||
|
expect(mockTerm.onData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
// ── Accessory bar control sequences ───────────────────────────────────────
|
// ── Accessory bar control sequences ───────────────────────────────────────
|
||||||
it("Esc key emits 0x1b as an input frame", async () => {
|
it("Esc key emits 0x1b as an input frame", async () => {
|
||||||
const { ws } = await renderMobile();
|
const { ws } = await renderMobile();
|
||||||
@@ -271,15 +293,13 @@ describe("SessionTerminal (mobile)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Input field submit ────────────────────────────────────────────────────
|
// ── Input field submit ────────────────────────────────────────────────────
|
||||||
it("submitting the input field sends the text then \\r", async () => {
|
it("submitting the input field sends the text then exactly one \\r", async () => {
|
||||||
const { ws } = await renderMobile();
|
const { ws } = await renderMobile();
|
||||||
const input = screen.getByTestId("cli-terminal-mobile-input") as HTMLInputElement;
|
const input = screen.getByTestId("cli-terminal-mobile-input") as HTMLInputElement;
|
||||||
fireEvent.change(input, { target: { value: "ls -la" } });
|
fireEvent.change(input, { target: { value: "ls -la" } });
|
||||||
fireEvent.click(screen.getByTestId("cli-terminal-mobile-send"));
|
fireEvent.click(screen.getByTestId("cli-terminal-mobile-send"));
|
||||||
const frames = inputFrames(ws);
|
const frames = inputFrames(ws);
|
||||||
const idx = frames.indexOf("ls -la");
|
expect(frames).toEqual(["ls -la", "\r"]);
|
||||||
expect(idx).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(frames[idx + 1]).toBe("\r");
|
|
||||||
// Field is cleared after submit.
|
// Field is cleared after submit.
|
||||||
expect(input.value).toBe("");
|
expect(input.value).toBe("");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ import { act, render, screen, fireEvent, waitFor } from "@testing-library/react"
|
|||||||
|
|
||||||
// ── Mock xterm + addon dynamic imports (jsdom has no canvas/WebGL) ──────────
|
// ── Mock xterm + addon dynamic imports (jsdom has no canvas/WebGL) ──────────
|
||||||
const mockFitAddon = { fit: vi.fn() };
|
const mockFitAddon = { fit: vi.fn() };
|
||||||
|
let sessionKeyEventHandler: ((event: KeyboardEvent) => boolean) | null = null;
|
||||||
const mockTerm = {
|
const mockTerm = {
|
||||||
loadAddon: vi.fn(),
|
loadAddon: vi.fn(),
|
||||||
open: vi.fn(),
|
open: vi.fn(),
|
||||||
onData: vi.fn(),
|
onData: vi.fn(),
|
||||||
attachCustomKeyEventHandler: vi.fn(),
|
attachCustomKeyEventHandler: vi.fn((handler: (event: KeyboardEvent) => boolean) => {
|
||||||
|
sessionKeyEventHandler = handler;
|
||||||
|
}),
|
||||||
|
hasSelection: vi.fn(() => false),
|
||||||
|
getSelection: vi.fn(() => ""),
|
||||||
write: vi.fn((_data: string, cb?: () => void) => cb?.()),
|
write: vi.fn((_data: string, cb?: () => void) => cb?.()),
|
||||||
refresh: vi.fn(),
|
refresh: vi.fn(),
|
||||||
dispose: vi.fn(),
|
dispose: vi.fn(),
|
||||||
@@ -82,8 +87,19 @@ beforeEach(() => {
|
|||||||
mockTerm.loadAddon.mockClear();
|
mockTerm.loadAddon.mockClear();
|
||||||
mockTerm.open.mockClear();
|
mockTerm.open.mockClear();
|
||||||
mockTerm.onData.mockReset();
|
mockTerm.onData.mockReset();
|
||||||
|
sessionKeyEventHandler = null;
|
||||||
mockTerm.attachCustomKeyEventHandler.mockClear();
|
mockTerm.attachCustomKeyEventHandler.mockClear();
|
||||||
|
mockTerm.hasSelection.mockReturnValue(false);
|
||||||
|
mockTerm.getSelection.mockReturnValue("");
|
||||||
mockTerm.write.mockClear();
|
mockTerm.write.mockClear();
|
||||||
|
Object.defineProperty(navigator, "platform", {
|
||||||
|
value: "Win32",
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(navigator, "clipboard", {
|
||||||
|
value: undefined,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
mockTerm.refresh.mockClear();
|
mockTerm.refresh.mockClear();
|
||||||
mockTerm.dispose.mockClear();
|
mockTerm.dispose.mockClear();
|
||||||
mockTerm.options = {};
|
mockTerm.options = {};
|
||||||
@@ -124,10 +140,34 @@ describe("SessionTerminal", () => {
|
|||||||
await waitFor(() => expect(mockTerm.write).toHaveBeenCalledWith("hello", expect.any(Function)));
|
await waitFor(() => expect(mockTerm.write).toHaveBeenCalledWith("hello", expect.any(Function)));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("read-only: never registers term.onData (input suppressed)", async () => {
|
it.each([
|
||||||
render(<SessionTerminal sessionId="s1" readOnly />);
|
["read-only", { readOnly: true }],
|
||||||
|
["idle", { mode: "idle" as const }],
|
||||||
|
["ended", { mode: "ended" as const }],
|
||||||
|
])("%s: never registers input handlers", async (_label, props) => {
|
||||||
|
render(<SessionTerminal sessionId="s1" {...props} />);
|
||||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||||
expect(mockTerm.onData).not.toHaveBeenCalled();
|
expect(mockTerm.onData).not.toHaveBeenCalled();
|
||||||
|
expect(mockTerm.attachCustomKeyEventHandler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors server read-only attach tickets when props are live+writable", async () => {
|
||||||
|
const { Terminal } = await import("@xterm/xterm");
|
||||||
|
apiMock.mockResolvedValue({ ticket: "tkt-ro", expiresAt: "", readOnly: true });
|
||||||
|
|
||||||
|
render(<SessionTerminal sessionId="s1" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||||
|
expect(FakeWS.instances[0].url).toContain("ticket=tkt-ro");
|
||||||
|
expect(Terminal).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
cursorBlink: false,
|
||||||
|
disableStdin: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockTerm.onData).not.toHaveBeenCalled();
|
||||||
|
expect(mockTerm.attachCustomKeyEventHandler).not.toHaveBeenCalled();
|
||||||
|
expect(await screen.findByText("Read-only")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("relies on native xterm paste while applying the default terminal font preference", async () => {
|
it("relies on native xterm paste while applying the default terminal font preference", async () => {
|
||||||
@@ -145,7 +185,6 @@ describe("SessionTerminal", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expectMeasurementSafeFontStack(mockTerm.options.fontFamily as string);
|
expectMeasurementSafeFontStack(mockTerm.options.fontFamily as string);
|
||||||
expect(mockTerm.attachCustomKeyEventHandler).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
const inputHandler = mockTerm.onData.mock.calls[0]?.[0] as
|
const inputHandler = mockTerm.onData.mock.calls[0]?.[0] as
|
||||||
| ((data: string) => void)
|
| ((data: string) => void)
|
||||||
@@ -158,6 +197,55 @@ describe("SessionTerminal", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("drops physical input frames when the attach WebSocket is not open", async () => {
|
||||||
|
render(<SessionTerminal sessionId="s1" />);
|
||||||
|
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||||
|
expect(mockTerm.onData).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const inputHandler = mockTerm.onData.mock.calls[0]?.[0] as ((data: string) => void) | undefined;
|
||||||
|
FakeWS.instances[0].readyState = 3;
|
||||||
|
inputHandler?.("dropped");
|
||||||
|
|
||||||
|
expect(FakeWS.instances[0].sent).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["mac", "MacIntel", { metaKey: true }],
|
||||||
|
["non-mac", "Win32", { ctrlKey: true }],
|
||||||
|
] as const)("preserves physical copy/paste terminal semantics on %s", async (_name, platform, modifier) => {
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const readText = vi.fn().mockResolvedValue("ignored because xterm handles paste");
|
||||||
|
Object.defineProperty(navigator, "platform", {
|
||||||
|
value: platform,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(navigator, "clipboard", {
|
||||||
|
value: { writeText, readText },
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SessionTerminal sessionId="s1" />);
|
||||||
|
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||||
|
await waitFor(() => expect(sessionKeyEventHandler).not.toBeNull());
|
||||||
|
|
||||||
|
mockTerm.hasSelection.mockReturnValue(true);
|
||||||
|
mockTerm.getSelection.mockReturnValue("selected cli output");
|
||||||
|
expect(sessionKeyEventHandler?.(new KeyboardEvent("keydown", { key: "c", ...modifier }))).toBe(false);
|
||||||
|
await waitFor(() => expect(writeText).toHaveBeenCalledWith("selected cli output"));
|
||||||
|
|
||||||
|
mockTerm.hasSelection.mockReturnValue(false);
|
||||||
|
expect(sessionKeyEventHandler?.(new KeyboardEvent("keydown", { key: "c", ...modifier }))).toBe(true);
|
||||||
|
|
||||||
|
const beforePasteFrames = FakeWS.instances[0].sent.length;
|
||||||
|
expect(sessionKeyEventHandler?.(new KeyboardEvent("keydown", { key: "v", ...modifier }))).toBe(true);
|
||||||
|
expect(readText).not.toHaveBeenCalled();
|
||||||
|
const inputHandler = mockTerm.onData.mock.calls[0]?.[0] as ((data: string) => void) | undefined;
|
||||||
|
inputHandler?.("pasted once");
|
||||||
|
expect(FakeWS.instances[0].sent.slice(beforePasteFrames)).toEqual([
|
||||||
|
JSON.stringify({ type: "input", data: "pasted once" }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("refits after font settlement even when iOS rejects the font-load shorthand", async () => {
|
it("refits after font settlement even when iOS rejects the font-load shorthand", async () => {
|
||||||
const load = vi.fn(() => Promise.reject(new DOMException("Invalid font shorthand")));
|
const load = vi.fn(() => Promise.reject(new DOMException("Invalid font shorthand")));
|
||||||
Object.defineProperty(document, "fonts", {
|
Object.defineProperty(document, "fonts", {
|
||||||
|
|||||||
@@ -1217,6 +1217,39 @@ describe("TerminalModal", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends sticky Ctrl shortcut bytes and clears the modifier after each delivery", async () => {
|
||||||
|
const terminalDiv = document.createElement("div");
|
||||||
|
terminalDiv.setAttribute("data-testid", "terminal");
|
||||||
|
const helperTextarea = document.createElement("textarea");
|
||||||
|
helperTextarea.className = "xterm-helper-textarea";
|
||||||
|
terminalDiv.appendChild(helperTextarea);
|
||||||
|
document.body.appendChild(terminalDiv);
|
||||||
|
|
||||||
|
try {
|
||||||
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
|
helperTextarea.focus();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
|
||||||
|
const ctrlButton = screen.getByTestId("terminal-modifier-ctrl");
|
||||||
|
|
||||||
|
for (const [label, expected] of [
|
||||||
|
["C", "\x03"],
|
||||||
|
["D", "\x04"],
|
||||||
|
["L", "\x0c"],
|
||||||
|
] as const) {
|
||||||
|
fireEvent.click(ctrlButton);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: label }));
|
||||||
|
expect(mockSendInput).toHaveBeenLastCalledWith(expected);
|
||||||
|
expect(ctrlButton.getAttribute("aria-pressed")).toBe("false");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockSendInput.mock.calls.map(([value]) => value)).toEqual(["\x03", "\x04", "\x0c"]);
|
||||||
|
expect(document.activeElement).toBe(helperTextarea);
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(terminalDiv);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("sends literal ANSI arrow sequences independent of sticky modifiers", async () => {
|
it("sends literal ANSI arrow sequences independent of sticky modifiers", async () => {
|
||||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user