feat(FN-1602): merge fusion/fn-1602
This commit is contained in:
11
.changeset/fix-terminal-text-input-regression.md
Normal file
11
.changeset/fix-terminal-text-input-regression.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
"@gsxdsm/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix terminal text entry not working in the dashboard.
|
||||||
|
|
||||||
|
This fix ensures xterm's hidden textarea receives proper focus after initialization by:
|
||||||
|
1. Focusing the helper textarea directly after `terminal.open()`
|
||||||
|
2. Dispatching a synthetic click event on the terminal container to trigger xterm's internal focus tracking
|
||||||
|
|
||||||
|
These changes address the root cause where programmatic `focus()` calls alone did not properly trigger xterm.js's internal focus management, which relies on canvas click events for full input handling setup.
|
||||||
@@ -401,9 +401,40 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
|||||||
// Clear watchdog — imports and open() succeeded within deadline
|
// Clear watchdog — imports and open() succeeded within deadline
|
||||||
clearTimeout(watchdogTimer);
|
clearTimeout(watchdogTimer);
|
||||||
|
|
||||||
|
// Ensure xterm's textarea receives focus for keyboard input.
|
||||||
|
// xterm.js creates a hidden textarea that captures keyboard events.
|
||||||
|
// We focus the textarea directly and dispatch a synthetic click on
|
||||||
|
// the container to trigger xterm's internal focus tracking.
|
||||||
|
const helperTextarea = terminalRef.current?.querySelector(
|
||||||
|
".xterm-helper-textarea",
|
||||||
|
) as HTMLTextAreaElement | undefined;
|
||||||
|
if (helperTextarea) {
|
||||||
|
helperTextarea.focus();
|
||||||
|
}
|
||||||
|
// Dispatch a click event on the xterm container to ensure xterm's
|
||||||
|
// internal focus tracking is properly initialized. This is necessary
|
||||||
|
// because xterm relies on canvas click events for full focus setup.
|
||||||
|
if (terminalRef.current) {
|
||||||
|
try {
|
||||||
|
terminalRef.current.dispatchEvent(new MouseEvent("click", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// Ignore event dispatch errors in non-browser environments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initial fit
|
// Initial fit
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
|
// Re-focus after fit in case the DOM changed
|
||||||
|
const textarea = terminalRef.current?.querySelector(
|
||||||
|
".xterm-helper-textarea",
|
||||||
|
) as HTMLTextAreaElement | undefined;
|
||||||
|
if (textarea) {
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|
||||||
xtermRef.current = terminal;
|
xtermRef.current = terminal;
|
||||||
@@ -614,7 +645,23 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (connectionStatus === "connected" && xtermRef.current) {
|
if (connectionStatus === "connected" && xtermRef.current) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
xtermRef.current?.focus();
|
if (!xtermRef.current || !terminalRef.current) return;
|
||||||
|
// Focus the xterm textarea directly for keyboard input
|
||||||
|
const helperTextarea = terminalRef.current.querySelector(
|
||||||
|
".xterm-helper-textarea",
|
||||||
|
) as HTMLTextAreaElement | undefined;
|
||||||
|
if (helperTextarea) {
|
||||||
|
helperTextarea.focus();
|
||||||
|
}
|
||||||
|
// Also dispatch a click to trigger xterm's internal focus tracking
|
||||||
|
try {
|
||||||
|
terminalRef.current.dispatchEvent(new MouseEvent("click", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// Ignore event dispatch errors in non-browser environments
|
||||||
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
}, [connectionStatus]);
|
}, [connectionStatus]);
|
||||||
|
|||||||
@@ -3419,3 +3419,113 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", ()
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- xterm focus initialization regression tests ---
|
||||||
|
describe("TerminalModal — xterm focus initialization (FN-1602)", () => {
|
||||||
|
const mockOnClose = vi.fn();
|
||||||
|
const mockSendInput = vi.fn();
|
||||||
|
const mockResize = vi.fn();
|
||||||
|
const mockReconnect = vi.fn();
|
||||||
|
|
||||||
|
const createMockTerminalState = (overrides = {}) => ({
|
||||||
|
connectionStatus: "disconnected" as const,
|
||||||
|
sendInput: mockSendInput,
|
||||||
|
resize: mockResize,
|
||||||
|
onData: vi.fn(() => vi.fn()),
|
||||||
|
onExit: vi.fn(() => vi.fn()),
|
||||||
|
onConnect: vi.fn(() => vi.fn()),
|
||||||
|
onScrollback: vi.fn(() => vi.fn()),
|
||||||
|
reconnect: mockReconnect,
|
||||||
|
onSessionInvalid: vi.fn(() => vi.fn()),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockCreateTerminalSession.mockResolvedValue({
|
||||||
|
sessionId: "test-session-123",
|
||||||
|
shell: "/bin/bash",
|
||||||
|
cwd: "/project",
|
||||||
|
});
|
||||||
|
mockKillPtyTerminalSession.mockResolvedValue({ killed: true });
|
||||||
|
mockUseTerminal.mockReturnValue(createMockTerminalState());
|
||||||
|
mockUseTerminalSessions.mockReturnValue(defaultSessionState);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression: terminal text entry not working after xterm initialization.
|
||||||
|
*
|
||||||
|
* The original bug occurred because xterm's programmatic focus() call did not
|
||||||
|
* properly trigger xterm's internal focus tracking. xterm.js relies on
|
||||||
|
* canvas click events to set up focus handling, so we now:
|
||||||
|
* 1. Focus the helper textarea directly after terminal.open()
|
||||||
|
* 2. Dispatch a synthetic click on the container to trigger xterm's
|
||||||
|
* internal focus tracking
|
||||||
|
*/
|
||||||
|
it("renders terminal container after xterm is ready", async () => {
|
||||||
|
mockUseTerminal.mockReturnValue(
|
||||||
|
createMockTerminalState({ connectionStatus: "connected" })
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Terminal container should be rendered
|
||||||
|
expect(screen.getByTestId("terminal-xterm")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles dispatchEvent errors gracefully in non-browser environments", async () => {
|
||||||
|
// Simulate dispatchEvent throwing an error (e.g., in jsdom without proper setup)
|
||||||
|
const originalDispatchEvent = Element.prototype.dispatchEvent;
|
||||||
|
Element.prototype.dispatchEvent = vi.fn(() => {
|
||||||
|
throw new Error("dispatchEvent not supported");
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseTerminal.mockReturnValue(
|
||||||
|
createMockTerminalState({ connectionStatus: "connected" })
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should not throw despite dispatchEvent failing
|
||||||
|
expect(() => {
|
||||||
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
|
}).not.toThrow();
|
||||||
|
|
||||||
|
// Restore original method
|
||||||
|
Element.prototype.dispatchEvent = originalDispatchEvent;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues to work when connection status changes after initial render", async () => {
|
||||||
|
// Start with disconnected
|
||||||
|
mockUseTerminal.mockReturnValue(
|
||||||
|
createMockTerminalState({ connectionStatus: "disconnected" })
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<TerminalModal isOpen={true} onClose={mockOnClose} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// xterm should still initialize
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now simulate connection becoming established
|
||||||
|
mockUseTerminal.mockReturnValue(
|
||||||
|
createMockTerminalState({ connectionStatus: "connected" })
|
||||||
|
);
|
||||||
|
|
||||||
|
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
|
// Modal should still render correctly
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user