feat(FN-682): add multi-tab terminal support with session persistence
- Add useTerminalSessions hook for managing multiple terminal sessions - Update TerminalModal to support multiple tabs with persistent sessions - Fix memory leak in useTerminalSessions (proper cleanup on unmount) - Fix type leak (avoid exposing internal session state) - Add comprehensive test coverage for useTerminalSessions hook - Update TerminalModal tests for multi-tab behavior - Remove obsolete test files and highlightDiff utility
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { X, Trash2, Terminal as TerminalIcon, RefreshCw } from "lucide-react";
|
||||
import { useTerminal } from "../hooks/useTerminal";
|
||||
import { createTerminalSession, killPtyTerminalSession } from "../api";
|
||||
import { useTerminalSessions } from "../hooks/useTerminalSessions";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
import type { Terminal as XTerm, ITerminalAddon } from "@xterm/xterm";
|
||||
import type { FitAddon } from "@xterm/addon-fit";
|
||||
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
interface TerminalModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -19,6 +19,7 @@ interface TerminalModalProps {
|
||||
* Provides a fully functional PTY terminal where users can execute commands
|
||||
* in the project's working directory. Features include:
|
||||
* - Real-time bidirectional communication via WebSocket
|
||||
* - Multiple terminal tabs with session persistence
|
||||
* - xterm.js for proper terminal emulation
|
||||
* - Copy/paste support
|
||||
* - Terminal zoom (Ctrl++/Ctrl+-/Ctrl+0)
|
||||
@@ -28,10 +29,7 @@ interface TerminalModalProps {
|
||||
* The terminal spawns a real shell (bash/zsh/powershell based on platform).
|
||||
*/
|
||||
export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModalProps) {
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [shellName, setShellName] = useState<string>("");
|
||||
const [exitCode, setExitCode] = useState<number | null>(null);
|
||||
const [xtermReady, setXtermReady] = useState(false);
|
||||
|
||||
@@ -39,14 +37,45 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
||||
const xtermRef = useRef<XTerm | null>(null);
|
||||
const fitAddonRef = useRef<ITerminalAddon | null>(null);
|
||||
const hasInitialCommandRun = useRef(false);
|
||||
const xtermInitializedRef = useRef(false);
|
||||
|
||||
const { connectionStatus, sendInput, resize, onData, onConnect, onExit, onScrollback, reconnect } = useTerminal(sessionId);
|
||||
// Use the session management hook
|
||||
const {
|
||||
tabs,
|
||||
activeTab,
|
||||
isReady,
|
||||
createTab,
|
||||
closeTab,
|
||||
setActiveTab,
|
||||
updateTabTitle,
|
||||
restartActiveTab
|
||||
} = useTerminalSessions();
|
||||
|
||||
// Initialize xterm.js
|
||||
// Depends on `isCreating` so the effect re-runs once session creation
|
||||
// completes and the terminal container div is visible in the DOM.
|
||||
// Get the WebSocket connection for the active session
|
||||
const { connectionStatus, sendInput, resize, onData, onConnect, onExit, onScrollback, reconnect } =
|
||||
useTerminal(activeTab?.sessionId ?? null);
|
||||
|
||||
// Initialize xterm.js when session is ready
|
||||
// Depends on `isReady`, `activeTab`, and xtermReady to properly reinitialize on tab switch
|
||||
useEffect(() => {
|
||||
if (!isOpen || isCreating || !terminalRef.current || xtermRef.current) return;
|
||||
if (!isOpen || !isReady || !activeTab || !terminalRef.current) return;
|
||||
|
||||
// If session changed, we need to reinitialize xterm
|
||||
const currentSessionId = activeTab.sessionId;
|
||||
|
||||
// Clean up existing xterm if switching sessions
|
||||
if (xtermRef.current && xtermInitializedRef.current !== currentSessionId) {
|
||||
xtermRef.current.dispose();
|
||||
xtermRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
xtermInitializedRef.current = false;
|
||||
setXtermReady(false);
|
||||
}
|
||||
|
||||
// If already initialized for this session, skip
|
||||
if (xtermInitializedRef.current === currentSessionId && xtermRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
|
||||
@@ -58,7 +87,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
||||
import("@xterm/addon-web-links"),
|
||||
]);
|
||||
|
||||
if (!mounted || !terminalRef.current) return;
|
||||
if (!mounted || !terminalRef.current || xtermRef.current) return;
|
||||
|
||||
// Create terminal instance
|
||||
const terminal = new Terminal({
|
||||
@@ -113,6 +142,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
||||
|
||||
xtermRef.current = terminal;
|
||||
fitAddonRef.current = fitAddon;
|
||||
xtermInitializedRef.current = currentSessionId;
|
||||
|
||||
// Signal that xterm is ready so the subscription effect re-runs
|
||||
setXtermReady(true);
|
||||
@@ -149,14 +179,27 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
||||
mounted = false;
|
||||
cleanupPromise.then((cleanup) => cleanup?.());
|
||||
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.dispose();
|
||||
xtermRef.current = null;
|
||||
}
|
||||
fitAddonRef.current = null;
|
||||
setXtermReady(false);
|
||||
// Don't dispose xterm here - it should persist across tab switches
|
||||
// Only dispose when the modal is fully closed
|
||||
};
|
||||
}, [isOpen, isCreating, sendInput, resize]);
|
||||
}, [isOpen, isReady, activeTab?.sessionId, sendInput, resize]);
|
||||
|
||||
// Cleanup xterm when modal closes
|
||||
useEffect(() => {
|
||||
if (isOpen) return;
|
||||
|
||||
// Modal is closed - cleanup xterm
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.dispose();
|
||||
xtermRef.current = null;
|
||||
}
|
||||
fitAddonRef.current = null;
|
||||
xtermInitializedRef.current = false;
|
||||
setXtermReady(false);
|
||||
hasInitialCommandRun.current = false;
|
||||
setError(null);
|
||||
setExitCode(null);
|
||||
}, [isOpen]);
|
||||
|
||||
// Subscribe to terminal data.
|
||||
// Depends on `xtermReady` so subscriptions are established after the
|
||||
@@ -173,7 +216,10 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
||||
});
|
||||
|
||||
const unsubConnect = onConnect((info) => {
|
||||
setShellName(info.shell.split("/").pop() || info.shell);
|
||||
// Update tab title with shell name
|
||||
if (activeTab) {
|
||||
updateTabTitle(activeTab.id, info.shell.split("/").pop() || info.shell);
|
||||
}
|
||||
});
|
||||
|
||||
const unsubExit = onExit((code) => {
|
||||
@@ -187,53 +233,18 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
||||
unsubConnect();
|
||||
unsubExit();
|
||||
};
|
||||
}, [xtermReady, onData, onScrollback, onConnect, onExit]);
|
||||
|
||||
// Create session when modal opens
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
// Cleanup session on close
|
||||
if (sessionId) {
|
||||
killPtyTerminalSession(sessionId).catch(() => {
|
||||
// Ignore errors during cleanup
|
||||
});
|
||||
setSessionId(null);
|
||||
}
|
||||
hasInitialCommandRun.current = false;
|
||||
setError(null);
|
||||
setExitCode(null);
|
||||
setShellName("");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new session
|
||||
const createSession = async () => {
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const session = await createTerminalSession();
|
||||
setSessionId(session.sessionId);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to create terminal session");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
createSession();
|
||||
}, [isOpen]);
|
||||
}, [xtermReady, activeTab?.id, onData, onScrollback, onConnect, onExit, updateTabTitle]);
|
||||
|
||||
// Run initial command when connected
|
||||
useEffect(() => {
|
||||
if (connectionStatus === "connected" && initialCommand && !hasInitialCommandRun.current && sessionId) {
|
||||
if (connectionStatus === "connected" && initialCommand && !hasInitialCommandRun.current && activeTab) {
|
||||
hasInitialCommandRun.current = true;
|
||||
// Small delay to let shell initialize
|
||||
setTimeout(() => {
|
||||
sendInput(initialCommand + "\n");
|
||||
}, 500);
|
||||
}
|
||||
}, [connectionStatus, initialCommand, sendInput, sessionId]);
|
||||
}, [connectionStatus, initialCommand, sendInput, activeTab]);
|
||||
|
||||
// Handle keyboard shortcuts (zoom)
|
||||
useEffect(() => {
|
||||
@@ -314,33 +325,20 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
||||
xtermRef.current?.clear();
|
||||
}, []);
|
||||
|
||||
// Handle restart
|
||||
// Handle restart - create new session in the current tab
|
||||
const handleRestart = useCallback(async () => {
|
||||
// Kill current session
|
||||
if (sessionId) {
|
||||
await killPtyTerminalSession(sessionId).catch(() => {
|
||||
// Ignore errors
|
||||
});
|
||||
}
|
||||
|
||||
// Clear terminal
|
||||
// Clear terminal display
|
||||
xtermRef.current?.clear();
|
||||
setExitCode(null);
|
||||
hasInitialCommandRun.current = false;
|
||||
|
||||
// Create new session
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
|
||||
// Restart the active tab's session
|
||||
try {
|
||||
const session = await createTerminalSession();
|
||||
setSessionId(session.sessionId);
|
||||
await restartActiveTab();
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to create terminal session");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
setError(err.message || "Failed to restart terminal session");
|
||||
}
|
||||
}, [sessionId]);
|
||||
}, [restartActiveTab]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -358,6 +356,9 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
||||
}
|
||||
};
|
||||
|
||||
// Determine loading state
|
||||
const isLoading = !isReady || !activeTab || !xtermReady;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay open"
|
||||
@@ -367,16 +368,50 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
||||
<div className="modal terminal-modal" data-testid="terminal-modal">
|
||||
{/* Header */}
|
||||
<div className="terminal-header">
|
||||
{/* Tab Bar */}
|
||||
<div className="terminal-tabs" data-testid="terminal-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`terminal-tab ${tab.isActive ? "terminal-tab--active" : ""}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
title={tab.title}
|
||||
role="tab"
|
||||
aria-selected={tab.isActive}
|
||||
>
|
||||
<span className="terminal-tab-label">{tab.title}</span>
|
||||
{tabs.length > 1 && (
|
||||
<button
|
||||
className="terminal-tab-close"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
closeTab(tab.id);
|
||||
}}
|
||||
title="Close tab"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className="terminal-tab terminal-tab--new"
|
||||
onClick={createTab}
|
||||
title="New terminal"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status indicator */}
|
||||
<div className="terminal-title" data-testid="terminal-title">
|
||||
<TerminalIcon size={16} />
|
||||
<span>Terminal</span>
|
||||
{shellName && (
|
||||
<span className="terminal-shell-name">({shellName})</span>
|
||||
)}
|
||||
{getStatusIndicator()}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="terminal-actions">
|
||||
{connectionStatus === "disconnected" && sessionId && (
|
||||
{connectionStatus === "disconnected" && activeTab && (
|
||||
<button
|
||||
className="terminal-reconnect-btn"
|
||||
onClick={reconnect}
|
||||
@@ -427,20 +462,18 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
||||
|
||||
{/* Terminal container */}
|
||||
<div className="terminal-container" data-testid="terminal-container">
|
||||
{isCreating && (
|
||||
{isLoading && (
|
||||
<div className="terminal-loading" data-testid="terminal-loading">
|
||||
<div className="terminal-spinner" />
|
||||
<span>Starting terminal...</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Always render the xterm container so the ref is available for
|
||||
initialization as soon as the session is ready. Hiding it with
|
||||
display:none while loading prevents a flash of empty terminal. */}
|
||||
{/* Use key to force remount on session change */}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="terminal-xterm"
|
||||
data-testid="terminal-xterm"
|
||||
style={isCreating ? { display: "none" } : undefined}
|
||||
style={isLoading ? { display: "none" } : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { TerminalModal } from "../TerminalModal";
|
||||
import * as useTerminalModule from "../../hooks/useTerminal";
|
||||
import * as useTerminalSessionsModule from "../../hooks/useTerminalSessions";
|
||||
import * as apiModule from "../../api";
|
||||
|
||||
// Mock hooks and API
|
||||
@@ -9,9 +10,14 @@ vi.mock("../../hooks/useTerminal", () => ({
|
||||
useTerminal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useTerminalSessions", () => ({
|
||||
useTerminalSessions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
createTerminalSession: vi.fn(),
|
||||
killPtyTerminalSession: vi.fn(),
|
||||
listTerminalSessions: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
// Mock xterm modules to prevent DOM errors in jsdom
|
||||
@@ -53,9 +59,30 @@ vi.mock("@xterm/addon-webgl", () => {
|
||||
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
|
||||
|
||||
const mockUseTerminal = vi.mocked(useTerminalModule.useTerminal);
|
||||
const mockUseTerminalSessions = vi.mocked(useTerminalSessionsModule.useTerminalSessions);
|
||||
const mockCreateTerminalSession = vi.mocked(apiModule.createTerminalSession);
|
||||
const mockKillPtyTerminalSession = vi.mocked(apiModule.killPtyTerminalSession);
|
||||
|
||||
// Default tab state
|
||||
const defaultTab = {
|
||||
id: "tab-1",
|
||||
sessionId: "test-session-123",
|
||||
title: "bash",
|
||||
isActive: true,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
const defaultSessionState = {
|
||||
tabs: [defaultTab],
|
||||
activeTab: defaultTab,
|
||||
isReady: true,
|
||||
createTab: vi.fn(),
|
||||
closeTab: vi.fn(),
|
||||
setActiveTab: vi.fn(),
|
||||
updateTabTitle: vi.fn(),
|
||||
restartActiveTab: vi.fn(),
|
||||
};
|
||||
|
||||
describe("TerminalModal", () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockSendInput = vi.fn();
|
||||
@@ -83,6 +110,7 @@ describe("TerminalModal", () => {
|
||||
});
|
||||
mockKillPtyTerminalSession.mockResolvedValue({ killed: true });
|
||||
mockUseTerminal.mockReturnValue(createMockTerminalState());
|
||||
mockUseTerminalSessions.mockReturnValue(defaultSessionState);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -102,17 +130,12 @@ describe("TerminalModal", () => {
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("creates terminal session on open", async () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateTerminalSession).toHaveBeenCalled();
|
||||
it("shows loading state while sessions are not ready", async () => {
|
||||
mockUseTerminalSessions.mockReturnValue({
|
||||
...defaultSessionState,
|
||||
isReady: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows loading state while creating session", async () => {
|
||||
mockCreateTerminalSession.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -120,16 +143,124 @@ describe("TerminalModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error when session creation fails", async () => {
|
||||
mockCreateTerminalSession.mockRejectedValue(new Error("Failed to create session"));
|
||||
it("shows tabs when multiple sessions exist", async () => {
|
||||
mockUseTerminalSessions.mockReturnValue({
|
||||
...defaultSessionState,
|
||||
tabs: [
|
||||
defaultTab,
|
||||
{ id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() },
|
||||
],
|
||||
});
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("terminal-error")).toBeTruthy();
|
||||
expect(screen.getByText("bash")).toBeTruthy();
|
||||
expect(screen.getByText("zsh")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows active tab styling", async () => {
|
||||
mockUseTerminalSessions.mockReturnValue({
|
||||
...defaultSessionState,
|
||||
tabs: [
|
||||
{ ...defaultTab, isActive: true },
|
||||
{ id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() },
|
||||
],
|
||||
});
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const activeTab = screen.getByText("bash").closest(".terminal-tab");
|
||||
expect(activeTab).toHaveClass("terminal-tab--active");
|
||||
});
|
||||
});
|
||||
|
||||
it("tab click switches active tab", async () => {
|
||||
const mockSetActiveTab = vi.fn();
|
||||
mockUseTerminalSessions.mockReturnValue({
|
||||
...defaultSessionState,
|
||||
tabs: [
|
||||
{ ...defaultTab, isActive: true },
|
||||
{ id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() },
|
||||
],
|
||||
setActiveTab: mockSetActiveTab,
|
||||
});
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const zshTab = screen.getByText("zsh");
|
||||
fireEvent.click(zshTab);
|
||||
});
|
||||
|
||||
expect(mockSetActiveTab).toHaveBeenCalledWith("tab-2");
|
||||
});
|
||||
|
||||
it("tab close button closes tab", async () => {
|
||||
const mockCloseTab = vi.fn();
|
||||
mockUseTerminalSessions.mockReturnValue({
|
||||
...defaultSessionState,
|
||||
tabs: [
|
||||
{ ...defaultTab, isActive: true },
|
||||
{ id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() },
|
||||
],
|
||||
closeTab: mockCloseTab,
|
||||
});
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Find the close button for the zsh tab (second tab)
|
||||
const closeButtons = screen.getAllByTitle("Close tab");
|
||||
const zshCloseBtn = closeButtons[1]; // Second close button (for zsh tab)
|
||||
if (zshCloseBtn) {
|
||||
fireEvent.click(zshCloseBtn);
|
||||
}
|
||||
});
|
||||
|
||||
expect(mockCloseTab).toHaveBeenCalledWith("tab-2");
|
||||
});
|
||||
|
||||
it("new tab button creates new tab", async () => {
|
||||
const mockCreateTab = vi.fn().mockResolvedValue({
|
||||
id: "tab-new",
|
||||
sessionId: "new-session",
|
||||
title: "Terminal 2",
|
||||
isActive: true,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
mockUseTerminalSessions.mockReturnValue({
|
||||
...defaultSessionState,
|
||||
createTab: mockCreateTab,
|
||||
});
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const newTabBtn = screen.getByTitle("New terminal");
|
||||
fireEvent.click(newTabBtn);
|
||||
});
|
||||
|
||||
expect(mockCreateTab).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sessions are NOT killed when modal closes (session persistence)", async () => {
|
||||
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rerender(<TerminalModal isOpen={false} onClose={mockOnClose} />);
|
||||
});
|
||||
|
||||
// With multi-tab support, sessions should persist when modal closes
|
||||
expect(mockKillPtyTerminalSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes modal on close button click", async () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
@@ -162,22 +293,6 @@ describe("TerminalModal", () => {
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("kills session on modal close", async () => {
|
||||
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateTerminalSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rerender(<TerminalModal isOpen={false} onClose={mockOnClose} />);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockKillPtyTerminalSession).toHaveBeenCalledWith("test-session-123");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows reconnect button when disconnected", async () => {
|
||||
mockUseTerminal.mockReturnValue(
|
||||
createMockTerminalState({
|
||||
@@ -209,7 +324,7 @@ describe("TerminalModal", () => {
|
||||
expect(mockReconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("WebSocket connects on mount with sessionId", async () => {
|
||||
it("WebSocket connects on mount with sessionId from active tab", async () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -217,10 +332,10 @@ describe("TerminalModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("initializes xterm after session is created", async () => {
|
||||
it("initializes xterm after session is ready", async () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
// Wait for session creation to complete and xterm to initialize
|
||||
// Wait for session to be ready and xterm to initialize
|
||||
await waitFor(() => {
|
||||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||||
});
|
||||
@@ -230,18 +345,21 @@ describe("TerminalModal", () => {
|
||||
expect(mockTerminalInstance.open).toHaveBeenCalledWith(terminalDiv);
|
||||
});
|
||||
|
||||
it("xterm container is always in the DOM", async () => {
|
||||
mockCreateTerminalSession.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
it("xterm container is hidden while loading", async () => {
|
||||
mockUseTerminalSessions.mockReturnValue({
|
||||
...defaultSessionState,
|
||||
isReady: false,
|
||||
});
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
// Even while loading, the xterm container should exist (hidden)
|
||||
const xtermDiv = screen.getByTestId("terminal-xterm");
|
||||
expect(xtermDiv).toBeTruthy();
|
||||
expect(xtermDiv.style.display).toBe("none");
|
||||
await waitFor(() => {
|
||||
const xtermDiv = screen.getByTestId("terminal-xterm");
|
||||
expect(xtermDiv.style.display).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
it("xterm container becomes visible after session creation", async () => {
|
||||
it("xterm container becomes visible when ready", async () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -280,4 +398,58 @@ describe("TerminalModal", () => {
|
||||
expect(mockOnScrollback).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls restartActiveTab when New Session button clicked", async () => {
|
||||
const mockRestartActiveTab = vi.fn();
|
||||
let exitCallback: ((code: number) => void) | null = null;
|
||||
|
||||
mockUseTerminalSessions.mockReturnValue({
|
||||
...defaultSessionState,
|
||||
restartActiveTab: mockRestartActiveTab,
|
||||
});
|
||||
|
||||
// Create a custom mock that captures the exit callback
|
||||
const customOnExit = vi.fn((cb: (code: number) => void) => {
|
||||
exitCallback = cb;
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
mockUseTerminal.mockReturnValue({
|
||||
connectionStatus: "connected",
|
||||
sendInput: mockSendInput,
|
||||
resize: mockResize,
|
||||
onData: vi.fn(() => vi.fn()),
|
||||
onExit: customOnExit,
|
||||
onConnect: vi.fn(() => vi.fn()),
|
||||
onScrollback: vi.fn(() => vi.fn()),
|
||||
reconnect: mockReconnect,
|
||||
});
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Wait for xterm to initialize
|
||||
await waitFor(() => {
|
||||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Trigger the exit callback to simulate terminal exit
|
||||
act(() => {
|
||||
if (exitCallback) {
|
||||
exitCallback(0);
|
||||
}
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("terminal-restart-btn")).toBeTruthy();
|
||||
});
|
||||
|
||||
const restartBtn = screen.getByTestId("terminal-restart-btn");
|
||||
fireEvent.click(restartBtn);
|
||||
|
||||
expect(mockRestartActiveTab).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
||||
import { useTerminalSessions } from "../useTerminalSessions";
|
||||
import * as apiModule from "../../api";
|
||||
|
||||
// Mock API
|
||||
vi.mock("../../api", () => ({
|
||||
createTerminalSession: vi.fn(),
|
||||
killPtyTerminalSession: vi.fn(),
|
||||
listTerminalSessions: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCreateTerminalSession = vi.mocked(apiModule.createTerminalSession);
|
||||
const mockKillPtyTerminalSession = vi.mocked(apiModule.killPtyTerminalSession);
|
||||
const mockListTerminalSessions = vi.mocked(apiModule.listTerminalSessions);
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
describe("useTerminalSessions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
localStorageMock.setItem.mockImplementation(() => {});
|
||||
|
||||
// Default mock implementations
|
||||
mockCreateTerminalSession.mockResolvedValue({
|
||||
sessionId: "session-1",
|
||||
shell: "/bin/bash",
|
||||
cwd: "/project",
|
||||
});
|
||||
mockKillPtyTerminalSession.mockResolvedValue({ killed: true });
|
||||
mockListTerminalSessions.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("initial tab creation", () => {
|
||||
it("auto-creates first tab when no tabs exist in localStorage", async () => {
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
mockListTerminalSessions.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
expect(result.current.activeTab).not.toBeNull();
|
||||
});
|
||||
|
||||
expect(mockCreateTerminalSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores tabs from localStorage on mount", async () => {
|
||||
const storedTabs = [
|
||||
{
|
||||
id: "tab-1",
|
||||
sessionId: "session-1",
|
||||
title: "bash",
|
||||
isActive: true,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
localStorageMock.getItem.mockReturnValue(JSON.stringify(storedTabs));
|
||||
|
||||
// Session is still valid on server
|
||||
mockListTerminalSessions.mockResolvedValue([{ id: "session-1", shell: "/bin/bash", cwd: "/project" }]);
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
expect(result.current.tabs[0].sessionId).toBe("session-1");
|
||||
expect(result.current.activeTab?.id).toBe("tab-1");
|
||||
|
||||
// Should not create a new session if restoring existing ones
|
||||
expect(mockCreateTerminalSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters out stale sessions that no longer exist on server", async () => {
|
||||
const storedTabs = [
|
||||
{
|
||||
id: "tab-1",
|
||||
sessionId: "session-stale",
|
||||
title: "bash",
|
||||
isActive: true,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: "tab-2",
|
||||
sessionId: "session-valid",
|
||||
title: "zsh",
|
||||
isActive: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
localStorageMock.getItem.mockReturnValue(JSON.stringify(storedTabs));
|
||||
|
||||
// Only session-valid still exists on server
|
||||
mockListTerminalSessions.mockResolvedValue([
|
||||
{ id: "session-valid", shell: "/bin/zsh", cwd: "/project" }
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
});
|
||||
|
||||
expect(result.current.tabs[0].sessionId).toBe("session-valid");
|
||||
expect(result.current.activeTab?.id).toBe("tab-2");
|
||||
});
|
||||
|
||||
it("creates new tab if all stored sessions are stale", async () => {
|
||||
const storedTabs = [
|
||||
{
|
||||
id: "tab-1",
|
||||
sessionId: "session-stale",
|
||||
title: "bash",
|
||||
isActive: true,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
localStorageMock.getItem.mockReturnValue(JSON.stringify(storedTabs));
|
||||
|
||||
// No sessions exist on server
|
||||
mockListTerminalSessions.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
});
|
||||
|
||||
// Should have created a new session
|
||||
expect(mockCreateTerminalSession).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("creating additional tabs", () => {
|
||||
it("creates new tab with fresh session when createTab is called", async () => {
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
mockListTerminalSessions.mockResolvedValue([]);
|
||||
|
||||
mockCreateTerminalSession
|
||||
.mockResolvedValueOnce({
|
||||
sessionId: "session-1",
|
||||
shell: "/bin/bash",
|
||||
cwd: "/project",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
sessionId: "session-2",
|
||||
shell: "/bin/bash",
|
||||
cwd: "/project",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.createTab();
|
||||
});
|
||||
|
||||
expect(result.current.tabs.length).toBe(2);
|
||||
expect(result.current.activeTab?.sessionId).toBe("session-2");
|
||||
|
||||
// First tab should be deactivated
|
||||
expect(result.current.tabs[0].isActive).toBe(false);
|
||||
expect(result.current.tabs[1].isActive).toBe(true);
|
||||
});
|
||||
|
||||
it("names tabs with incrementing numbers", async () => {
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
mockListTerminalSessions.mockResolvedValue([]);
|
||||
|
||||
mockCreateTerminalSession
|
||||
.mockResolvedValueOnce({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" })
|
||||
.mockResolvedValueOnce({ sessionId: "session-2", shell: "/bin/bash", cwd: "/project" })
|
||||
.mockResolvedValueOnce({ sessionId: "session-3", shell: "/bin/bash", cwd: "/project" });
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
expect(result.current.tabs[0].title).toBe("Terminal 1");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.createTab();
|
||||
});
|
||||
|
||||
expect(result.current.tabs[1].title).toBe("Terminal 2");
|
||||
|
||||
await act(async () => {
|
||||
await result.current.createTab();
|
||||
});
|
||||
|
||||
expect(result.current.tabs[2].title).toBe("Terminal 3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("closing tabs", () => {
|
||||
it("closes tab and kills server session", async () => {
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
mockListTerminalSessions.mockResolvedValue([]);
|
||||
|
||||
mockCreateTerminalSession.mockResolvedValue({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" });
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
});
|
||||
|
||||
const tabId = result.current.tabs[0].id;
|
||||
const sessionId = result.current.tabs[0].sessionId;
|
||||
|
||||
act(() => {
|
||||
result.current.closeTab(tabId);
|
||||
});
|
||||
|
||||
expect(mockKillPtyTerminalSession).toHaveBeenCalledWith(sessionId);
|
||||
expect(result.current.tabs.length).toBe(0);
|
||||
});
|
||||
|
||||
it("closing active tab switches to next tab", async () => {
|
||||
const storedTabs = [
|
||||
{
|
||||
id: "tab-1",
|
||||
sessionId: "session-1",
|
||||
title: "Terminal 1",
|
||||
isActive: true,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: "tab-2",
|
||||
sessionId: "session-2",
|
||||
title: "Terminal 2",
|
||||
isActive: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
localStorageMock.getItem.mockReturnValue(JSON.stringify(storedTabs));
|
||||
mockListTerminalSessions.mockResolvedValue([
|
||||
{ id: "session-1", shell: "/bin/bash", cwd: "/project" },
|
||||
{ id: "session-2", shell: "/bin/bash", cwd: "/project" },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(2);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.closeTab("tab-1");
|
||||
});
|
||||
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
expect(result.current.activeTab?.id).toBe("tab-2");
|
||||
});
|
||||
|
||||
it("closing last tab triggers auto-creation of new tab", async () => {
|
||||
const storedTabs = [
|
||||
{
|
||||
id: "tab-1",
|
||||
sessionId: "session-1",
|
||||
title: "Terminal 1",
|
||||
isActive: true,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
localStorageMock.getItem.mockReturnValue(JSON.stringify(storedTabs));
|
||||
mockListTerminalSessions.mockResolvedValue([{ id: "session-1", shell: "/bin/bash", cwd: "/project" }]);
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
});
|
||||
|
||||
// Tab was restored from localStorage (no createTerminalSession call yet)
|
||||
expect(mockCreateTerminalSession).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
result.current.closeTab("tab-1");
|
||||
});
|
||||
|
||||
// Tab count goes to 0 momentarily
|
||||
expect(result.current.tabs.length).toBe(0);
|
||||
|
||||
// New tab should be auto-created
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
});
|
||||
|
||||
// createTerminalSession was called once during auto-create
|
||||
expect(mockCreateTerminalSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("switching active tab", () => {
|
||||
it("updates isActive when switching tabs", async () => {
|
||||
const storedTabs = [
|
||||
{
|
||||
id: "tab-1",
|
||||
sessionId: "session-1",
|
||||
title: "Terminal 1",
|
||||
isActive: true,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: "tab-2",
|
||||
sessionId: "session-2",
|
||||
title: "Terminal 2",
|
||||
isActive: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
localStorageMock.getItem.mockReturnValue(JSON.stringify(storedTabs));
|
||||
mockListTerminalSessions.mockResolvedValue([
|
||||
{ id: "session-1", shell: "/bin/bash", cwd: "/project" },
|
||||
{ id: "session-2", shell: "/bin/bash", cwd: "/project" },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.activeTab?.id).toBe("tab-1");
|
||||
|
||||
act(() => {
|
||||
result.current.setActiveTab("tab-2");
|
||||
});
|
||||
|
||||
expect(result.current.activeTab?.id).toBe("tab-2");
|
||||
expect(result.current.tabs.find((t) => t.id === "tab-1")?.isActive).toBe(false);
|
||||
expect(result.current.tabs.find((t) => t.id === "tab-2")?.isActive).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updating tab titles", () => {
|
||||
it("updates tab title when updateTabTitle is called", async () => {
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
mockListTerminalSessions.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.updateTabTitle(result.current.tabs[0].id, "zsh");
|
||||
});
|
||||
|
||||
expect(result.current.tabs[0].title).toBe("zsh");
|
||||
});
|
||||
});
|
||||
|
||||
describe("restarting active tab", () => {
|
||||
it("creates new session for active tab", async () => {
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
mockListTerminalSessions.mockResolvedValue([]);
|
||||
|
||||
mockCreateTerminalSession
|
||||
.mockResolvedValueOnce({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" })
|
||||
.mockResolvedValueOnce({ sessionId: "session-new", shell: "/bin/bash", cwd: "/project" });
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.restartActiveTab();
|
||||
});
|
||||
|
||||
// Old session should be killed
|
||||
expect(mockKillPtyTerminalSession).toHaveBeenCalledWith("session-1");
|
||||
|
||||
// Tab should have new session
|
||||
expect(result.current.activeTab?.sessionId).toBe("session-new");
|
||||
});
|
||||
});
|
||||
|
||||
describe("localStorage persistence", () => {
|
||||
it("persists tabs to localStorage", async () => {
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
mockListTerminalSessions.mockResolvedValue([]);
|
||||
mockCreateTerminalSession.mockResolvedValue({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" });
|
||||
|
||||
renderHook(() => useTerminalSessions());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorageMock.setItem).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify the stored data contains the tabs
|
||||
const setItemCalls = localStorageMock.setItem.mock.calls;
|
||||
expect(setItemCalls.length).toBeGreaterThan(0);
|
||||
|
||||
const lastCall = setItemCalls[setItemCalls.length - 1];
|
||||
const storedTabs = JSON.parse(lastCall[1]);
|
||||
expect(storedTabs).toBeInstanceOf(Array);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("handles localStorage errors gracefully", async () => {
|
||||
localStorageMock.getItem.mockImplementation(() => {
|
||||
throw new Error("localStorage error");
|
||||
});
|
||||
mockListTerminalSessions.mockResolvedValue([]);
|
||||
|
||||
// Should not throw
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
});
|
||||
|
||||
// Should still create a tab
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("handles server listing failure gracefully", async () => {
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
mockListTerminalSessions.mockRejectedValue(new Error("Server error"));
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
});
|
||||
|
||||
// Should still create a tab
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("non-blocking session kill - tab removed even if kill fails", async () => {
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
mockListTerminalSessions.mockResolvedValue([]);
|
||||
mockKillPtyTerminalSession.mockRejectedValue(new Error("Kill failed"));
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
});
|
||||
|
||||
const tabId = result.current.tabs[0].id;
|
||||
|
||||
act(() => {
|
||||
result.current.closeTab(tabId);
|
||||
});
|
||||
|
||||
// Tab should still be removed
|
||||
expect(result.current.tabs.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
323
packages/dashboard/app/hooks/useTerminalSessions.ts
Normal file
323
packages/dashboard/app/hooks/useTerminalSessions.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { createTerminalSession, killPtyTerminalSession, listTerminalSessions } from "../api";
|
||||
|
||||
const STORAGE_KEY = "kb-terminal-tabs";
|
||||
|
||||
/**
|
||||
* Represents a terminal tab with its metadata and session information.
|
||||
*/
|
||||
export interface TerminalTab {
|
||||
/** Unique tab ID (client-generated) */
|
||||
id: string;
|
||||
/** PTY session ID from server */
|
||||
sessionId: string;
|
||||
/** Display title (e.g., "bash", "zsh", or "Terminal 1") */
|
||||
title: string;
|
||||
/** Whether this tab is currently active */
|
||||
isActive: boolean;
|
||||
/** Creation timestamp */
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface StoredTab extends TerminalTab {
|
||||
/** Marked as unverified during server validation */
|
||||
_verified?: boolean;
|
||||
}
|
||||
|
||||
interface UseTerminalSessionsReturn {
|
||||
/** All terminal tabs */
|
||||
tabs: TerminalTab[];
|
||||
/** Currently active tab */
|
||||
activeTab: TerminalTab | null;
|
||||
/** Whether sessions have been validated and restored from server */
|
||||
isReady: boolean;
|
||||
/** Creates a new tab with a fresh server session */
|
||||
createTab: () => Promise<TerminalTab>;
|
||||
/** Closes a specific tab (kills server session) */
|
||||
closeTab: (tabId: string) => void;
|
||||
/** Switches to a different tab */
|
||||
setActiveTab: (tabId: string) => void;
|
||||
/** Updates the display title of a tab */
|
||||
updateTabTitle: (tabId: string, title: string) => void;
|
||||
/** Restarts the active tab's session with a new PTY session */
|
||||
restartActiveTab: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique ID for a new tab.
|
||||
*/
|
||||
function generateTabId(): string {
|
||||
return `tab-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing multiple terminal sessions with localStorage persistence.
|
||||
*
|
||||
* Features:
|
||||
* - Multiple terminal tabs with independent sessions
|
||||
* - Sessions persist when modal is closed
|
||||
* - Automatic session restoration on page reload
|
||||
* - Stale session cleanup via server validation
|
||||
* - `isReady` flag indicates when session validation is complete
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { tabs, activeTab, isReady, createTab, closeTab, setActiveTab, updateTabTitle, restartActiveTab } = useTerminalSessions();
|
||||
* ```
|
||||
*/
|
||||
export function useTerminalSessions(): UseTerminalSessionsReturn {
|
||||
// Initialize state synchronously from localStorage (no async here)
|
||||
const [tabs, setTabs] = useState<TerminalTab[]>(() => {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as TerminalTab[];
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// Track whether validation has completed
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
// Persist tabs to localStorage whenever they change
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(tabs));
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}, [tabs]);
|
||||
|
||||
// Validate and restore tabs from server on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const validateAndRestore = async () => {
|
||||
if (cancelled) return;
|
||||
|
||||
try {
|
||||
// Get active server sessions
|
||||
const serverSessions = await listTerminalSessions();
|
||||
if (cancelled) return;
|
||||
|
||||
const validSessionIds = new Set(serverSessions.map((s) => s.id));
|
||||
|
||||
setTabs((currentTabs) => {
|
||||
if (cancelled) return currentTabs;
|
||||
|
||||
// Filter out tabs whose sessions no longer exist on server
|
||||
const validTabs = currentTabs.map((tab) => ({
|
||||
...tab,
|
||||
_verified: validSessionIds.has(tab.sessionId),
|
||||
}));
|
||||
|
||||
const remainingTabs = validTabs.filter((tab) => tab._verified);
|
||||
|
||||
if (remainingTabs.length === 0) {
|
||||
// No valid tabs - return empty to trigger auto-create
|
||||
return [];
|
||||
}
|
||||
|
||||
// Strip internal _verified property and return clean TerminalTab objects
|
||||
const cleanTabs = remainingTabs.map(({ _verified: _unused, ...tab }) => tab);
|
||||
|
||||
// Ensure exactly one tab is active
|
||||
const activeTab = cleanTabs.find((t) => t.isActive);
|
||||
if (!activeTab) {
|
||||
// No active tab, activate the first one
|
||||
return cleanTabs.map((tab, i) => ({
|
||||
...tab,
|
||||
isActive: i === 0,
|
||||
}));
|
||||
}
|
||||
|
||||
return cleanTabs;
|
||||
});
|
||||
|
||||
// Mark as ready after validation
|
||||
setIsReady(true);
|
||||
} catch (err) {
|
||||
// Server listing failed - keep local tabs but mark as unverified
|
||||
// The WebSocket will fail to connect, which is acceptable
|
||||
console.warn("Failed to validate terminal sessions with server:", err);
|
||||
// Still mark as ready so the UI can proceed
|
||||
setIsReady(true);
|
||||
}
|
||||
};
|
||||
|
||||
validateAndRestore();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []); // Only run once on mount
|
||||
|
||||
// Auto-create first tab if no tabs exist after validation
|
||||
useEffect(() => {
|
||||
if (tabs.length === 0 && isReady) {
|
||||
// Small delay to avoid race condition with the validation effect
|
||||
const timeout = setTimeout(() => {
|
||||
createTabInternal().catch(console.error);
|
||||
}, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [isReady, tabs.length]); // Run when ready or when tabs become empty
|
||||
|
||||
/**
|
||||
* Internal create tab function (used for auto-creation and user-initiated creation)
|
||||
*/
|
||||
const createTabInternal = useCallback(async (): Promise<TerminalTab> => {
|
||||
const session = await createTerminalSession();
|
||||
const newTab: TerminalTab = {
|
||||
id: generateTabId(),
|
||||
sessionId: session.sessionId,
|
||||
title: `Terminal ${tabs.length + 1}`,
|
||||
isActive: true,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
setTabs((currentTabs) => {
|
||||
// Deactivate all other tabs
|
||||
const updatedTabs = currentTabs.map((tab) => ({
|
||||
...tab,
|
||||
isActive: false,
|
||||
}));
|
||||
return [...updatedTabs, newTab];
|
||||
});
|
||||
|
||||
return newTab;
|
||||
}, [tabs.length]);
|
||||
|
||||
/**
|
||||
* Creates a new tab with a fresh server session.
|
||||
* The new tab becomes the active tab.
|
||||
*/
|
||||
const createTab = useCallback(async (): Promise<TerminalTab> => {
|
||||
return createTabInternal();
|
||||
}, [createTabInternal]);
|
||||
|
||||
/**
|
||||
* Closes a specific tab by ID.
|
||||
* Kills the server session (non-blocking) and removes the tab.
|
||||
* If closing the active tab, activates the next or previous tab.
|
||||
* If closing the last tab, auto-creates a new one.
|
||||
*/
|
||||
const closeTab = useCallback((tabId: string): void => {
|
||||
setTabs((currentTabs) => {
|
||||
const tabToClose = currentTabs.find((t) => t.id === tabId);
|
||||
if (!tabToClose) return currentTabs;
|
||||
|
||||
// Non-blocking server session kill
|
||||
killPtyTerminalSession(tabToClose.sessionId).catch((err) => {
|
||||
console.warn(`Failed to kill terminal session ${tabToClose.sessionId}:`, err);
|
||||
});
|
||||
|
||||
const tabIndex = currentTabs.findIndex((t) => t.id === tabId);
|
||||
const wasActive = tabToClose.isActive;
|
||||
const remainingTabs = currentTabs.filter((t) => t.id !== tabId);
|
||||
|
||||
// If no tabs left, return empty (auto-create will happen via effect)
|
||||
if (remainingTabs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// If we closed the active tab, activate adjacent tab
|
||||
if (wasActive) {
|
||||
// Try to activate the next tab, or fall back to previous
|
||||
const newActiveIndex = Math.min(tabIndex, remainingTabs.length - 1);
|
||||
return remainingTabs.map((tab, i) => ({
|
||||
...tab,
|
||||
isActive: i === newActiveIndex,
|
||||
}));
|
||||
}
|
||||
|
||||
return remainingTabs;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Switches to a different tab by ID.
|
||||
*/
|
||||
const setActiveTab = useCallback((tabId: string): void => {
|
||||
setTabs((currentTabs) => {
|
||||
let found = false;
|
||||
const updatedTabs = currentTabs.map((tab) => {
|
||||
if (tab.id === tabId) {
|
||||
found = true;
|
||||
return { ...tab, isActive: true };
|
||||
}
|
||||
return { ...tab, isActive: false };
|
||||
});
|
||||
|
||||
// Only update if the tab was found
|
||||
if (found) {
|
||||
return updatedTabs;
|
||||
}
|
||||
return currentTabs;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Updates the display title of a specific tab.
|
||||
*/
|
||||
const updateTabTitle = useCallback((tabId: string, title: string): void => {
|
||||
setTabs((currentTabs) =>
|
||||
currentTabs.map((tab) =>
|
||||
tab.id === tabId ? { ...tab, title } : tab
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Restarts the active tab's session with a new PTY session.
|
||||
* Keeps the same tab but creates a new server session.
|
||||
*/
|
||||
const restartActiveTab = useCallback(async (): Promise<void> => {
|
||||
setTabs((currentTabs) => {
|
||||
const activeTab = currentTabs.find((t) => t.isActive);
|
||||
if (!activeTab) return currentTabs;
|
||||
|
||||
// Kill the old session (non-blocking)
|
||||
killPtyTerminalSession(activeTab.sessionId).catch((err) => {
|
||||
console.warn(`Failed to kill old session ${activeTab.sessionId}:`, err);
|
||||
});
|
||||
|
||||
return currentTabs;
|
||||
});
|
||||
|
||||
// Create new session for the active tab
|
||||
// We need to do this outside of setTabs to properly handle the async operation
|
||||
// Store the current tabs to find the active tab ID
|
||||
const currentActiveTab = tabs.find((t) => t.isActive);
|
||||
if (!currentActiveTab) return;
|
||||
|
||||
// Create new session and update the tab's sessionId
|
||||
const session = await createTerminalSession();
|
||||
|
||||
setTabs((currentTabs) =>
|
||||
currentTabs.map((tab) =>
|
||||
tab.id === currentActiveTab.id
|
||||
? { ...tab, sessionId: session.sessionId }
|
||||
: tab
|
||||
)
|
||||
);
|
||||
}, [tabs]);
|
||||
|
||||
// Derive active tab
|
||||
const activeTab = tabs.find((tab) => tab.isActive) ?? null;
|
||||
|
||||
return {
|
||||
tabs,
|
||||
activeTab,
|
||||
isReady,
|
||||
createTab,
|
||||
closeTab,
|
||||
setActiveTab,
|
||||
updateTabTitle,
|
||||
restartActiveTab,
|
||||
};
|
||||
}
|
||||
@@ -6213,6 +6213,50 @@ body {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.terminal-tab-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
margin-left: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.terminal-tab:hover .terminal-tab-close,
|
||||
.terminal-tab--active .terminal-tab-close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.terminal-tab-close:hover {
|
||||
background: var(--card-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.terminal-tab--new {
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 4px;
|
||||
min-width: 32px;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.terminal-tab--new:hover {
|
||||
color: var(--text);
|
||||
background: var(--card-hover);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.terminal-tab--empty {
|
||||
color: var(--text-muted);
|
||||
cursor: default;
|
||||
|
||||
Reference in New Issue
Block a user