FN-5885: fix terminal copy/paste shortcuts
Restore expected terminal clipboard shortcuts. - intercept Ctrl/Cmd+C in the dashboard terminal to copy the current selection while preserving plain SIGINT when nothing is selected - intercept Ctrl/Cmd+V to read clipboard text and send it into the active PTY session - add terminal shortcut regression coverage and document the new integrated terminal behavior - add a patch changeset for @runfusion/fusion Files changed: .changeset/friendly-ravens-hammer.md | 5 + docs/dashboard-guide.md | 2 + packages/dashboard/app/components/TerminalModal.tsx | 48 +++++++++ packages/dashboard/app/components/__tests__/TerminalModal.test.tsx | 113 +++++++++++++++++++++ 4 files changed, 168 insertions(+) Fusion-Task-Id: FN-5885 Fusion-Task-Lineage: f8b3658c-2b52-4094-8cef-79c762f4d78d
This commit is contained in:
5
.changeset/friendly-ravens-hammer.md
Normal file
5
.changeset/friendly-ravens-hammer.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix the integrated dashboard terminal so Ctrl/Cmd+C copies selected terminal text without swallowing plain SIGINT behavior, and Ctrl/Cmd+V pastes clipboard text into the active session.
|
||||
@@ -224,6 +224,8 @@ Features:
|
||||
|
||||
- Multiple terminal tabs
|
||||
- PTY-backed shell sessions
|
||||
- Ctrl/Cmd+C copies the current terminal selection, while plain Ctrl+C with no selection still sends SIGINT
|
||||
- Ctrl/Cmd+V pastes clipboard text into the active terminal session
|
||||
- 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)
|
||||
|
||||
|
||||
@@ -161,6 +161,16 @@ function isMobileDevice(): boolean {
|
||||
return hasTouchScreen && isNarrow;
|
||||
}
|
||||
|
||||
function isMacPlatform(): boolean {
|
||||
if (typeof navigator === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const platform = navigator.platform ?? "";
|
||||
const userAgent = navigator.userAgent ?? "";
|
||||
return /mac/i.test(platform) || /mac/i.test(userAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute how many CSS pixels the virtual keyboard covers from the bottom
|
||||
* of the layout viewport. Returns 0 on desktop or when visualViewport is
|
||||
@@ -649,6 +659,44 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
||||
sendInputRef.current(data);
|
||||
});
|
||||
|
||||
terminal.attachCustomKeyEventHandler((event) => {
|
||||
if (event.type !== "keydown") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isModifierPressed = isMacPlatform() ? event.metaKey : event.ctrlKey;
|
||||
if (!isModifierPressed || event.altKey || event.shiftKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
|
||||
if (key === "c") {
|
||||
const selection = terminal.hasSelection() ? terminal.getSelection() : "";
|
||||
if (!selection) {
|
||||
return true;
|
||||
}
|
||||
|
||||
navigator.clipboard?.writeText(selection).catch(() => {
|
||||
// Ignore clipboard permission/errors so terminal input stays responsive.
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (key === "v") {
|
||||
navigator.clipboard?.readText().then((text) => {
|
||||
if (text) {
|
||||
sendInputRef.current(text);
|
||||
}
|
||||
}).catch(() => {
|
||||
// Ignore clipboard permission/errors so terminal input stays responsive.
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Window resize listener bound to this xterm. Tracked in a ref so it
|
||||
// can be removed when xterm is disposed (modal close, tab switch).
|
||||
const resizeHandler = () => {
|
||||
|
||||
@@ -23,10 +23,18 @@ vi.mock("../../api", () => ({
|
||||
// Mock xterm modules to prevent DOM errors in jsdom
|
||||
const mockFitAddonFit = vi.fn();
|
||||
|
||||
let terminalKeyEventHandler: ((event: KeyboardEvent) => boolean) | null = null;
|
||||
|
||||
const mockTerminalInstance = {
|
||||
loadAddon: vi.fn(),
|
||||
open: vi.fn(),
|
||||
onData: vi.fn((_cb: (data: string) => void) => ({ dispose: vi.fn() })),
|
||||
attachCustomKeyEventHandler: vi.fn((handler: (event: KeyboardEvent) => boolean) => {
|
||||
terminalKeyEventHandler = handler;
|
||||
}),
|
||||
hasSelection: vi.fn(() => false),
|
||||
getSelection: vi.fn(() => ""),
|
||||
paste: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
write: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
@@ -122,7 +130,18 @@ describe("TerminalModal", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
terminalKeyEventHandler = null;
|
||||
mockFitAddonFit.mockClear();
|
||||
mockTerminalInstance.hasSelection.mockReturnValue(false);
|
||||
mockTerminalInstance.getSelection.mockReturnValue("");
|
||||
Object.defineProperty(navigator, "platform", {
|
||||
value: "Win32",
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
});
|
||||
window.localStorage.removeItem(TERMINAL_FONT_SIZE_KEY);
|
||||
mockTerminalInstance.options.fontSize = 14;
|
||||
mockCreateTerminalSession.mockResolvedValue({
|
||||
@@ -3993,6 +4012,100 @@ 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 () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "platform", {
|
||||
value: "Win32",
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
mockTerminalInstance.hasSelection.mockReturnValue(true);
|
||||
mockTerminalInstance.getSelection.mockReturnValue("copied output");
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(terminalKeyEventHandler).not.toBeNull();
|
||||
});
|
||||
|
||||
const handled = terminalKeyEventHandler?.(
|
||||
new KeyboardEvent("keydown", { key: "c", ctrlKey: true }),
|
||||
);
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(writeText).toHaveBeenCalledWith("copied output");
|
||||
expect(mockSendInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves sigint on ctrl+c when nothing is selected", async () => {
|
||||
Object.defineProperty(navigator, "platform", {
|
||||
value: "Win32",
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
configurable: true,
|
||||
});
|
||||
mockTerminalInstance.hasSelection.mockReturnValue(false);
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(terminalKeyEventHandler).not.toBeNull();
|
||||
});
|
||||
|
||||
const handled = terminalKeyEventHandler?.(
|
||||
new KeyboardEvent("keydown", { key: "c", ctrlKey: true }),
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
});
|
||||
|
||||
it("pastes clipboard text into the active session on cmd+v", async () => {
|
||||
const readText = vi.fn().mockResolvedValue("npm test\n");
|
||||
Object.defineProperty(navigator, "platform", {
|
||||
value: "MacIntel",
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { readText },
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(terminalKeyEventHandler).not.toBeNull();
|
||||
});
|
||||
|
||||
const handled = terminalKeyEventHandler?.(
|
||||
new KeyboardEvent("keydown", { key: "v", metaKey: true }),
|
||||
);
|
||||
|
||||
expect(handled).toBe(false);
|
||||
await waitFor(() => {
|
||||
expect(readText).toHaveBeenCalled();
|
||||
expect(mockSendInput).toHaveBeenCalledWith("npm test\n");
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves unrelated key handling untouched", async () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(terminalKeyEventHandler).not.toBeNull();
|
||||
});
|
||||
|
||||
const handled = terminalKeyEventHandler?.(
|
||||
new KeyboardEvent("keydown", { key: "x", ctrlKey: true }),
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps terminal input forwarding active after active-tab title rerenders", async () => {
|
||||
let terminalInputCallback: ((data: string) => void) | null = null;
|
||||
const disposeInputHandler = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user