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:
@@ -3,35 +3,38 @@
|
||||
/**
|
||||
* TerminalPanel
|
||||
*
|
||||
* Shell panel that wraps the XTerminal component with a tab bar and close button.
|
||||
* Replaces the former mock terminal with a real xterm.js PTY terminal.
|
||||
* Multi-tab terminal panel. Manages multiple PTY sessions via useTerminalSessions,
|
||||
* 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 { useTerminalSessions } from "@/hooks/useTerminalSessions";
|
||||
|
||||
// ==========================================
|
||||
// Types (retained for backwards compatibility)
|
||||
// Types
|
||||
// ==========================================
|
||||
|
||||
export interface TerminalTab {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface TerminalPanelProps {
|
||||
/** Whether the panel is visible */
|
||||
open: boolean;
|
||||
/** Called when the user closes the panel */
|
||||
onClose: () => void;
|
||||
tabs?: TerminalTab[];
|
||||
activeTab?: string;
|
||||
onTabChange?: (id: string) => void;
|
||||
/** Authentication token for the WebSocket connection */
|
||||
token?: string;
|
||||
/** Optional CSS class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const defaultTabs: TerminalTab[] = [{ id: "main", label: "main" }];
|
||||
|
||||
// ==========================================
|
||||
// Component
|
||||
// ==========================================
|
||||
@@ -39,14 +42,107 @@ const defaultTabs: TerminalTab[] = [{ id: "main", label: "main" }];
|
||||
export function TerminalPanel({
|
||||
open,
|
||||
onClose,
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
token = "",
|
||||
className = "",
|
||||
}: TerminalPanelProps): ReactElement {
|
||||
const resolvedTabs = tabs ?? defaultTabs;
|
||||
const resolvedActiveTab = activeTab ?? resolvedTabs[0]?.id ?? "";
|
||||
const {
|
||||
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 = {
|
||||
background: "var(--bg-deep)",
|
||||
@@ -71,12 +167,15 @@ export function TerminalPanel({
|
||||
const tabBarStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
gap: 2,
|
||||
alignItems: "center",
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const actionsStyle: CSSProperties = {
|
||||
marginLeft: "auto",
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
alignItems: "center",
|
||||
};
|
||||
|
||||
const bodyStyle: CSSProperties = {
|
||||
@@ -85,8 +184,13 @@ export function TerminalPanel({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: 0,
|
||||
position: "relative",
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// Render
|
||||
// ==========================================
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
@@ -99,50 +203,172 @@ export function TerminalPanel({
|
||||
<div style={headerStyle}>
|
||||
{/* Tab bar */}
|
||||
<div style={tabBarStyle} role="tablist" aria-label="Terminal tabs">
|
||||
{resolvedTabs.map((tab) => {
|
||||
const isActive = tab.id === resolvedActiveTab;
|
||||
{[...sessions.entries()].map(([sessionId, sessionInfo]) => {
|
||||
const isActive = sessionId === activeSessionId;
|
||||
const isEditing = sessionId === editingTabId;
|
||||
|
||||
const tabStyle: CSSProperties = {
|
||||
padding: "3px 10px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
padding: "3px 6px 3px 10px",
|
||||
borderRadius: 4,
|
||||
fontSize: "0.75rem",
|
||||
fontFamily: "var(--mono)",
|
||||
color: isActive ? "var(--success)" : "var(--muted)",
|
||||
cursor: "pointer",
|
||||
background: isActive ? "var(--surface)" : "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
style={tabStyle}
|
||||
onClick={(): void => {
|
||||
onTabChange?.(tab.id);
|
||||
}}
|
||||
onMouseEnter={(e): void => {
|
||||
if (!isActive) {
|
||||
<div key={sessionId} style={tabStyle}>
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
value={editingName}
|
||||
onChange={(e): void => {
|
||||
setEditingName(e.target.value);
|
||||
}}
|
||||
onBlur={commitRename}
|
||||
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.color = "var(--text-2)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e): void => {
|
||||
if (!isActive) {
|
||||
(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)";
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
}}
|
||||
>
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
|
||||
<path
|
||||
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>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={actionsStyle}>
|
||||
{/* Close panel button */}
|
||||
<button
|
||||
aria-label="Close terminal"
|
||||
style={{
|
||||
@@ -182,9 +408,72 @@ export function TerminalPanel({
|
||||
</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}>
|
||||
<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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user