feat(web): implement multi-session terminal tab management (#520)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #520.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file TerminalPanel.test.tsx
|
* @file TerminalPanel.test.tsx
|
||||||
* @description Unit tests for the TerminalPanel component
|
* @description Unit tests for the TerminalPanel component — multi-tab scenarios
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
@@ -13,27 +13,93 @@ import type { ReactElement } from "react";
|
|||||||
|
|
||||||
// Mock XTerminal to avoid xterm.js DOM dependencies in panel tests
|
// Mock XTerminal to avoid xterm.js DOM dependencies in panel tests
|
||||||
vi.mock("./XTerminal", () => ({
|
vi.mock("./XTerminal", () => ({
|
||||||
XTerminal: vi.fn(({ token, isVisible }: { token: string; isVisible: boolean }) => (
|
XTerminal: vi.fn(
|
||||||
<div
|
({
|
||||||
data-testid="mock-xterminal"
|
sessionId,
|
||||||
data-token={token}
|
isVisible,
|
||||||
data-visible={isVisible ? "true" : "false"}
|
sessionStatus,
|
||||||
/>
|
}: {
|
||||||
)),
|
sessionId: string;
|
||||||
|
isVisible: boolean;
|
||||||
|
sessionStatus: string;
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
data-testid="mock-xterminal"
|
||||||
|
data-session-id={sessionId}
|
||||||
|
data-visible={isVisible ? "true" : "false"}
|
||||||
|
data-status={sessionStatus}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock useTerminalSessions
|
||||||
|
const mockCreateSession = vi.fn();
|
||||||
|
const mockCloseSession = vi.fn();
|
||||||
|
const mockRenameSession = vi.fn();
|
||||||
|
const mockSetActiveSession = vi.fn();
|
||||||
|
const mockSendInput = vi.fn();
|
||||||
|
const mockResize = vi.fn();
|
||||||
|
const mockRegisterOutputCallback = vi.fn(() => vi.fn());
|
||||||
|
|
||||||
|
// Mutable state for the mock — tests update these
|
||||||
|
let mockSessions = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
sessionId: string;
|
||||||
|
name: string;
|
||||||
|
status: "active" | "exited";
|
||||||
|
exitCode?: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
let mockActiveSessionId: string | null = null;
|
||||||
|
let mockIsConnected = false;
|
||||||
|
let mockConnectionError: string | null = null;
|
||||||
|
|
||||||
|
vi.mock("@/hooks/useTerminalSessions", () => ({
|
||||||
|
useTerminalSessions: vi.fn(() => ({
|
||||||
|
sessions: mockSessions,
|
||||||
|
activeSessionId: mockActiveSessionId,
|
||||||
|
isConnected: mockIsConnected,
|
||||||
|
connectionError: mockConnectionError,
|
||||||
|
createSession: mockCreateSession,
|
||||||
|
closeSession: mockCloseSession,
|
||||||
|
renameSession: mockRenameSession,
|
||||||
|
setActiveSession: mockSetActiveSession,
|
||||||
|
sendInput: mockSendInput,
|
||||||
|
resize: mockResize,
|
||||||
|
registerOutputCallback: mockRegisterOutputCallback,
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { TerminalPanel } from "./TerminalPanel";
|
import { TerminalPanel } from "./TerminalPanel";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Helpers
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
function setTwoSessions(): void {
|
||||||
|
mockSessions = new Map([
|
||||||
|
["session-1", { sessionId: "session-1", name: "Terminal 1", status: "active" }],
|
||||||
|
["session-2", { sessionId: "session-2", name: "Terminal 2", status: "active" }],
|
||||||
|
]);
|
||||||
|
mockActiveSessionId = "session-1";
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Tests
|
// Tests
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
describe("TerminalPanel", () => {
|
describe("TerminalPanel", () => {
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
const onTabChange = vi.fn();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockSessions = new Map();
|
||||||
|
mockActiveSessionId = null;
|
||||||
|
mockIsConnected = false;
|
||||||
|
mockConnectionError = null;
|
||||||
|
mockRegisterOutputCallback.mockReturnValue(vi.fn());
|
||||||
});
|
});
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -60,112 +126,249 @@ describe("TerminalPanel", () => {
|
|||||||
expect(panel).toHaveStyle({ height: "0px" });
|
expect(panel).toHaveStyle({ height: "0px" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes isVisible=true to XTerminal when open", () => {
|
it("renders empty state when no sessions exist", () => {
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
const xterm = screen.getByTestId("mock-xterminal");
|
// No XTerminal instances should be mounted
|
||||||
expect(xterm).toHaveAttribute("data-visible", "true");
|
expect(screen.queryByTestId("mock-xterminal")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes isVisible=false to XTerminal when closed", () => {
|
it("shows connecting message in empty state when not connected", () => {
|
||||||
const { container } = render(
|
mockIsConnected = false;
|
||||||
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
);
|
expect(screen.getByText("Connecting...")).toBeInTheDocument();
|
||||||
// Use container query since the element is inside an aria-hidden region
|
|
||||||
const xterm = container.querySelector('[data-testid="mock-xterminal"]');
|
|
||||||
expect(xterm).toHaveAttribute("data-visible", "false");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes token to XTerminal", () => {
|
it("shows creating message in empty state when connected", () => {
|
||||||
render(
|
mockIsConnected = true;
|
||||||
(<TerminalPanel open={true} onClose={onClose} token="my-auth-token" />) as ReactElement
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
);
|
expect(screen.getByText("Creating terminal...")).toBeInTheDocument();
|
||||||
const xterm = screen.getByTestId("mock-xterminal");
|
|
||||||
expect(xterm).toHaveAttribute("data-token", "my-auth-token");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Tab bar
|
// Tab bar from sessions
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
describe("tab bar", () => {
|
describe("tab bar", () => {
|
||||||
it("renders default tabs when none provided", () => {
|
it("renders a tab for each session", () => {
|
||||||
|
setTwoSessions();
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
expect(screen.getByRole("tab", { name: "main" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders custom tabs", () => {
|
|
||||||
const tabs = [
|
|
||||||
{ id: "tab1", label: "Terminal 1" },
|
|
||||||
{ id: "tab2", label: "Terminal 2" },
|
|
||||||
];
|
|
||||||
render(
|
|
||||||
(
|
|
||||||
<TerminalPanel open={true} onClose={onClose} tabs={tabs} token="test-token" />
|
|
||||||
) as ReactElement
|
|
||||||
);
|
|
||||||
expect(screen.getByRole("tab", { name: "Terminal 1" })).toBeInTheDocument();
|
expect(screen.getByRole("tab", { name: "Terminal 1" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("tab", { name: "Terminal 2" })).toBeInTheDocument();
|
expect(screen.getByRole("tab", { name: "Terminal 2" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks the active tab as selected", () => {
|
it("marks the active session tab as selected", () => {
|
||||||
const tabs = [
|
setTwoSessions();
|
||||||
{ id: "tab1", label: "Tab 1" },
|
mockActiveSessionId = "session-2";
|
||||||
{ id: "tab2", label: "Tab 2" },
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
];
|
expect(screen.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute(
|
||||||
render(
|
"aria-selected",
|
||||||
(
|
"true"
|
||||||
<TerminalPanel
|
);
|
||||||
open={true}
|
expect(screen.getByRole("tab", { name: "Terminal 1" })).toHaveAttribute(
|
||||||
onClose={onClose}
|
"aria-selected",
|
||||||
tabs={tabs}
|
"false"
|
||||||
activeTab="tab2"
|
|
||||||
token="test-token"
|
|
||||||
/>
|
|
||||||
) as ReactElement
|
|
||||||
);
|
);
|
||||||
const tab2 = screen.getByRole("tab", { name: "Tab 2" });
|
|
||||||
expect(tab2).toHaveAttribute("aria-selected", "true");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onTabChange when a tab is clicked", () => {
|
it("calls setActiveSession when a tab is clicked", () => {
|
||||||
const tabs = [
|
setTwoSessions();
|
||||||
{ id: "tab1", label: "Tab 1" },
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
{ id: "tab2", label: "Tab 2" },
|
fireEvent.click(screen.getByRole("tab", { name: "Terminal 2" }));
|
||||||
];
|
expect(mockSetActiveSession).toHaveBeenCalledWith("session-2");
|
||||||
render(
|
});
|
||||||
(
|
|
||||||
<TerminalPanel
|
it("has tablist role on the tab bar", () => {
|
||||||
open={true}
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
onClose={onClose}
|
expect(screen.getByRole("tablist")).toBeInTheDocument();
|
||||||
tabs={tabs}
|
|
||||||
onTabChange={onTabChange}
|
|
||||||
token="test-token"
|
|
||||||
/>
|
|
||||||
) as ReactElement
|
|
||||||
);
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Tab 2" }));
|
|
||||||
expect(onTabChange).toHaveBeenCalledWith("tab2");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Close button
|
// New tab button
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
describe("close button", () => {
|
describe("new tab button", () => {
|
||||||
it("renders the close button", () => {
|
it("renders the new tab button", () => {
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.getByRole("button", { name: "New terminal tab" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls createSession when new tab button is clicked", () => {
|
||||||
|
setTwoSessions();
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "New terminal tab" }));
|
||||||
|
expect(mockCreateSession).toHaveBeenCalledWith(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
expect.objectContaining({ name: expect.any(String) })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Per-tab close button
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("per-tab close button", () => {
|
||||||
|
it("renders a close button for each tab", () => {
|
||||||
|
setTwoSessions();
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.getByRole("button", { name: "Close Terminal 1" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Close Terminal 2" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls closeSession with the correct sessionId when tab close is clicked", () => {
|
||||||
|
setTwoSessions();
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Close Terminal 1" }));
|
||||||
|
expect(mockCloseSession).toHaveBeenCalledWith("session-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Panel close button
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("panel close button", () => {
|
||||||
|
it("renders the close panel button", () => {
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
expect(screen.getByRole("button", { name: "Close terminal" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Close terminal" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onClose when close button is clicked", () => {
|
it("calls onClose when close panel button is clicked", () => {
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Close terminal" }));
|
fireEvent.click(screen.getByRole("button", { name: "Close terminal" }));
|
||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Multi-tab XTerminal rendering
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("multi-tab terminal rendering", () => {
|
||||||
|
it("renders an XTerminal for each session", () => {
|
||||||
|
setTwoSessions();
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
const terminals = screen.getAllByTestId("mock-xterminal");
|
||||||
|
expect(terminals).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the active session terminal as visible", () => {
|
||||||
|
setTwoSessions();
|
||||||
|
mockActiveSessionId = "session-1";
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
const terminal1 = screen
|
||||||
|
.getAllByTestId("mock-xterminal")
|
||||||
|
.find((el) => el.getAttribute("data-session-id") === "session-1");
|
||||||
|
expect(terminal1).toHaveAttribute("data-visible", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides inactive session terminals", () => {
|
||||||
|
setTwoSessions();
|
||||||
|
mockActiveSessionId = "session-1";
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
const terminal2 = screen
|
||||||
|
.getAllByTestId("mock-xterminal")
|
||||||
|
.find((el) => el.getAttribute("data-session-id") === "session-2");
|
||||||
|
expect(terminal2).toHaveAttribute("data-visible", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes sessionStatus to XTerminal", () => {
|
||||||
|
mockSessions = new Map([
|
||||||
|
[
|
||||||
|
"session-1",
|
||||||
|
{ sessionId: "session-1", name: "Terminal 1", status: "exited", exitCode: 0 },
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
mockActiveSessionId = "session-1";
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
const terminal = screen.getByTestId("mock-xterminal");
|
||||||
|
expect(terminal).toHaveAttribute("data-status", "exited");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes isVisible=false to all terminals when panel is closed", () => {
|
||||||
|
setTwoSessions();
|
||||||
|
const { container } = render(
|
||||||
|
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement
|
||||||
|
);
|
||||||
|
const terminals = container.querySelectorAll('[data-testid="mock-xterminal"]');
|
||||||
|
terminals.forEach((terminal) => {
|
||||||
|
expect(terminal).toHaveAttribute("data-visible", "false");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Inline tab rename
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("tab rename", () => {
|
||||||
|
it("shows a rename input when a tab is double-clicked", () => {
|
||||||
|
setTwoSessions();
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" }));
|
||||||
|
expect(screen.getByTestId("tab-rename-input")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls renameSession when rename input loses focus", () => {
|
||||||
|
setTwoSessions();
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" }));
|
||||||
|
|
||||||
|
const input = screen.getByTestId("tab-rename-input");
|
||||||
|
fireEvent.change(input, { target: { value: "Custom Shell" } });
|
||||||
|
fireEvent.blur(input);
|
||||||
|
|
||||||
|
expect(mockRenameSession).toHaveBeenCalledWith("session-1", "Custom Shell");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls renameSession when Enter is pressed in the rename input", () => {
|
||||||
|
setTwoSessions();
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" }));
|
||||||
|
|
||||||
|
const input = screen.getByTestId("tab-rename-input");
|
||||||
|
fireEvent.change(input, { target: { value: "New Name" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
|
||||||
|
expect(mockRenameSession).toHaveBeenCalledWith("session-1", "New Name");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels rename when Escape is pressed", () => {
|
||||||
|
setTwoSessions();
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" }));
|
||||||
|
|
||||||
|
const input = screen.getByTestId("tab-rename-input");
|
||||||
|
fireEvent.change(input, { target: { value: "Abandoned Name" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Escape" });
|
||||||
|
|
||||||
|
expect(mockRenameSession).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByTestId("tab-rename-input")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Connection error banner
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("connection error", () => {
|
||||||
|
it("shows a connection error banner when connectionError is set", () => {
|
||||||
|
mockConnectionError = "WebSocket connection failed";
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
const alert = screen.getByRole("alert");
|
||||||
|
expect(alert).toBeInTheDocument();
|
||||||
|
expect(alert).toHaveTextContent(/WebSocket connection failed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show the error banner when connectionError is null", () => {
|
||||||
|
mockConnectionError = null;
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Accessibility
|
// Accessibility
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -175,8 +378,6 @@ describe("TerminalPanel", () => {
|
|||||||
const { container } = render(
|
const { container } = render(
|
||||||
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement
|
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement
|
||||||
);
|
);
|
||||||
// When aria-hidden=true, testing-library role queries ignore the element's aria-label.
|
|
||||||
// Use a direct DOM query to verify the attribute.
|
|
||||||
const panel = container.querySelector('[role="region"][aria-label="Terminal panel"]');
|
const panel = container.querySelector('[role="region"][aria-label="Terminal panel"]');
|
||||||
expect(panel).toHaveAttribute("aria-hidden", "true");
|
expect(panel).toHaveAttribute("aria-hidden", "true");
|
||||||
});
|
});
|
||||||
@@ -186,10 +387,42 @@ describe("TerminalPanel", () => {
|
|||||||
const panel = screen.getByRole("region", { name: "Terminal panel" });
|
const panel = screen.getByRole("region", { name: "Terminal panel" });
|
||||||
expect(panel).toHaveAttribute("aria-hidden", "false");
|
expect(panel).toHaveAttribute("aria-hidden", "false");
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("has tablist role on the tab bar", () => {
|
// ==========================================
|
||||||
|
// Auto-create session
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("auto-create first session", () => {
|
||||||
|
it("calls createSession when connected and no sessions exist", () => {
|
||||||
|
mockIsConnected = true;
|
||||||
|
mockSessions = new Map();
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
expect(screen.getByRole("tablist")).toBeInTheDocument();
|
expect(mockCreateSession).toHaveBeenCalledWith(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
expect.objectContaining({ name: expect.any(String) })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call createSession when sessions already exist", () => {
|
||||||
|
mockIsConnected = true;
|
||||||
|
setTwoSessions();
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(mockCreateSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call createSession when not connected", () => {
|
||||||
|
mockIsConnected = false;
|
||||||
|
mockSessions = new Map();
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(mockCreateSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call createSession when panel is closed", () => {
|
||||||
|
mockIsConnected = true;
|
||||||
|
mockSessions = new Map();
|
||||||
|
render((<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(mockCreateSession).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,35 +3,38 @@
|
|||||||
/**
|
/**
|
||||||
* TerminalPanel
|
* TerminalPanel
|
||||||
*
|
*
|
||||||
* Shell panel that wraps the XTerminal component with a tab bar and close button.
|
* Multi-tab terminal panel. Manages multiple PTY sessions via useTerminalSessions,
|
||||||
* Replaces the former mock terminal with a real xterm.js PTY terminal.
|
* rendering one XTerminal per session and keeping all instances mounted (for
|
||||||
|
* scrollback preservation) while switching visibility with display:none.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - "+" button to open a new tab
|
||||||
|
* - Per-tab close button
|
||||||
|
* - Double-click tab label for inline rename
|
||||||
|
* - Auto-creates the first session on connect
|
||||||
|
* - Connection error state
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ReactElement, CSSProperties } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import type { ReactElement, CSSProperties, KeyboardEvent } from "react";
|
||||||
import { XTerminal } from "./XTerminal";
|
import { XTerminal } from "./XTerminal";
|
||||||
|
import { useTerminalSessions } from "@/hooks/useTerminalSessions";
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Types (retained for backwards compatibility)
|
// Types
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
export interface TerminalTab {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TerminalPanelProps {
|
export interface TerminalPanelProps {
|
||||||
|
/** Whether the panel is visible */
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
/** Called when the user closes the panel */
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
tabs?: TerminalTab[];
|
|
||||||
activeTab?: string;
|
|
||||||
onTabChange?: (id: string) => void;
|
|
||||||
/** Authentication token for the WebSocket connection */
|
/** Authentication token for the WebSocket connection */
|
||||||
token?: string;
|
token?: string;
|
||||||
|
/** Optional CSS class name */
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultTabs: TerminalTab[] = [{ id: "main", label: "main" }];
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Component
|
// Component
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -39,14 +42,107 @@ const defaultTabs: TerminalTab[] = [{ id: "main", label: "main" }];
|
|||||||
export function TerminalPanel({
|
export function TerminalPanel({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
tabs,
|
|
||||||
activeTab,
|
|
||||||
onTabChange,
|
|
||||||
token = "",
|
token = "",
|
||||||
className = "",
|
className = "",
|
||||||
}: TerminalPanelProps): ReactElement {
|
}: TerminalPanelProps): ReactElement {
|
||||||
const resolvedTabs = tabs ?? defaultTabs;
|
const {
|
||||||
const resolvedActiveTab = activeTab ?? resolvedTabs[0]?.id ?? "";
|
sessions,
|
||||||
|
activeSessionId,
|
||||||
|
isConnected,
|
||||||
|
connectionError,
|
||||||
|
createSession,
|
||||||
|
closeSession,
|
||||||
|
renameSession,
|
||||||
|
setActiveSession,
|
||||||
|
sendInput,
|
||||||
|
resize,
|
||||||
|
registerOutputCallback,
|
||||||
|
} = useTerminalSessions({ token });
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Inline rename state
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const [editingTabId, setEditingTabId] = useState<string | null>(null);
|
||||||
|
const [editingName, setEditingName] = useState("");
|
||||||
|
const editInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Focus the rename input when editing starts
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingTabId !== null) {
|
||||||
|
editInputRef.current?.select();
|
||||||
|
}
|
||||||
|
}, [editingTabId]);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Auto-create first session on connect
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && isConnected && sessions.size === 0) {
|
||||||
|
createSession({ name: "Terminal 1" });
|
||||||
|
}
|
||||||
|
}, [open, isConnected, sessions.size, createSession]);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Tab rename helpers
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const commitRename = useCallback((): void => {
|
||||||
|
if (editingTabId !== null) {
|
||||||
|
const trimmed = editingName.trim();
|
||||||
|
if (trimmed.length > 0) {
|
||||||
|
renameSession(editingTabId, trimmed);
|
||||||
|
}
|
||||||
|
setEditingTabId(null);
|
||||||
|
setEditingName("");
|
||||||
|
}
|
||||||
|
}, [editingTabId, editingName, renameSession]);
|
||||||
|
|
||||||
|
const handleTabDoubleClick = useCallback((sessionId: string, currentName: string): void => {
|
||||||
|
setEditingTabId(sessionId);
|
||||||
|
setEditingName(currentName);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRenameKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent<HTMLInputElement>): void => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
commitRename();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setEditingTabId(null);
|
||||||
|
setEditingName("");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[commitRename]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Session control helpers
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const handleCreateTab = useCallback((): void => {
|
||||||
|
const tabNumber = sessions.size + 1;
|
||||||
|
createSession({ name: `Terminal ${tabNumber.toString()}` });
|
||||||
|
}, [sessions.size, createSession]);
|
||||||
|
|
||||||
|
const handleCloseTab = useCallback(
|
||||||
|
(sessionId: string): void => {
|
||||||
|
closeSession(sessionId);
|
||||||
|
},
|
||||||
|
[closeSession]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRestart = useCallback(
|
||||||
|
(sessionId: string, name: string): void => {
|
||||||
|
closeSession(sessionId);
|
||||||
|
createSession({ name });
|
||||||
|
},
|
||||||
|
[closeSession, createSession]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Styles
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
const panelStyle: CSSProperties = {
|
const panelStyle: CSSProperties = {
|
||||||
background: "var(--bg-deep)",
|
background: "var(--bg-deep)",
|
||||||
@@ -71,12 +167,15 @@ export function TerminalPanel({
|
|||||||
const tabBarStyle: CSSProperties = {
|
const tabBarStyle: CSSProperties = {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: 2,
|
gap: 2,
|
||||||
|
alignItems: "center",
|
||||||
|
flex: 1,
|
||||||
|
overflow: "hidden",
|
||||||
};
|
};
|
||||||
|
|
||||||
const actionsStyle: CSSProperties = {
|
const actionsStyle: CSSProperties = {
|
||||||
marginLeft: "auto",
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: 4,
|
gap: 4,
|
||||||
|
alignItems: "center",
|
||||||
};
|
};
|
||||||
|
|
||||||
const bodyStyle: CSSProperties = {
|
const bodyStyle: CSSProperties = {
|
||||||
@@ -85,8 +184,13 @@ export function TerminalPanel({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
|
position: "relative",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Render
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={className}
|
className={className}
|
||||||
@@ -99,50 +203,172 @@ export function TerminalPanel({
|
|||||||
<div style={headerStyle}>
|
<div style={headerStyle}>
|
||||||
{/* Tab bar */}
|
{/* Tab bar */}
|
||||||
<div style={tabBarStyle} role="tablist" aria-label="Terminal tabs">
|
<div style={tabBarStyle} role="tablist" aria-label="Terminal tabs">
|
||||||
{resolvedTabs.map((tab) => {
|
{[...sessions.entries()].map(([sessionId, sessionInfo]) => {
|
||||||
const isActive = tab.id === resolvedActiveTab;
|
const isActive = sessionId === activeSessionId;
|
||||||
|
const isEditing = sessionId === editingTabId;
|
||||||
|
|
||||||
const tabStyle: CSSProperties = {
|
const tabStyle: CSSProperties = {
|
||||||
padding: "3px 10px",
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
padding: "3px 6px 3px 10px",
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
fontFamily: "var(--mono)",
|
fontFamily: "var(--mono)",
|
||||||
color: isActive ? "var(--success)" : "var(--muted)",
|
color: isActive ? "var(--success)" : "var(--muted)",
|
||||||
cursor: "pointer",
|
|
||||||
background: isActive ? "var(--surface)" : "transparent",
|
background: isActive ? "var(--surface)" : "transparent",
|
||||||
border: "none",
|
border: "none",
|
||||||
outline: "none",
|
outline: "none",
|
||||||
|
flexShrink: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div key={sessionId} style={tabStyle}>
|
||||||
key={tab.id}
|
{isEditing ? (
|
||||||
role="tab"
|
<input
|
||||||
aria-selected={isActive}
|
ref={editInputRef}
|
||||||
style={tabStyle}
|
value={editingName}
|
||||||
onClick={(): void => {
|
onChange={(e): void => {
|
||||||
onTabChange?.(tab.id);
|
setEditingName(e.target.value);
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e): void => {
|
onBlur={commitRename}
|
||||||
if (!isActive) {
|
onKeyDown={handleRenameKeyDown}
|
||||||
|
data-testid="tab-rename-input"
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
outline: "1px solid var(--primary)",
|
||||||
|
borderRadius: 2,
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "var(--text)",
|
||||||
|
width: `${Math.max(editingName.length, 4).toString()}ch`,
|
||||||
|
padding: "0 2px",
|
||||||
|
}}
|
||||||
|
aria-label="Rename terminal tab"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: isActive ? "var(--success)" : "var(--muted)",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
onClick={(): void => {
|
||||||
|
setActiveSession(sessionId);
|
||||||
|
}}
|
||||||
|
onDoubleClick={(): void => {
|
||||||
|
handleTabDoubleClick(sessionId, sessionInfo.name);
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e): void => {
|
||||||
|
if (!isActive) {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--text-2)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e): void => {
|
||||||
|
if (!isActive) {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={sessionInfo.name}
|
||||||
|
>
|
||||||
|
{sessionInfo.name}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Per-tab close button */}
|
||||||
|
<button
|
||||||
|
aria-label={`Close ${sessionInfo.name}`}
|
||||||
|
style={{
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: 3,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "var(--muted)",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
padding: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onClick={(): void => {
|
||||||
|
handleCloseTab(sessionId);
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e): void => {
|
||||||
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
|
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
|
||||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--text-2)";
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
||||||
}
|
}}
|
||||||
}}
|
onMouseLeave={(e): void => {
|
||||||
onMouseLeave={(e): void => {
|
|
||||||
if (!isActive) {
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||||
}
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
|
||||||
{tab.label}
|
<path
|
||||||
</button>
|
d="M1 1L7 7M7 1L1 7"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* New tab button */}
|
||||||
|
<button
|
||||||
|
aria-label="New terminal tab"
|
||||||
|
style={{
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 4,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "var(--muted)",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
padding: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onClick={handleCreateTab}
|
||||||
|
onMouseEnter={(e): void => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e): void => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Plus icon */}
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M6 1V11M1 6H11"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div style={actionsStyle}>
|
<div style={actionsStyle}>
|
||||||
|
{/* Close panel button */}
|
||||||
<button
|
<button
|
||||||
aria-label="Close terminal"
|
aria-label="Close terminal"
|
||||||
style={{
|
style={{
|
||||||
@@ -182,9 +408,72 @@ export function TerminalPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Terminal body */}
|
{/* Connection error banner */}
|
||||||
|
{connectionError !== null && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
padding: "4px 16px",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--danger)",
|
||||||
|
backgroundColor: "var(--bg-deep)",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Connection error: {connectionError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Terminal body — keep all XTerminal instances mounted for scrollback */}
|
||||||
<div style={bodyStyle}>
|
<div style={bodyStyle}>
|
||||||
<XTerminal token={token} isVisible={open} style={{ flex: 1, minHeight: 0 }} />
|
{[...sessions.entries()].map(([sessionId, sessionInfo]) => {
|
||||||
|
const isActive = sessionId === activeSessionId;
|
||||||
|
const termStyle: CSSProperties = {
|
||||||
|
display: isActive ? "flex" : "none",
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "column",
|
||||||
|
minHeight: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={sessionId} style={termStyle}>
|
||||||
|
<XTerminal
|
||||||
|
sessionId={sessionId}
|
||||||
|
sendInput={sendInput}
|
||||||
|
resize={resize}
|
||||||
|
closeSession={closeSession}
|
||||||
|
registerOutputCallback={registerOutputCallback}
|
||||||
|
isConnected={isConnected}
|
||||||
|
sessionStatus={sessionInfo.status}
|
||||||
|
{...(sessionInfo.exitCode !== undefined ? { exitCode: sessionInfo.exitCode } : {})}
|
||||||
|
isVisible={isActive && open}
|
||||||
|
onRestart={(): void => {
|
||||||
|
handleRestart(sessionId, sessionInfo.name);
|
||||||
|
}}
|
||||||
|
style={{ flex: 1, minHeight: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{sessions.size === 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isConnected ? "Creating terminal..." : (connectionError ?? "Connecting...")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,21 +4,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Mocks — set up before importing components
|
// Mocks — set up before importing components
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
// Mock socket.io-client
|
|
||||||
vi.mock("socket.io-client");
|
|
||||||
|
|
||||||
// Mock lib/config
|
|
||||||
vi.mock("@/lib/config", () => ({
|
|
||||||
API_BASE_URL: "http://localhost:3001",
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock xterm packages — they require a DOM canvas not available in jsdom
|
// Mock xterm packages — they require a DOM canvas not available in jsdom
|
||||||
const mockTerminalDispose = vi.fn();
|
const mockTerminalDispose = vi.fn();
|
||||||
const mockTerminalWrite = vi.fn();
|
const mockTerminalWrite = vi.fn();
|
||||||
@@ -76,26 +68,6 @@ vi.mock("@xterm/addon-web-links", () => ({
|
|||||||
// Mock the CSS import
|
// Mock the CSS import
|
||||||
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
|
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
|
||||||
|
|
||||||
// Mock useTerminal hook
|
|
||||||
const mockCreateSession = vi.fn();
|
|
||||||
const mockSendInput = vi.fn();
|
|
||||||
const mockResize = vi.fn();
|
|
||||||
const mockCloseSession = vi.fn();
|
|
||||||
let mockIsConnected = false;
|
|
||||||
let mockSessionId: string | null = null;
|
|
||||||
|
|
||||||
vi.mock("@/hooks/useTerminal", () => ({
|
|
||||||
useTerminal: vi.fn(() => ({
|
|
||||||
isConnected: mockIsConnected,
|
|
||||||
sessionId: mockSessionId,
|
|
||||||
createSession: mockCreateSession,
|
|
||||||
sendInput: mockSendInput,
|
|
||||||
resize: mockResize,
|
|
||||||
closeSession: mockCloseSession,
|
|
||||||
connectionError: null,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock ResizeObserver
|
// Mock ResizeObserver
|
||||||
const mockObserve = vi.fn();
|
const mockObserve = vi.fn();
|
||||||
const mockUnobserve = vi.fn();
|
const mockUnobserve = vi.fn();
|
||||||
@@ -128,6 +100,31 @@ vi.stubGlobal(
|
|||||||
|
|
||||||
import { XTerminal } from "./XTerminal";
|
import { XTerminal } from "./XTerminal";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Default props factory
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const mockSendInput = vi.fn();
|
||||||
|
const mockResize = vi.fn();
|
||||||
|
const mockCloseSession = vi.fn();
|
||||||
|
const mockRegisterOutputCallback = vi.fn(() => vi.fn()); // returns unsubscribe fn
|
||||||
|
const mockOnRestart = vi.fn();
|
||||||
|
|
||||||
|
function makeDefaultProps(
|
||||||
|
overrides: Partial<Parameters<typeof XTerminal>[0]> = {}
|
||||||
|
): Parameters<typeof XTerminal>[0] {
|
||||||
|
return {
|
||||||
|
sessionId: "session-test",
|
||||||
|
sendInput: mockSendInput,
|
||||||
|
resize: mockResize,
|
||||||
|
closeSession: mockCloseSession,
|
||||||
|
registerOutputCallback: mockRegisterOutputCallback,
|
||||||
|
isConnected: false,
|
||||||
|
sessionStatus: "active" as const,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Tests
|
// Tests
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -135,10 +132,9 @@ import { XTerminal } from "./XTerminal";
|
|||||||
describe("XTerminal", () => {
|
describe("XTerminal", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockIsConnected = false;
|
|
||||||
mockSessionId = null;
|
|
||||||
mockTerminalCols = 80;
|
mockTerminalCols = 80;
|
||||||
mockTerminalRows = 24;
|
mockTerminalRows = 24;
|
||||||
|
mockRegisterOutputCallback.mockReturnValue(vi.fn());
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -151,66 +147,101 @@ describe("XTerminal", () => {
|
|||||||
|
|
||||||
describe("rendering", () => {
|
describe("rendering", () => {
|
||||||
it("renders the terminal container", () => {
|
it("renders the terminal container", () => {
|
||||||
render((<XTerminal token="test-token" />) as ReactElement);
|
render((<XTerminal {...makeDefaultProps()} />) as ReactElement);
|
||||||
expect(screen.getByTestId("xterminal-container")).toBeInTheDocument();
|
expect(screen.getByTestId("xterminal-container")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the xterm viewport div", () => {
|
it("renders the xterm viewport div", () => {
|
||||||
render((<XTerminal token="test-token" />) as ReactElement);
|
render((<XTerminal {...makeDefaultProps()} />) as ReactElement);
|
||||||
expect(screen.getByTestId("xterm-viewport")).toBeInTheDocument();
|
expect(screen.getByTestId("xterm-viewport")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies the className prop to the container", () => {
|
it("applies the className prop to the container", () => {
|
||||||
render((<XTerminal token="test-token" className="custom-class" />) as ReactElement);
|
render((<XTerminal {...makeDefaultProps()} className="custom-class" />) as ReactElement);
|
||||||
expect(screen.getByTestId("xterminal-container")).toHaveClass("custom-class");
|
expect(screen.getByTestId("xterminal-container")).toHaveClass("custom-class");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows connecting message when not connected", () => {
|
it("sets data-session-id on the container", () => {
|
||||||
mockIsConnected = false;
|
render((<XTerminal {...makeDefaultProps({ sessionId: "my-session" })} />) as ReactElement);
|
||||||
|
expect(screen.getByTestId("xterminal-container")).toHaveAttribute(
|
||||||
|
"data-session-id",
|
||||||
|
"my-session"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
render((<XTerminal token="test-token" />) as ReactElement);
|
it("shows connecting message when not connected and session is active", () => {
|
||||||
|
render((<XTerminal {...makeDefaultProps({ isConnected: false })} />) as ReactElement);
|
||||||
expect(screen.getByText("Connecting to terminal...")).toBeInTheDocument();
|
expect(screen.getByText("Connecting to terminal...")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not show connecting message when connected", async () => {
|
it("does not show connecting message when connected", () => {
|
||||||
mockIsConnected = true;
|
render((<XTerminal {...makeDefaultProps({ isConnected: true })} />) as ReactElement);
|
||||||
|
expect(screen.queryByText("Connecting to terminal...")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
const { useTerminal } = await import("@/hooks/useTerminal");
|
it("does not show connecting message when session has exited", () => {
|
||||||
vi.mocked(useTerminal).mockReturnValue({
|
render(
|
||||||
isConnected: true,
|
(
|
||||||
sessionId: "session-xyz",
|
<XTerminal {...makeDefaultProps({ isConnected: false, sessionStatus: "exited" })} />
|
||||||
createSession: mockCreateSession,
|
) as ReactElement
|
||||||
sendInput: mockSendInput,
|
);
|
||||||
resize: mockResize,
|
|
||||||
closeSession: mockCloseSession,
|
|
||||||
connectionError: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
render((<XTerminal token="test-token" />) as ReactElement);
|
|
||||||
expect(screen.queryByText("Connecting to terminal...")).not.toBeInTheDocument();
|
expect(screen.queryByText("Connecting to terminal...")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// useTerminal integration
|
// Exit overlay
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
describe("useTerminal integration", () => {
|
describe("exit overlay", () => {
|
||||||
it("passes the token to useTerminal", async () => {
|
it("shows restart button when session has exited", () => {
|
||||||
const { useTerminal } = await import("@/hooks/useTerminal");
|
render((<XTerminal {...makeDefaultProps({ sessionStatus: "exited" })} />) as ReactElement);
|
||||||
render((<XTerminal token="my-auth-token" />) as ReactElement);
|
expect(screen.getByRole("button", { name: /restart terminal/i })).toBeInTheDocument();
|
||||||
expect(vi.mocked(useTerminal)).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ token: "my-auth-token" })
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes onOutput, onExit, onError callbacks to useTerminal", async () => {
|
it("does not show restart button when session is active", () => {
|
||||||
const { useTerminal } = await import("@/hooks/useTerminal");
|
render((<XTerminal {...makeDefaultProps({ sessionStatus: "active" })} />) as ReactElement);
|
||||||
render((<XTerminal token="test-token" />) as ReactElement);
|
expect(screen.queryByRole("button", { name: /restart terminal/i })).not.toBeInTheDocument();
|
||||||
const callArgs = vi.mocked(useTerminal).mock.calls[0]?.[0];
|
});
|
||||||
expect(typeof callArgs?.onOutput).toBe("function");
|
|
||||||
expect(typeof callArgs?.onExit).toBe("function");
|
it("shows exit code in restart button when provided", () => {
|
||||||
expect(typeof callArgs?.onError).toBe("function");
|
render(
|
||||||
|
(
|
||||||
|
<XTerminal {...makeDefaultProps({ sessionStatus: "exited", exitCode: 1 })} />
|
||||||
|
) as ReactElement
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: /exit 1/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onRestart when restart button is clicked", () => {
|
||||||
|
render(
|
||||||
|
(
|
||||||
|
<XTerminal {...makeDefaultProps({ sessionStatus: "exited", onRestart: mockOnRestart })} />
|
||||||
|
) as ReactElement
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /restart terminal/i }));
|
||||||
|
expect(mockOnRestart).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Output callback registration
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("registerOutputCallback", () => {
|
||||||
|
it("registers a callback for its sessionId on mount", () => {
|
||||||
|
render((<XTerminal {...makeDefaultProps({ sessionId: "test-session" })} />) as ReactElement);
|
||||||
|
expect(mockRegisterOutputCallback).toHaveBeenCalledWith("test-session", expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls the returned unsubscribe function on unmount", () => {
|
||||||
|
const unsubscribe = vi.fn();
|
||||||
|
mockRegisterOutputCallback.mockReturnValue(unsubscribe);
|
||||||
|
|
||||||
|
const { unmount } = render((<XTerminal {...makeDefaultProps()} />) as ReactElement);
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(unsubscribe).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -220,8 +251,20 @@ describe("XTerminal", () => {
|
|||||||
|
|
||||||
describe("accessibility", () => {
|
describe("accessibility", () => {
|
||||||
it("has an accessible region role", () => {
|
it("has an accessible region role", () => {
|
||||||
render((<XTerminal token="test-token" />) as ReactElement);
|
render((<XTerminal {...makeDefaultProps()} />) as ReactElement);
|
||||||
expect(screen.getByRole("region", { name: "Terminal" })).toBeInTheDocument();
|
expect(screen.getByRole("region", { name: "Terminal" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Visibility
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("isVisible", () => {
|
||||||
|
it("renders with isVisible=true by default", () => {
|
||||||
|
render((<XTerminal {...makeDefaultProps()} />) as ReactElement);
|
||||||
|
// Container is present; isVisible affects re-fit timing
|
||||||
|
expect(screen.getByTestId("xterminal-container")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,29 +3,55 @@
|
|||||||
/**
|
/**
|
||||||
* XTerminal component
|
* XTerminal component
|
||||||
*
|
*
|
||||||
* Renders a real xterm.js terminal connected to the backend /terminal WebSocket namespace.
|
* Renders a real xterm.js terminal. The parent (TerminalPanel via useTerminalSessions)
|
||||||
* Handles resize, copy/paste, theme, and session lifecycle.
|
* owns the WebSocket connection and session lifecycle. This component receives the
|
||||||
|
* sessionId and control functions as props and registers for output data specific
|
||||||
|
* to its session.
|
||||||
|
*
|
||||||
|
* Handles resize, copy/paste, theme, exit overlay, and reconnect.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, useCallback, useState } from "react";
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
import type { ReactElement, CSSProperties } from "react";
|
import type { ReactElement, CSSProperties } from "react";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||||
import type { FitAddon as XFitAddon } from "@xterm/addon-fit";
|
import type { FitAddon as XFitAddon } from "@xterm/addon-fit";
|
||||||
import { useTerminal } from "@/hooks/useTerminal";
|
import type { SessionStatus } from "@/hooks/useTerminalSessions";
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Types
|
// Types
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
export interface XTerminalProps {
|
export interface XTerminalProps {
|
||||||
/** Authentication token for the WebSocket connection */
|
/** Session identifier (provided by useTerminalSessions) */
|
||||||
token: string;
|
sessionId: string;
|
||||||
|
/** Send keyboard input to this session */
|
||||||
|
sendInput: (sessionId: string, data: string) => void;
|
||||||
|
/** Notify the server of a terminal resize */
|
||||||
|
resize: (sessionId: string, cols: number, rows: number) => void;
|
||||||
|
/** Close this PTY session */
|
||||||
|
closeSession: (sessionId: string) => void;
|
||||||
|
/**
|
||||||
|
* Register a callback to receive output for this session.
|
||||||
|
* Returns an unsubscribe function.
|
||||||
|
*/
|
||||||
|
registerOutputCallback: (sessionId: string, cb: (data: string) => void) => () => void;
|
||||||
|
/** Whether the WebSocket is currently connected */
|
||||||
|
isConnected: boolean;
|
||||||
|
/** Current PTY process status */
|
||||||
|
sessionStatus: SessionStatus;
|
||||||
|
/** Exit code, populated when sessionStatus === 'exited' */
|
||||||
|
exitCode?: number;
|
||||||
|
/**
|
||||||
|
* Called when the user clicks the restart button after the session has exited.
|
||||||
|
* The parent is responsible for closing the old session and creating a new one.
|
||||||
|
*/
|
||||||
|
onRestart?: () => void;
|
||||||
/** Optional CSS class name for the outer container */
|
/** Optional CSS class name for the outer container */
|
||||||
className?: string;
|
className?: string;
|
||||||
/** Optional inline styles for the outer container */
|
/** Optional inline styles for the outer container */
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
/** Whether the terminal is visible (used to trigger re-fit) */
|
/** Whether the terminal is visible (used to trigger re-fit on tab switch) */
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,8 +71,6 @@ function getCssVar(varName: string, fallback: string): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build an xterm.js ITheme object from the current design system CSS variables.
|
* Build an xterm.js ITheme object from the current design system CSS variables.
|
||||||
* All 5 themes (dark, light, aurora, midnight, sunlit, ocean) update --ms-* primitives
|
|
||||||
* which flow through to the semantic aliases we read here.
|
|
||||||
*/
|
*/
|
||||||
function buildXtermTheme(): Record<string, string> {
|
function buildXtermTheme(): Record<string, string> {
|
||||||
return {
|
return {
|
||||||
@@ -82,11 +106,20 @@ function buildXtermTheme(): Record<string, string> {
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* XTerminal renders a real PTY terminal powered by xterm.js,
|
* XTerminal renders a real PTY terminal powered by xterm.js.
|
||||||
* connected to the backend /terminal WebSocket namespace.
|
* The parent provides the sessionId and control functions; this component
|
||||||
|
* registers for output data and manages the xterm.js instance lifecycle.
|
||||||
*/
|
*/
|
||||||
export function XTerminal({
|
export function XTerminal({
|
||||||
token,
|
sessionId,
|
||||||
|
sendInput,
|
||||||
|
resize,
|
||||||
|
closeSession: _closeSession,
|
||||||
|
registerOutputCallback,
|
||||||
|
isConnected,
|
||||||
|
sessionStatus,
|
||||||
|
exitCode,
|
||||||
|
onRestart,
|
||||||
className = "",
|
className = "",
|
||||||
style,
|
style,
|
||||||
isVisible = true,
|
isVisible = true,
|
||||||
@@ -97,41 +130,7 @@ export function XTerminal({
|
|||||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||||
const isTerminalMountedRef = useRef(false);
|
const isTerminalMountedRef = useRef(false);
|
||||||
|
|
||||||
const [hasExited, setHasExited] = useState(false);
|
const hasExited = sessionStatus === "exited";
|
||||||
const [exitCode, setExitCode] = useState<number | null>(null);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Terminal session callbacks
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
const handleOutput = useCallback((sessionId: string, data: string): void => {
|
|
||||||
void sessionId; // sessionId is single-session in this component
|
|
||||||
terminalRef.current?.write(data);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleExit = useCallback((event: { sessionId: string; exitCode: number }): void => {
|
|
||||||
void event.sessionId;
|
|
||||||
setHasExited(true);
|
|
||||||
setExitCode(event.exitCode);
|
|
||||||
const term = terminalRef.current;
|
|
||||||
if (term) {
|
|
||||||
term.write(`\r\n\x1b[33m[Process exited with code ${event.exitCode.toString()}]\x1b[0m\r\n`);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleError = useCallback((message: string): void => {
|
|
||||||
const term = terminalRef.current;
|
|
||||||
if (term) {
|
|
||||||
term.write(`\r\n\x1b[31m[Error: ${message}]\x1b[0m\r\n`);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { isConnected, sessionId, createSession, sendInput, resize, closeSession } = useTerminal({
|
|
||||||
token,
|
|
||||||
onOutput: handleOutput,
|
|
||||||
onExit: handleExit,
|
|
||||||
onError: handleError,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Fit helper
|
// Fit helper
|
||||||
@@ -144,11 +143,11 @@ export function XTerminal({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
resize(terminal.cols, terminal.rows);
|
resize(sessionId, terminal.cols, terminal.rows);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore fit errors (e.g., when container has zero dimensions)
|
// Ignore fit errors (e.g., when container has zero dimensions)
|
||||||
}
|
}
|
||||||
}, [resize]);
|
}, [resize, sessionId]);
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Mount xterm.js terminal (client-only)
|
// Mount xterm.js terminal (client-only)
|
||||||
@@ -217,8 +216,20 @@ export function XTerminal({
|
|||||||
return (): void => {
|
return (): void => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
|
// Intentionally empty dep array — mount once only
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Register output callback for this session
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unregister = registerOutputCallback(sessionId, (data: string) => {
|
||||||
|
terminalRef.current?.write(data);
|
||||||
|
});
|
||||||
|
return unregister;
|
||||||
|
}, [sessionId, registerOutputCallback]);
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Re-fit when visibility changes
|
// Re-fit when visibility changes
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -243,39 +254,13 @@ export function XTerminal({
|
|||||||
if (!terminal) return;
|
if (!terminal) return;
|
||||||
|
|
||||||
const disposable = terminal.onData((data: string): void => {
|
const disposable = terminal.onData((data: string): void => {
|
||||||
sendInput(data);
|
sendInput(sessionId, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
return (): void => {
|
return (): void => {
|
||||||
disposable.dispose();
|
disposable.dispose();
|
||||||
};
|
};
|
||||||
}, [sendInput]);
|
}, [sendInput, sessionId]);
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Create PTY session when connected
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isConnected || sessionId !== null) return;
|
|
||||||
|
|
||||||
const terminal = terminalRef.current;
|
|
||||||
const fitAddon = fitAddonRef.current;
|
|
||||||
|
|
||||||
let cols = 80;
|
|
||||||
let rows = 24;
|
|
||||||
|
|
||||||
if (terminal && fitAddon) {
|
|
||||||
try {
|
|
||||||
fitAddon.fit();
|
|
||||||
cols = terminal.cols;
|
|
||||||
rows = terminal.rows;
|
|
||||||
} catch {
|
|
||||||
// Use defaults
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createSession({ cols, rows });
|
|
||||||
}, [isConnected, sessionId, createSession]);
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Update xterm theme when data-theme attribute changes
|
// Update xterm theme when data-theme attribute changes
|
||||||
@@ -309,16 +294,26 @@ export function XTerminal({
|
|||||||
resizeObserverRef.current?.disconnect();
|
resizeObserverRef.current?.disconnect();
|
||||||
resizeObserverRef.current = null;
|
resizeObserverRef.current = null;
|
||||||
|
|
||||||
// Close PTY session
|
|
||||||
closeSession();
|
|
||||||
|
|
||||||
// Dispose xterm terminal
|
// Dispose xterm terminal
|
||||||
terminalRef.current?.dispose();
|
terminalRef.current?.dispose();
|
||||||
terminalRef.current = null;
|
terminalRef.current = null;
|
||||||
fitAddonRef.current = null;
|
fitAddonRef.current = null;
|
||||||
isTerminalMountedRef.current = false;
|
isTerminalMountedRef.current = false;
|
||||||
};
|
};
|
||||||
}, [closeSession]);
|
}, []);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Restart handler
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const handleRestart = useCallback((): void => {
|
||||||
|
const terminal = terminalRef.current;
|
||||||
|
if (terminal) {
|
||||||
|
terminal.clear();
|
||||||
|
}
|
||||||
|
// Notify parent to close old session and create a new one
|
||||||
|
onRestart?.();
|
||||||
|
}, [onRestart]);
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Render
|
// Render
|
||||||
@@ -339,8 +334,9 @@ export function XTerminal({
|
|||||||
role="region"
|
role="region"
|
||||||
aria-label="Terminal"
|
aria-label="Terminal"
|
||||||
data-testid="xterminal-container"
|
data-testid="xterminal-container"
|
||||||
|
data-session-id={sessionId}
|
||||||
>
|
>
|
||||||
{/* Status bar */}
|
{/* Status bar — show when not connected and not exited */}
|
||||||
{!isConnected && !hasExited && (
|
{!isConnected && !hasExited && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -385,28 +381,9 @@ export function XTerminal({
|
|||||||
border: "1px solid var(--border)",
|
border: "1px solid var(--border)",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
onClick={(): void => {
|
onClick={handleRestart}
|
||||||
setHasExited(false);
|
|
||||||
setExitCode(null);
|
|
||||||
if (isConnected) {
|
|
||||||
const terminal = terminalRef.current;
|
|
||||||
const fitAddon = fitAddonRef.current;
|
|
||||||
let cols = 80;
|
|
||||||
let rows = 24;
|
|
||||||
if (terminal && fitAddon) {
|
|
||||||
try {
|
|
||||||
cols = terminal.cols;
|
|
||||||
rows = terminal.rows;
|
|
||||||
} catch {
|
|
||||||
// Use defaults
|
|
||||||
}
|
|
||||||
}
|
|
||||||
terminal?.clear();
|
|
||||||
createSession({ cols, rows });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Restart terminal {exitCode !== null ? `(exit ${exitCode.toString()})` : ""}
|
Restart terminal{exitCode !== undefined ? ` (exit ${exitCode.toString()})` : ""}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type { TerminalTab, TerminalPanelProps } from "./TerminalPanel";
|
export type { TerminalPanelProps } from "./TerminalPanel";
|
||||||
export { TerminalPanel } from "./TerminalPanel";
|
export { TerminalPanel } from "./TerminalPanel";
|
||||||
export type { XTerminalProps } from "./XTerminal";
|
export type { XTerminalProps } from "./XTerminal";
|
||||||
export { XTerminal } from "./XTerminal";
|
export { XTerminal } from "./XTerminal";
|
||||||
|
|||||||
690
apps/web/src/hooks/useTerminalSessions.test.ts
Normal file
690
apps/web/src/hooks/useTerminalSessions.test.ts
Normal file
@@ -0,0 +1,690 @@
|
|||||||
|
/**
|
||||||
|
* @file useTerminalSessions.test.ts
|
||||||
|
* @description Unit tests for the useTerminalSessions hook
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||||
|
import { useTerminalSessions } from "./useTerminalSessions";
|
||||||
|
import type { Socket } from "socket.io-client";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Mock socket.io-client
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
vi.mock("socket.io-client");
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Mock lib/config
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
vi.mock("@/lib/config", () => ({
|
||||||
|
API_BASE_URL: "http://localhost:3001",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Helpers
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
interface MockSocket {
|
||||||
|
on: ReturnType<typeof vi.fn>;
|
||||||
|
off: ReturnType<typeof vi.fn>;
|
||||||
|
emit: ReturnType<typeof vi.fn>;
|
||||||
|
disconnect: ReturnType<typeof vi.fn>;
|
||||||
|
connected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useTerminalSessions", () => {
|
||||||
|
let mockSocket: MockSocket;
|
||||||
|
let socketEventHandlers: Record<string, (data: unknown) => void>;
|
||||||
|
let mockIo: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
socketEventHandlers = {};
|
||||||
|
|
||||||
|
mockSocket = {
|
||||||
|
on: vi.fn((event: string, handler: (data: unknown) => void) => {
|
||||||
|
socketEventHandlers[event] = handler;
|
||||||
|
return mockSocket;
|
||||||
|
}),
|
||||||
|
off: vi.fn(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
connected: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const socketIo = await import("socket.io-client");
|
||||||
|
mockIo = vi.mocked(socketIo.io);
|
||||||
|
mockIo.mockReturnValue(mockSocket as unknown as Socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Connection lifecycle
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("connection lifecycle", () => {
|
||||||
|
it("should connect to the /terminal namespace with auth token", () => {
|
||||||
|
renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
|
||||||
|
expect(mockIo).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("/terminal"),
|
||||||
|
expect.objectContaining({
|
||||||
|
auth: { token: "test-token" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should start disconnected", () => {
|
||||||
|
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
|
||||||
|
expect(result.current.isConnected).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update isConnected when connect event fires", async () => {
|
||||||
|
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers.connect?.(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isConnected).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set connectionError when connect_error fires", async () => {
|
||||||
|
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers.connect_error?.(new Error("Connection refused"));
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.connectionError).toBe("Connection refused");
|
||||||
|
expect(result.current.isConnected).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not connect when token is empty", () => {
|
||||||
|
renderHook(() => useTerminalSessions({ token: "" }));
|
||||||
|
|
||||||
|
expect(mockIo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should disconnect socket on unmount", () => {
|
||||||
|
const { unmount } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(mockSocket.disconnect).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Session creation
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("createSession", () => {
|
||||||
|
it("should emit terminal:create when connected", () => {
|
||||||
|
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers.connect?.(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.createSession({ name: "bash", cols: 120, rows: 40 });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:create", {
|
||||||
|
name: "bash",
|
||||||
|
cols: 120,
|
||||||
|
rows: 40,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not emit terminal:create when disconnected", () => {
|
||||||
|
mockSocket.connected = false;
|
||||||
|
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.createSession();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSocket.emit).not.toHaveBeenCalledWith("terminal:create", expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add session to sessions map when terminal:created fires", async () => {
|
||||||
|
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers["terminal:created"]?.({
|
||||||
|
sessionId: "session-1",
|
||||||
|
name: "Terminal 1",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.sessions.has("session-1")).toBe(true);
|
||||||
|
expect(result.current.sessions.get("session-1")?.name).toBe("Terminal 1");
|
||||||
|
expect(result.current.sessions.get("session-1")?.status).toBe("active");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set first created session as active", async () => {
|
||||||
|
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers["terminal:created"]?.({
|
||||||
|
sessionId: "session-1",
|
||||||
|
name: "Terminal 1",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.activeSessionId).toBe("session-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not change active session when a second session is created", async () => {
|
||||||
|
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers["terminal:created"]?.({
|
||||||
|
sessionId: "session-1",
|
||||||
|
name: "Terminal 1",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.activeSessionId).toBe("session-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers["terminal:created"]?.({
|
||||||
|
sessionId: "session-2",
|
||||||
|
name: "Terminal 2",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.sessions.size).toBe(2);
|
||||||
|
// Active session should remain session-1
|
||||||
|
expect(result.current.activeSessionId).toBe("session-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should manage multiple sessions in the sessions map", async () => {
|
||||||
|
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers["terminal:created"]?.({
|
||||||
|
sessionId: "session-1",
|
||||||
|
name: "Terminal 1",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
socketEventHandlers["terminal:created"]?.({
|
||||||
|
sessionId: "session-2",
|
||||||
|
name: "Terminal 2",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.sessions.size).toBe(2);
|
||||||
|
expect(result.current.sessions.has("session-1")).toBe(true);
|
||||||
|
expect(result.current.sessions.has("session-2")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Session close
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("closeSession", () => {
|
||||||
|
it("should emit terminal:close and remove session from map", async () => {
|
||||||
|
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers["terminal:created"]?.({
|
||||||
|
sessionId: "session-1",
|
||||||
|
name: "Terminal 1",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.sessions.has("session-1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.closeSession("session-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:close", {
|
||||||
|
sessionId: "session-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.sessions.has("session-1")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should switch active session to another when active is closed", async () => {
|
||||||
|
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers["terminal:created"]?.({
|
||||||
|
sessionId: "session-1",
|
||||||
|
name: "Terminal 1",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
socketEventHandlers["terminal:created"]?.({
|
||||||
|
sessionId: "session-2",
|
||||||
|
name: "Terminal 2",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.activeSessionId).toBe("session-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.closeSession("session-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Should switch to session-2
|
||||||
|
expect(result.current.activeSessionId).toBe("session-2");
|
||||||
|
expect(result.current.sessions.has("session-1")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set activeSessionId to null when last session is closed", async () => {
|
||||||
|
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers["terminal:created"]?.({
|
||||||
|
sessionId: "session-1",
|
||||||
|
name: "Terminal 1",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.activeSessionId).toBe("session-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.closeSession("session-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.sessions.size).toBe(0);
|
||||||
|
expect(result.current.activeSessionId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Rename session
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("renameSession", () => {
|
||||||
|
it("should update the session name in the sessions map", async () => {
|
||||||
|
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers["terminal:created"]?.({
|
||||||
|
sessionId: "session-1",
|
||||||
|
name: "Terminal 1",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.sessions.get("session-1")?.name).toBe("Terminal 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.renameSession("session-1", "My Custom Shell");
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.sessions.get("session-1")?.name).toBe("My Custom Shell");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not affect other session names", async () => {
|
||||||
|
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers["terminal:created"]?.({
|
||||||
|
sessionId: "session-1",
|
||||||
|
name: "Terminal 1",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
socketEventHandlers["terminal:created"]?.({
|
||||||
|
sessionId: "session-2",
|
||||||
|
name: "Terminal 2",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.renameSession("session-1", "Custom");
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.sessions.get("session-1")?.name).toBe("Custom");
|
||||||
|
expect(result.current.sessions.get("session-2")?.name).toBe("Terminal 2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// setActiveSession
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("setActiveSession", () => {
|
||||||
|
it("should update activeSessionId", async () => {
|
||||||
|
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers["terminal:created"]?.({
|
||||||
|
sessionId: "session-1",
|
||||||
|
name: "Terminal 1",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
socketEventHandlers["terminal:created"]?.({
|
||||||
|
sessionId: "session-2",
|
||||||
|
name: "Terminal 2",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.activeSessionId).toBe("session-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setActiveSession("session-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.activeSessionId).toBe("session-2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// sendInput
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("sendInput", () => {
|
||||||
|
it("should emit terminal:input with sessionId and data", () => {
|
||||||
|
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers.connect?.(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.sendInput("session-1", "ls -la\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:input", {
|
||||||
|
sessionId: "session-1",
|
||||||
|
data: "ls -la\n",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not emit when disconnected", () => {
|
||||||
|
mockSocket.connected = false;
|
||||||
|
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.sendInput("session-1", "ls\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSocket.emit).not.toHaveBeenCalledWith("terminal:input", expect.anything());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// resize
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("resize", () => {
|
||||||
|
it("should emit terminal:resize with sessionId, cols, and rows", () => {
|
||||||
|
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers.connect?.(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.resize("session-1", 120, 40);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:resize", {
|
||||||
|
sessionId: "session-1",
|
||||||
|
cols: 120,
|
||||||
|
rows: 40,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Output callback routing
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("registerOutputCallback", () => {
|
||||||
|
it("should call the registered callback when terminal:output fires for that session", () => {
|
||||||
|
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
const cb = vi.fn();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.registerOutputCallback("session-1", cb);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers["terminal:output"]?.({
|
||||||
|
sessionId: "session-1",
|
||||||
|
data: "hello world\r\n",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cb).toHaveBeenCalledWith("hello world\r\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not call callback for a different session", () => {
|
||||||
|
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
const cbSession1 = vi.fn();
|
||||||
|
const cbSession2 = vi.fn();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.registerOutputCallback("session-1", cbSession1);
|
||||||
|
result.current.registerOutputCallback("session-2", cbSession2);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers["terminal:output"]?.({
|
||||||
|
sessionId: "session-1",
|
||||||
|
data: "output for session 1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cbSession1).toHaveBeenCalledWith("output for session 1");
|
||||||
|
expect(cbSession2).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should stop calling callback after unsubscribing", () => {
|
||||||
|
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
const cb = vi.fn();
|
||||||
|
let unsubscribe: (() => void) | undefined;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
unsubscribe = result.current.registerOutputCallback("session-1", cb);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
unsubscribe?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers["terminal:output"]?.({
|
||||||
|
sessionId: "session-1",
|
||||||
|
data: "should not arrive",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support multiple callbacks for the same session", () => {
|
||||||
|
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
const cb1 = vi.fn();
|
||||||
|
const cb2 = vi.fn();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.registerOutputCallback("session-1", cb1);
|
||||||
|
result.current.registerOutputCallback("session-1", cb2);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers["terminal:output"]?.({
|
||||||
|
sessionId: "session-1",
|
||||||
|
data: "broadcast",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cb1).toHaveBeenCalledWith("broadcast");
|
||||||
|
expect(cb2).toHaveBeenCalledWith("broadcast");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Exit event
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("terminal:exit handling", () => {
|
||||||
|
it("should mark session as exited when terminal:exit fires", async () => {
|
||||||
|
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers["terminal:created"]?.({
|
||||||
|
sessionId: "session-1",
|
||||||
|
name: "Terminal 1",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.sessions.get("session-1")?.status).toBe("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers["terminal:exit"]?.({
|
||||||
|
sessionId: "session-1",
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.sessions.get("session-1")?.status).toBe("exited");
|
||||||
|
expect(result.current.sessions.get("session-1")?.exitCode).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not remove the session from the map on exit", async () => {
|
||||||
|
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers["terminal:created"]?.({
|
||||||
|
sessionId: "session-1",
|
||||||
|
name: "Terminal 1",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers["terminal:exit"]?.({
|
||||||
|
sessionId: "session-1",
|
||||||
|
exitCode: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Session remains in map — user can restart or close it manually
|
||||||
|
expect(result.current.sessions.has("session-1")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Disconnect handling
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("disconnect handling", () => {
|
||||||
|
it("should mark all active sessions as exited on disconnect", async () => {
|
||||||
|
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers["terminal:created"]?.({
|
||||||
|
sessionId: "session-1",
|
||||||
|
name: "Terminal 1",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
socketEventHandlers["terminal:created"]?.({
|
||||||
|
sessionId: "session-2",
|
||||||
|
name: "Terminal 2",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.sessions.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers.disconnect?.(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isConnected).toBe(false);
|
||||||
|
expect(result.current.sessions.get("session-1")?.status).toBe("exited");
|
||||||
|
expect(result.current.sessions.get("session-2")?.status).toBe("exited");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
381
apps/web/src/hooks/useTerminalSessions.ts
Normal file
381
apps/web/src/hooks/useTerminalSessions.ts
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
/**
|
||||||
|
* useTerminalSessions hook
|
||||||
|
*
|
||||||
|
* Manages multiple PTY terminal sessions over a single WebSocket connection
|
||||||
|
* to the /terminal namespace. Supports creating, closing, renaming, and switching
|
||||||
|
* between sessions, with per-session output callback multiplexing.
|
||||||
|
*
|
||||||
|
* Protocol (from terminal.gateway.ts):
|
||||||
|
* 1. Connect with auth token in handshake
|
||||||
|
* 2. Emit terminal:create { name?, cols?, rows? } → receive terminal:created { sessionId, name, cols, rows }
|
||||||
|
* 3. Emit terminal:input { sessionId, data } to send keystrokes
|
||||||
|
* 4. Receive terminal:output { sessionId, data } for stdout/stderr
|
||||||
|
* 5. Emit terminal:resize { sessionId, cols, rows } on window resize
|
||||||
|
* 6. Emit terminal:close { sessionId } to terminate the PTY
|
||||||
|
* 7. Receive terminal:exit { sessionId, exitCode, signal } on PTY exit
|
||||||
|
* 8. Receive terminal:error { message } on errors
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
|
import type { Socket } from "socket.io-client";
|
||||||
|
import { io } from "socket.io-client";
|
||||||
|
import { API_BASE_URL } from "@/lib/config";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Types
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export type SessionStatus = "active" | "exited";
|
||||||
|
|
||||||
|
export interface SessionInfo {
|
||||||
|
/** Session identifier returned by the server */
|
||||||
|
sessionId: string;
|
||||||
|
/** Human-readable tab label */
|
||||||
|
name: string;
|
||||||
|
/** Whether the PTY process is still running */
|
||||||
|
status: SessionStatus;
|
||||||
|
/** Exit code, populated when status === 'exited' */
|
||||||
|
exitCode?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSessionOptions {
|
||||||
|
/** Optional label for the new session */
|
||||||
|
name?: string;
|
||||||
|
/** Terminal columns */
|
||||||
|
cols?: number;
|
||||||
|
/** Terminal rows */
|
||||||
|
rows?: number;
|
||||||
|
/** Working directory */
|
||||||
|
cwd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseTerminalSessionsOptions {
|
||||||
|
/** Authentication token for WebSocket handshake */
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseTerminalSessionsReturn {
|
||||||
|
/** Map of sessionId → SessionInfo */
|
||||||
|
sessions: Map<string, SessionInfo>;
|
||||||
|
/** Currently active (visible) session id, or null if none */
|
||||||
|
activeSessionId: string | null;
|
||||||
|
/** Whether the WebSocket is connected */
|
||||||
|
isConnected: boolean;
|
||||||
|
/** Connection error message, if any */
|
||||||
|
connectionError: string | null;
|
||||||
|
/** Create a new PTY session */
|
||||||
|
createSession: (options?: CreateSessionOptions) => void;
|
||||||
|
/** Close an existing PTY session */
|
||||||
|
closeSession: (sessionId: string) => void;
|
||||||
|
/** Rename a session (local label only, not persisted to server) */
|
||||||
|
renameSession: (sessionId: string, name: string) => void;
|
||||||
|
/** Switch the visible session */
|
||||||
|
setActiveSession: (sessionId: string) => void;
|
||||||
|
/** Send keyboard input to a session */
|
||||||
|
sendInput: (sessionId: string, data: string) => void;
|
||||||
|
/** Notify the server of a terminal resize */
|
||||||
|
resize: (sessionId: string, cols: number, rows: number) => void;
|
||||||
|
/**
|
||||||
|
* Register a callback that receives output data for a specific session.
|
||||||
|
* Returns an unsubscribe function — call it during cleanup.
|
||||||
|
*/
|
||||||
|
registerOutputCallback: (sessionId: string, cb: (data: string) => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Payload shapes matching terminal.dto.ts
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
interface TerminalCreatedPayload {
|
||||||
|
sessionId: string;
|
||||||
|
name: string;
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TerminalOutputPayload {
|
||||||
|
sessionId: string;
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TerminalExitPayload {
|
||||||
|
sessionId: string;
|
||||||
|
exitCode: number;
|
||||||
|
signal?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TerminalErrorPayload {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Security validation
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
function validateWebSocketSecurity(url: string): void {
|
||||||
|
const isProduction = process.env.NODE_ENV === "production";
|
||||||
|
const isSecure = url.startsWith("https://") || url.startsWith("wss://");
|
||||||
|
|
||||||
|
if (isProduction && !isSecure) {
|
||||||
|
console.warn(
|
||||||
|
"[Security Warning] Terminal WebSocket using insecure protocol (ws://). " +
|
||||||
|
"Authentication tokens may be exposed. Use wss:// in production."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Hook
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing multiple PTY terminal sessions over a single WebSocket connection.
|
||||||
|
*
|
||||||
|
* @param options - Configuration including auth token
|
||||||
|
* @returns Multi-session terminal state and control functions
|
||||||
|
*/
|
||||||
|
export function useTerminalSessions(
|
||||||
|
options: UseTerminalSessionsOptions
|
||||||
|
): UseTerminalSessionsReturn {
|
||||||
|
const { token } = options;
|
||||||
|
|
||||||
|
const socketRef = useRef<Socket | null>(null);
|
||||||
|
// Per-session output callback registry; keyed by sessionId
|
||||||
|
const outputCallbacksRef = useRef<Map<string, Set<(data: string) => void>>>(new Map());
|
||||||
|
|
||||||
|
const [sessions, setSessions] = useState<Map<string, SessionInfo>>(new Map());
|
||||||
|
const [activeSessionId, setActiveSessionIdState] = useState<string | null>(null);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Auto-select first available session when active becomes null
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeSessionId === null && sessions.size > 0) {
|
||||||
|
const firstId = sessions.keys().next().value;
|
||||||
|
if (firstId !== undefined) {
|
||||||
|
setActiveSessionIdState(firstId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeSessionId, sessions]);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// WebSocket connection
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsUrl = API_BASE_URL;
|
||||||
|
validateWebSocketSecurity(wsUrl);
|
||||||
|
|
||||||
|
setConnectionError(null);
|
||||||
|
|
||||||
|
const socket = io(`${wsUrl}/terminal`, {
|
||||||
|
auth: { token },
|
||||||
|
transports: ["websocket", "polling"],
|
||||||
|
});
|
||||||
|
|
||||||
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
const handleConnect = (): void => {
|
||||||
|
setIsConnected(true);
|
||||||
|
setConnectionError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisconnect = (): void => {
|
||||||
|
setIsConnected(false);
|
||||||
|
// Sessions remain in the Map but are no longer interactive
|
||||||
|
setSessions((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const [id, info] of next) {
|
||||||
|
if (info.status === "active") {
|
||||||
|
next.set(id, { ...info, status: "exited" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConnectError = (error: Error): void => {
|
||||||
|
setConnectionError(error.message || "Terminal connection failed");
|
||||||
|
setIsConnected(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTerminalCreated = (payload: TerminalCreatedPayload): void => {
|
||||||
|
setSessions((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(payload.sessionId, {
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
name: payload.name,
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
// Set as active session if none is currently active
|
||||||
|
setActiveSessionIdState((prev) => prev ?? payload.sessionId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTerminalOutput = (payload: TerminalOutputPayload): void => {
|
||||||
|
const callbacks = outputCallbacksRef.current.get(payload.sessionId);
|
||||||
|
if (callbacks) {
|
||||||
|
for (const cb of callbacks) {
|
||||||
|
cb(payload.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTerminalExit = (payload: TerminalExitPayload): void => {
|
||||||
|
setSessions((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const session = next.get(payload.sessionId);
|
||||||
|
if (session) {
|
||||||
|
next.set(payload.sessionId, {
|
||||||
|
...session,
|
||||||
|
status: "exited",
|
||||||
|
exitCode: payload.exitCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTerminalError = (payload: TerminalErrorPayload): void => {
|
||||||
|
console.error("[Terminal] Error:", payload.message);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on("connect", handleConnect);
|
||||||
|
socket.on("disconnect", handleDisconnect);
|
||||||
|
socket.on("connect_error", handleConnectError);
|
||||||
|
socket.on("terminal:created", handleTerminalCreated);
|
||||||
|
socket.on("terminal:output", handleTerminalOutput);
|
||||||
|
socket.on("terminal:exit", handleTerminalExit);
|
||||||
|
socket.on("terminal:error", handleTerminalError);
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
socket.off("connect", handleConnect);
|
||||||
|
socket.off("disconnect", handleDisconnect);
|
||||||
|
socket.off("connect_error", handleConnectError);
|
||||||
|
socket.off("terminal:created", handleTerminalCreated);
|
||||||
|
socket.off("terminal:output", handleTerminalOutput);
|
||||||
|
socket.off("terminal:exit", handleTerminalExit);
|
||||||
|
socket.off("terminal:error", handleTerminalError);
|
||||||
|
|
||||||
|
// Close all active sessions before disconnecting
|
||||||
|
const currentSessions = sessions;
|
||||||
|
for (const [id, info] of currentSessions) {
|
||||||
|
if (info.status === "active") {
|
||||||
|
socket.emit("terminal:close", { sessionId: id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.disconnect();
|
||||||
|
socketRef.current = null;
|
||||||
|
};
|
||||||
|
// Intentional: token is the only dep that should trigger reconnection
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Control functions
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const createSession = useCallback((createOptions: CreateSessionOptions = {}): void => {
|
||||||
|
const socket = socketRef.current;
|
||||||
|
if (!socket?.connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {};
|
||||||
|
if (createOptions.name !== undefined) payload.name = createOptions.name;
|
||||||
|
if (createOptions.cols !== undefined) payload.cols = createOptions.cols;
|
||||||
|
if (createOptions.rows !== undefined) payload.rows = createOptions.rows;
|
||||||
|
if (createOptions.cwd !== undefined) payload.cwd = createOptions.cwd;
|
||||||
|
|
||||||
|
socket.emit("terminal:create", payload);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeSession = useCallback((sessionId: string): void => {
|
||||||
|
const socket = socketRef.current;
|
||||||
|
if (socket?.connected) {
|
||||||
|
socket.emit("terminal:close", { sessionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
setSessions((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(sessionId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// If closing the active session, activeSessionId becomes null
|
||||||
|
// and the auto-select useEffect will pick the first remaining session
|
||||||
|
setActiveSessionIdState((prev) => (prev === sessionId ? null : prev));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renameSession = useCallback((sessionId: string, name: string): void => {
|
||||||
|
setSessions((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const session = next.get(sessionId);
|
||||||
|
if (session) {
|
||||||
|
next.set(sessionId, { ...session, name });
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setActiveSession = useCallback((sessionId: string): void => {
|
||||||
|
setActiveSessionIdState(sessionId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendInput = useCallback((sessionId: string, data: string): void => {
|
||||||
|
const socket = socketRef.current;
|
||||||
|
if (!socket?.connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
socket.emit("terminal:input", { sessionId, data });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resize = useCallback((sessionId: string, cols: number, rows: number): void => {
|
||||||
|
const socket = socketRef.current;
|
||||||
|
if (!socket?.connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
socket.emit("terminal:resize", { sessionId, cols, rows });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const registerOutputCallback = useCallback(
|
||||||
|
(sessionId: string, cb: (data: string) => void): (() => void) => {
|
||||||
|
const registry = outputCallbacksRef.current;
|
||||||
|
if (!registry.has(sessionId)) {
|
||||||
|
registry.set(sessionId, new Set());
|
||||||
|
}
|
||||||
|
// Safe: we just ensured the key exists
|
||||||
|
const callbackSet = registry.get(sessionId);
|
||||||
|
if (callbackSet) {
|
||||||
|
callbackSet.add(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
registry.get(sessionId)?.delete(cb);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions,
|
||||||
|
activeSessionId,
|
||||||
|
isConnected,
|
||||||
|
connectionError,
|
||||||
|
createSession,
|
||||||
|
closeSession,
|
||||||
|
renameSession,
|
||||||
|
setActiveSession,
|
||||||
|
sendInput,
|
||||||
|
resize,
|
||||||
|
registerOutputCallback,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user