a3d01dd0a5
- Initialize loading=true in useTerminalSessions to prevent auto-create from firing before initial load completes - Remove hasAutoCreated ref from TerminalPage (no longer needed) - Add focus() to TerminalRef, call on tab switch - Add term.focus() after term.open() in TerminalComponent - Add console logging for WebSocket send/receive to debug no-i/o - Revert backend _read_loop retry logic to original break-on-error
311 lines
8.3 KiB
TypeScript
311 lines
8.3 KiB
TypeScript
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
import { TerminalComponent, type TerminalRef } from "../components/terminal";
|
|
import {
|
|
TerminalSessionTabs,
|
|
type TerminalSessionInfo,
|
|
} from "../components/terminal-session-tabs";
|
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
|
import { useAutoHide } from "../hooks/use-auto-hide";
|
|
import { useTerminalSessions } from "../hooks/use-terminal-sessions";
|
|
import type { TerminalSession } from "../api/terminal";
|
|
|
|
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
|
|
sessions.map((s) => ({
|
|
id: s.id,
|
|
name: s.name,
|
|
status: s.status as TerminalSessionInfo["status"],
|
|
}));
|
|
|
|
export const TerminalPage: React.FC = () => {
|
|
const { instanceId } = useParams<{
|
|
instanceId: string;
|
|
}>();
|
|
const navigate = useNavigate();
|
|
const isMobile = useMobileViewport();
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
|
|
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
|
|
|
|
const {
|
|
sessions,
|
|
activeSessionId,
|
|
setActiveSessionId,
|
|
createSession,
|
|
closeSession,
|
|
renameSession,
|
|
resetSession,
|
|
loading,
|
|
error,
|
|
} = useTerminalSessions(instanceId ?? "");
|
|
|
|
// Auto-create default session if none exist after loading completes
|
|
useEffect(() => {
|
|
if (!loading && sessions.length === 0 && !error && instanceId) {
|
|
void createSession("Session 1");
|
|
}
|
|
}, [loading, sessions.length, error, instanceId, createSession]);
|
|
|
|
// Ensure refs map is kept in sync with sessions
|
|
useEffect(() => {
|
|
for (const session of sessions) {
|
|
if (!terminalRefs.current[session.id]) {
|
|
terminalRefs.current[session.id] = React.createRef<TerminalRef>();
|
|
}
|
|
}
|
|
// Clean up refs for closed sessions
|
|
const currentIds = new Set(sessions.map((s) => s.id));
|
|
for (const id of Object.keys(terminalRefs.current)) {
|
|
if (!currentIds.has(id)) {
|
|
delete terminalRefs.current[id];
|
|
}
|
|
}
|
|
}, [sessions]);
|
|
|
|
// Fit and focus active terminal when switching tabs
|
|
useEffect(() => {
|
|
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
|
const ref = terminalRefs.current[activeSessionId];
|
|
// Small delay to allow display:block to apply
|
|
const timer = setTimeout(() => {
|
|
ref.current?.fit();
|
|
ref.current?.focus();
|
|
}, 50);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [activeSessionId]);
|
|
|
|
// Keyboard shortcuts
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
|
|
if (!isAltShift) return;
|
|
|
|
switch (e.key.toLowerCase()) {
|
|
case "n":
|
|
e.preventDefault();
|
|
if (sessions.length < 5) {
|
|
void createSession(`Session ${sessions.length + 1}`);
|
|
}
|
|
break;
|
|
case "w":
|
|
e.preventDefault();
|
|
if (
|
|
activeSessionId &&
|
|
window.confirm("Close this terminal session?")
|
|
) {
|
|
void closeSession(activeSessionId);
|
|
}
|
|
break;
|
|
case "arrowleft":
|
|
e.preventDefault();
|
|
if (activeSessionId) {
|
|
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
|
if (idx > 0) {
|
|
setActiveSessionId(sessions[idx - 1].id);
|
|
}
|
|
}
|
|
break;
|
|
case "arrowright":
|
|
e.preventDefault();
|
|
if (activeSessionId) {
|
|
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
|
if (idx < sessions.length - 1) {
|
|
setActiveSessionId(sessions[idx + 1].id);
|
|
}
|
|
}
|
|
break;
|
|
case "r":
|
|
e.preventDefault();
|
|
if (activeSessionId) {
|
|
void resetSession(activeSessionId);
|
|
}
|
|
break;
|
|
case "f":
|
|
e.preventDefault();
|
|
setIsFullscreen((prev) => !prev);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [
|
|
sessions,
|
|
activeSessionId,
|
|
createSession,
|
|
closeSession,
|
|
resetSession,
|
|
setActiveSessionId,
|
|
]);
|
|
|
|
// Exit fullscreen on Escape
|
|
useEffect(() => {
|
|
if (!isFullscreen) return;
|
|
const handleEscape = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
setIsFullscreen(false);
|
|
}
|
|
};
|
|
window.addEventListener("keydown", handleEscape);
|
|
return () => window.removeEventListener("keydown", handleEscape);
|
|
}, [isFullscreen]);
|
|
|
|
const handleSelect = useCallback(
|
|
(sessionId: string) => {
|
|
setActiveSessionId(sessionId);
|
|
},
|
|
[setActiveSessionId],
|
|
);
|
|
|
|
const handleClose = useCallback(
|
|
async (sessionId: string) => {
|
|
await closeSession(sessionId);
|
|
},
|
|
[closeSession],
|
|
);
|
|
|
|
const handleCreate = useCallback(() => {
|
|
void createSession(`Session ${sessions.length + 1}`);
|
|
}, [createSession, sessions.length]);
|
|
|
|
const handleRename = useCallback(
|
|
(sessionId: string, newName: string) => {
|
|
void renameSession(sessionId, newName);
|
|
},
|
|
[renameSession],
|
|
);
|
|
|
|
if (!instanceId) {
|
|
return (
|
|
<section className="stack">
|
|
<h1>Terminal</h1>
|
|
<p className="muted">No instance ID provided.</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const sessionInfos = SESSIONS_TO_INFO(sessions);
|
|
|
|
if (isMobile) {
|
|
return (
|
|
<section
|
|
className={`terminal-page mobile ${isFullscreen ? "fullscreen" : ""}`}
|
|
>
|
|
<div
|
|
className={`terminal-page-header mobile-header ${headerAutoHide.isVisible ? "visible" : "hidden"}`}
|
|
onClick={() => headerAutoHide.show()}
|
|
>
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() => navigate(-1)}
|
|
type="button"
|
|
>
|
|
Back
|
|
</button>
|
|
<h1>Terminal</h1>
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() => setIsFullscreen((p) => !p)}
|
|
type="button"
|
|
>
|
|
{isFullscreen ? "Exit" : "Fullscreen"}
|
|
</button>
|
|
</div>
|
|
<div
|
|
className={`mobile-tabs-container ${headerAutoHide.isVisible ? "visible" : "hidden"}`}
|
|
onClick={() => headerAutoHide.show()}
|
|
>
|
|
<TerminalSessionTabs
|
|
sessions={sessionInfos}
|
|
activeSessionId={activeSessionId ?? ""}
|
|
onSelect={handleSelect}
|
|
onClose={handleClose}
|
|
onCreate={handleCreate}
|
|
onRename={handleRename}
|
|
isMobile={true}
|
|
/>
|
|
</div>
|
|
<div className="terminal-page-content">
|
|
{error && <div className="terminal-error-banner">{error}</div>}
|
|
{sessions
|
|
.filter((session) => session.id === activeSessionId)
|
|
.map((session) => (
|
|
<div key={session.id} className="terminal-instance active">
|
|
<TerminalComponent
|
|
ref={terminalRefs.current[session.id]}
|
|
instanceId={instanceId}
|
|
sessionId={session.id}
|
|
onClose={() => handleClose(session.id)}
|
|
isMobile={true}
|
|
/>
|
|
</div>
|
|
))}
|
|
{sessions.length === 0 && !loading && (
|
|
<div className="terminal-empty-state">
|
|
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section className={`terminal-page ${isFullscreen ? "fullscreen" : ""}`}>
|
|
{!isFullscreen && (
|
|
<div className="terminal-page-header">
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() => navigate(-1)}
|
|
type="button"
|
|
>
|
|
Back
|
|
</button>
|
|
<h1>Terminal</h1>
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() => setIsFullscreen((p) => !p)}
|
|
type="button"
|
|
title="Toggle fullscreen (Alt+Shift+F)"
|
|
>
|
|
{isFullscreen ? "Exit Fullscreen" : "Fullscreen"}
|
|
</button>
|
|
</div>
|
|
)}
|
|
<TerminalSessionTabs
|
|
sessions={sessionInfos}
|
|
activeSessionId={activeSessionId ?? ""}
|
|
onSelect={handleSelect}
|
|
onClose={handleClose}
|
|
onCreate={handleCreate}
|
|
onRename={handleRename}
|
|
isMobile={false}
|
|
/>
|
|
<div className="terminal-page-content">
|
|
{error && <div className="terminal-error-banner">{error}</div>}
|
|
{sessions
|
|
.filter((session) => session.id === activeSessionId)
|
|
.map((session) => (
|
|
<div key={session.id} className="terminal-instance active">
|
|
<TerminalComponent
|
|
ref={terminalRefs.current[session.id]}
|
|
instanceId={instanceId}
|
|
sessionId={session.id}
|
|
onClose={() => handleClose(session.id)}
|
|
isMobile={false}
|
|
/>
|
|
</div>
|
|
))}
|
|
{sessions.length === 0 && !loading && (
|
|
<div className="terminal-empty-state">
|
|
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|