refactor: extract TerminalPage components
- Extract use-terminal-page hook for terminal state and effects - Extract MobileTerminalView and DesktopTerminalView components - Slim TerminalPage from 571 to 112 lines Quality gates: tsc --noEmit passes, npm run build passes
This commit is contained in:
@@ -0,0 +1,198 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { TerminalComponent, type TerminalRef } from "./terminal";
|
||||||
|
import { TerminalSessionTabs, type TerminalSessionInfo } from "./terminal-session-tabs";
|
||||||
|
import type { TerminalSession } from "../../../api/terminal";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
instanceId: string;
|
||||||
|
sessions: TerminalSession[];
|
||||||
|
sessionInfos: TerminalSessionInfo[];
|
||||||
|
activeSessionId: string;
|
||||||
|
terminalRefs: React.MutableRefObject<Record<string, React.RefObject<TerminalRef>>>;
|
||||||
|
isFullscreen: boolean;
|
||||||
|
status: string;
|
||||||
|
error: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
showResetConfirm: boolean;
|
||||||
|
onFullscreenClick: (e: React.MouseEvent<HTMLElement>) => void;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onClose: (id: string) => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
onRename: (id: string, name: string) => void;
|
||||||
|
onNavigateBack: () => void;
|
||||||
|
onToggleFullscreen: () => void;
|
||||||
|
onFontSizeChange: (delta: number) => void;
|
||||||
|
onShowResetConfirm: () => void;
|
||||||
|
onHideResetConfirm: () => void;
|
||||||
|
onReset: () => void;
|
||||||
|
onTerminalReady: (
|
||||||
|
sendData: (data: string) => void,
|
||||||
|
status: "connecting" | "connected" | "disconnected" | "error" | "resetting",
|
||||||
|
focusInput: () => void,
|
||||||
|
changeFontSize: (delta: number) => void,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DesktopTerminalView: React.FC<Props> = ({
|
||||||
|
instanceId,
|
||||||
|
sessions,
|
||||||
|
sessionInfos,
|
||||||
|
activeSessionId,
|
||||||
|
terminalRefs,
|
||||||
|
isFullscreen,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
showResetConfirm,
|
||||||
|
onFullscreenClick,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
onCreate,
|
||||||
|
onRename,
|
||||||
|
onNavigateBack,
|
||||||
|
onToggleFullscreen,
|
||||||
|
onFontSizeChange,
|
||||||
|
onShowResetConfirm,
|
||||||
|
onHideResetConfirm,
|
||||||
|
onReset,
|
||||||
|
onTerminalReady,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`terminal-page ${isFullscreen ? "fullscreen" : ""}`}
|
||||||
|
onClick={onFullscreenClick}
|
||||||
|
>
|
||||||
|
{!isFullscreen && (
|
||||||
|
<div className="terminal-page-header">
|
||||||
|
<button className="secondary-button" onClick={onNavigateBack} type="button">
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<h1>Terminal</h1>
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={onToggleFullscreen}
|
||||||
|
type="button"
|
||||||
|
title="Toggle fullscreen (Alt+Shift+F)"
|
||||||
|
>
|
||||||
|
{isFullscreen ? "Exit Fullscreen" : "Fullscreen"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isFullscreen ? (
|
||||||
|
<div className="terminal-fullscreen-header">
|
||||||
|
<div className="terminal-fullscreen-header-tabs">
|
||||||
|
<TerminalSessionTabs
|
||||||
|
sessions={sessionInfos}
|
||||||
|
activeSessionId={activeSessionId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onClose={onClose}
|
||||||
|
onCreate={onCreate}
|
||||||
|
onRename={onRename}
|
||||||
|
isMobile={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="terminal-fullscreen-header-controls">
|
||||||
|
<span
|
||||||
|
className={`terminal-fullscreen-status status-dot ${status}`}
|
||||||
|
aria-label={`Terminal status: ${status}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="terminal-header-button"
|
||||||
|
onClick={() => onFontSizeChange(-1)}
|
||||||
|
type="button"
|
||||||
|
aria-label="Decrease font size"
|
||||||
|
>
|
||||||
|
A-
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="terminal-header-button"
|
||||||
|
onClick={() => onFontSizeChange(1)}
|
||||||
|
type="button"
|
||||||
|
aria-label="Increase font size"
|
||||||
|
>
|
||||||
|
A+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="terminal-header-button"
|
||||||
|
onClick={onShowResetConfirm}
|
||||||
|
type="button"
|
||||||
|
aria-label="Reset terminal"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="terminal-close"
|
||||||
|
onClick={onToggleFullscreen}
|
||||||
|
type="button"
|
||||||
|
title="Exit fullscreen (Esc)"
|
||||||
|
>
|
||||||
|
Exit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showResetConfirm && (
|
||||||
|
<div className="terminal-reset-confirm">
|
||||||
|
<div className="terminal-reset-confirm-content">
|
||||||
|
<p>
|
||||||
|
Reset terminal? This will kill the current shell session and
|
||||||
|
start fresh.
|
||||||
|
</p>
|
||||||
|
<div className="terminal-reset-confirm-buttons">
|
||||||
|
<button
|
||||||
|
className="terminal-reset-confirm-button cancel"
|
||||||
|
onClick={onHideResetConfirm}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="terminal-reset-confirm-button confirm"
|
||||||
|
onClick={() => {
|
||||||
|
onHideResetConfirm();
|
||||||
|
onReset();
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TerminalSessionTabs
|
||||||
|
sessions={sessionInfos}
|
||||||
|
activeSessionId={activeSessionId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onClose={onClose}
|
||||||
|
onCreate={onCreate}
|
||||||
|
onRename={onRename}
|
||||||
|
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={() => onClose(session.id)}
|
||||||
|
isMobile={false}
|
||||||
|
showControls={!isFullscreen}
|
||||||
|
onTerminalReady={onTerminalReady}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { TerminalComponent, type TerminalRef } from "./terminal";
|
||||||
|
import { TerminalSessionTabs, type TerminalSessionInfo } from "./terminal-session-tabs";
|
||||||
|
import { Icon } from "../../icon";
|
||||||
|
import { SpecialKeysStrip } from "./special-keys-strip";
|
||||||
|
import { SpecialKeysPanel } from "./special-keys-panel";
|
||||||
|
import type { ModifierKey } from "../../../hooks/use-special-keys";
|
||||||
|
import type { TerminalSession } from "../../../api/terminal";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
instanceId: string;
|
||||||
|
sessions: TerminalSession[];
|
||||||
|
sessionInfos: TerminalSessionInfo[];
|
||||||
|
activeSessionId: string;
|
||||||
|
terminalRefs: React.MutableRefObject<Record<string, React.RefObject<TerminalRef>>>;
|
||||||
|
status: string;
|
||||||
|
error: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
isKeyboardOpen: boolean;
|
||||||
|
keyboardHeight: number;
|
||||||
|
isVisible: boolean;
|
||||||
|
activeModifier: ModifierKey | null;
|
||||||
|
showSpecialKeysPanel: boolean;
|
||||||
|
onToggleHeader: () => void;
|
||||||
|
onNavigateBack: () => void;
|
||||||
|
onFontSizeChange: (delta: number) => void;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onClose: (id: string) => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
onRename: (id: string, name: string) => void;
|
||||||
|
onTerminalReady: (
|
||||||
|
sendData: (data: string) => void,
|
||||||
|
status: "connecting" | "connected" | "disconnected" | "error" | "resetting",
|
||||||
|
focusInput: () => void,
|
||||||
|
changeFontSize: (delta: number) => void,
|
||||||
|
) => void;
|
||||||
|
onSendKey: (data: string) => void;
|
||||||
|
onModifierChange: (mod: ModifierKey | null) => void;
|
||||||
|
onShowSpecialKeys: () => void;
|
||||||
|
onHideSpecialKeys: () => void;
|
||||||
|
onKeepFocus: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileTerminalView: React.FC<Props> = ({
|
||||||
|
instanceId,
|
||||||
|
sessions,
|
||||||
|
sessionInfos,
|
||||||
|
activeSessionId,
|
||||||
|
terminalRefs,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
isKeyboardOpen,
|
||||||
|
keyboardHeight,
|
||||||
|
isVisible,
|
||||||
|
activeModifier,
|
||||||
|
showSpecialKeysPanel,
|
||||||
|
onToggleHeader,
|
||||||
|
onNavigateBack,
|
||||||
|
onFontSizeChange,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
onCreate,
|
||||||
|
onRename,
|
||||||
|
onTerminalReady,
|
||||||
|
onSendKey,
|
||||||
|
onModifierChange,
|
||||||
|
onShowSpecialKeys,
|
||||||
|
onHideSpecialKeys,
|
||||||
|
onKeepFocus,
|
||||||
|
}) => {
|
||||||
|
const activeSession = sessions.find((s) => s.id === activeSessionId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="terminal-page mobile">
|
||||||
|
<div className={`mobile-terminal-overlay ${isVisible ? "visible" : "hidden"}`} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="mobile-terminal-toolbar">
|
||||||
|
<div className="mobile-terminal-toolbar-left">
|
||||||
|
<button className="mobile-terminal-toolbtn" onClick={onNavigateBack} type="button" aria-label="Back">
|
||||||
|
<Icon name="arrow-left" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-terminal-toolbar-center">
|
||||||
|
<span className="mobile-terminal-title">{activeSession?.name || "Terminal"}</span>
|
||||||
|
<span className={`mobile-terminal-status status-dot ${status}`} aria-label={`Connection status: ${status}`} />
|
||||||
|
</div>
|
||||||
|
<div className="mobile-terminal-toolbar-right">
|
||||||
|
<button className="mobile-terminal-toolbtn" onClick={() => onFontSizeChange(-1)} type="button" aria-label="Decrease font size">
|
||||||
|
<span style={{ fontSize: "0.75rem" }}>A-</span>
|
||||||
|
</button>
|
||||||
|
<button className="mobile-terminal-toolbtn" onClick={() => onFontSizeChange(1)} type="button" aria-label="Increase font size">
|
||||||
|
<span style={{ fontSize: "1rem" }}>A+</span>
|
||||||
|
</button>
|
||||||
|
<button className="mobile-terminal-toolbtn" onClick={onNavigateBack} type="button" aria-label="Exit terminal">
|
||||||
|
<Icon name="close" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-terminal-overlay-tabs">
|
||||||
|
<TerminalSessionTabs
|
||||||
|
sessions={sessionInfos}
|
||||||
|
activeSessionId={activeSessionId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onClose={onClose}
|
||||||
|
onCreate={onCreate}
|
||||||
|
onRename={onRename}
|
||||||
|
isMobile={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="terminal-page-content mobile-full"
|
||||||
|
style={{ paddingBottom: isKeyboardOpen ? keyboardHeight : 0 }}
|
||||||
|
onClick={onToggleHeader}
|
||||||
|
>
|
||||||
|
{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={() => onClose(session.id)}
|
||||||
|
isMobile={true}
|
||||||
|
showControls={false}
|
||||||
|
activeModifier={activeModifier}
|
||||||
|
onModifierChange={onModifierChange}
|
||||||
|
onTerminalReady={onTerminalReady}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sessions.length === 0 && !loading && (
|
||||||
|
<div className="terminal-empty-state">
|
||||||
|
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SpecialKeysStrip
|
||||||
|
onSend={onSendKey}
|
||||||
|
isVisible={!showSpecialKeysPanel}
|
||||||
|
onMoreClick={onShowSpecialKeys}
|
||||||
|
onKeepFocus={onKeepFocus}
|
||||||
|
activeModifier={activeModifier}
|
||||||
|
onModifierChange={onModifierChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SpecialKeysPanel
|
||||||
|
onSend={onSendKey}
|
||||||
|
isOpen={showSpecialKeysPanel}
|
||||||
|
onClose={onHideSpecialKeys}
|
||||||
|
onKeepFocus={onKeepFocus}
|
||||||
|
activeModifier={activeModifier}
|
||||||
|
onModifierChange={onModifierChange}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import type { TerminalRef } from "../components/features/terminal/terminal";
|
||||||
|
import type { TerminalSessionInfo } from "../components/features/terminal/terminal-session-tabs";
|
||||||
|
import { useMobileViewport } from "./use-mobile-viewport";
|
||||||
|
import { useAutoHide } from "./use-auto-hide";
|
||||||
|
import { useVirtualKeyboard } from "./use-virtual-keyboard";
|
||||||
|
import { useTerminalSessions } from "./use-terminal-sessions";
|
||||||
|
import type { TerminalSession } from "../api/terminal";
|
||||||
|
import type { ModifierKey } from "./use-special-keys";
|
||||||
|
|
||||||
|
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
|
||||||
|
sessions.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
status: s.status as TerminalSessionInfo["status"],
|
||||||
|
}));
|
||||||
|
|
||||||
|
type TerminalStatus =
|
||||||
|
| "connecting"
|
||||||
|
| "connected"
|
||||||
|
| "disconnected"
|
||||||
|
| "error"
|
||||||
|
| "resetting";
|
||||||
|
|
||||||
|
export const useTerminalPage = () => {
|
||||||
|
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 [terminalStatuses, setTerminalStatuses] = useState<
|
||||||
|
Record<string, TerminalStatus>
|
||||||
|
>({});
|
||||||
|
const changeFontSizeRef = useRef<((delta: number) => void) | null>(null);
|
||||||
|
const sendDataRef = useRef<((data: string) => void) | null>(null);
|
||||||
|
const focusInputRef = useRef<(() => void) | null>(null);
|
||||||
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
|
const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false);
|
||||||
|
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(null);
|
||||||
|
const { isOpen: isKeyboardOpen, height: keyboardHeight } = useVirtualKeyboard();
|
||||||
|
|
||||||
|
const {
|
||||||
|
sessions,
|
||||||
|
activeSessionId,
|
||||||
|
setActiveSessionId,
|
||||||
|
createSession,
|
||||||
|
closeSession,
|
||||||
|
renameSession,
|
||||||
|
resetSession,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
} = useTerminalSessions(instanceId ?? "");
|
||||||
|
|
||||||
|
// Auto-create default session
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && sessions.length === 0 && !error && instanceId) {
|
||||||
|
void createSession("Session 1");
|
||||||
|
}
|
||||||
|
}, [loading, sessions.length, error, instanceId, createSession]);
|
||||||
|
|
||||||
|
// Sync refs with sessions
|
||||||
|
useEffect(() => {
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (!terminalRefs.current[session.id]) {
|
||||||
|
terminalRefs.current[session.id] = React.createRef<TerminalRef>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
||||||
|
const ref = terminalRefs.current[activeSessionId];
|
||||||
|
let raf1 = 0;
|
||||||
|
let raf2 = 0;
|
||||||
|
raf1 = requestAnimationFrame(() => {
|
||||||
|
raf2 = requestAnimationFrame(() => {
|
||||||
|
ref.current?.fit();
|
||||||
|
ref.current?.focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf1);
|
||||||
|
cancelAnimationFrame(raf2);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [
|
||||||
|
sessions,
|
||||||
|
activeSessionId,
|
||||||
|
createSession,
|
||||||
|
closeSession,
|
||||||
|
resetSession,
|
||||||
|
setActiveSessionId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Keep screen awake
|
||||||
|
useEffect(() => {
|
||||||
|
let wakeLock: WakeLockSentinel | null = null;
|
||||||
|
const requestWakeLock = async () => {
|
||||||
|
try {
|
||||||
|
if ("wakeLock" in navigator) {
|
||||||
|
wakeLock = await navigator.wakeLock.request("screen");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void requestWakeLock();
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === "visible") void requestWakeLock();
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
wakeLock?.release().catch(() => {});
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Lock page scroll on mobile
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobile) return;
|
||||||
|
document.documentElement.classList.add("terminal-page-open");
|
||||||
|
document.body.classList.add("terminal-page-open");
|
||||||
|
return () => {
|
||||||
|
document.documentElement.classList.remove("terminal-page-open");
|
||||||
|
document.body.classList.remove("terminal-page-open");
|
||||||
|
};
|
||||||
|
}, [isMobile]);
|
||||||
|
|
||||||
|
const handleFullscreenClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLElement>) => {
|
||||||
|
if (!isFullscreen) return;
|
||||||
|
const target = e.target as Node;
|
||||||
|
const current = e.currentTarget as HTMLElement;
|
||||||
|
const content = current.querySelector(".terminal-page-content");
|
||||||
|
const header = current.querySelector(".terminal-fullscreen-header");
|
||||||
|
if (content?.contains(target) || header?.contains(target)) return;
|
||||||
|
setIsFullscreen(false);
|
||||||
|
},
|
||||||
|
[isFullscreen],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(sessionId: string) => setActiveSessionId(sessionId),
|
||||||
|
[setActiveSessionId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClose = useCallback(
|
||||||
|
async (sessionId: string) => 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],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTerminalReady = useCallback(
|
||||||
|
(
|
||||||
|
sendData: (data: string) => void,
|
||||||
|
status: TerminalStatus,
|
||||||
|
focusInput: () => void,
|
||||||
|
changeFontSize: (delta: number) => void,
|
||||||
|
) => {
|
||||||
|
setTerminalStatuses((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[activeSessionId ?? "default"]: status,
|
||||||
|
}));
|
||||||
|
sendDataRef.current = sendData;
|
||||||
|
focusInputRef.current = focusInput;
|
||||||
|
changeFontSizeRef.current = changeFontSize;
|
||||||
|
},
|
||||||
|
[activeSessionId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFontSizeChange = useCallback((delta: number) => {
|
||||||
|
changeFontSizeRef.current?.(delta);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSendKey = useCallback((data: string) => {
|
||||||
|
sendDataRef.current?.(data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
||||||
|
terminalRefs.current[activeSessionId].current?.reset();
|
||||||
|
}
|
||||||
|
}, [activeSessionId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
instanceId,
|
||||||
|
navigate,
|
||||||
|
isMobile,
|
||||||
|
isFullscreen,
|
||||||
|
setIsFullscreen,
|
||||||
|
terminalRefs,
|
||||||
|
headerAutoHide,
|
||||||
|
terminalStatuses,
|
||||||
|
sendDataRef,
|
||||||
|
focusInputRef,
|
||||||
|
changeFontSizeRef,
|
||||||
|
showResetConfirm,
|
||||||
|
setShowResetConfirm,
|
||||||
|
showSpecialKeysPanel,
|
||||||
|
setShowSpecialKeysPanel,
|
||||||
|
activeModifier,
|
||||||
|
setActiveModifier,
|
||||||
|
isKeyboardOpen,
|
||||||
|
keyboardHeight,
|
||||||
|
sessions,
|
||||||
|
activeSessionId,
|
||||||
|
setActiveSessionId,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
handleFullscreenClick,
|
||||||
|
handleSelect,
|
||||||
|
handleClose,
|
||||||
|
handleCreate,
|
||||||
|
handleRename,
|
||||||
|
handleTerminalReady,
|
||||||
|
handleFontSizeChange,
|
||||||
|
handleSendKey,
|
||||||
|
handleReset,
|
||||||
|
sessionInfos: SESSIONS_TO_INFO(sessions),
|
||||||
|
};
|
||||||
|
};
|
||||||
+105
-564
@@ -1,571 +1,112 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useTerminalPage } from "../hooks/use-terminal-page";
|
||||||
import { TerminalComponent, type TerminalRef } from "../components/features/terminal/terminal";
|
import { MobileTerminalView } from "../components/features/terminal/MobileTerminalView";
|
||||||
import {
|
import { DesktopTerminalView } from "../components/features/terminal/DesktopTerminalView";
|
||||||
TerminalSessionTabs,
|
|
||||||
type TerminalSessionInfo,
|
|
||||||
} from "../components/features/terminal/terminal-session-tabs";
|
|
||||||
import { Icon } from "../components/icon";
|
|
||||||
import { SpecialKeysStrip } from "../components/features/terminal/special-keys-strip";
|
|
||||||
import { SpecialKeysPanel } from "../components/features/terminal/special-keys-panel";
|
|
||||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
|
||||||
import { useAutoHide } from "../hooks/use-auto-hide";
|
|
||||||
import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard";
|
|
||||||
import { useTerminalSessions } from "../hooks/use-terminal-sessions";
|
|
||||||
import type { TerminalSession } from "../api/terminal";
|
|
||||||
import type { ModifierKey } from "../hooks/use-special-keys";
|
|
||||||
|
|
||||||
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
|
|
||||||
sessions.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
name: s.name,
|
|
||||||
status: s.status as TerminalSessionInfo["status"],
|
|
||||||
}));
|
|
||||||
|
|
||||||
type TerminalStatus =
|
|
||||||
| "connecting"
|
|
||||||
| "connected"
|
|
||||||
| "disconnected"
|
|
||||||
| "error"
|
|
||||||
| "resetting";
|
|
||||||
|
|
||||||
export const TerminalPage: React.FC = () => {
|
export const TerminalPage: React.FC = () => {
|
||||||
const { instanceId } = useParams<{
|
const {
|
||||||
instanceId: string;
|
instanceId,
|
||||||
}>();
|
navigate,
|
||||||
const navigate = useNavigate();
|
isMobile,
|
||||||
const isMobile = useMobileViewport();
|
isFullscreen,
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
setIsFullscreen,
|
||||||
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
|
terminalRefs,
|
||||||
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
|
headerAutoHide,
|
||||||
|
terminalStatuses,
|
||||||
|
showResetConfirm,
|
||||||
|
setShowResetConfirm,
|
||||||
|
showSpecialKeysPanel,
|
||||||
|
setShowSpecialKeysPanel,
|
||||||
|
activeModifier,
|
||||||
|
setActiveModifier,
|
||||||
|
isKeyboardOpen,
|
||||||
|
keyboardHeight,
|
||||||
|
sessions,
|
||||||
|
activeSessionId,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
handleFullscreenClick,
|
||||||
|
handleSelect,
|
||||||
|
handleClose,
|
||||||
|
handleCreate,
|
||||||
|
handleRename,
|
||||||
|
handleTerminalReady,
|
||||||
|
handleFontSizeChange,
|
||||||
|
handleSendKey,
|
||||||
|
handleReset,
|
||||||
|
sessionInfos,
|
||||||
|
} = useTerminalPage();
|
||||||
|
|
||||||
// Track terminal status and callbacks for unified fullscreen header
|
if (!instanceId) {
|
||||||
const [terminalStatuses, setTerminalStatuses] = useState<
|
return (
|
||||||
Record<string, TerminalStatus>
|
<section className="stack">
|
||||||
>({});
|
<h1>Terminal</h1>
|
||||||
const changeFontSizeRef = useRef<((delta: number) => void) | null>(null);
|
<p className="muted">No instance ID provided.</p>
|
||||||
const sendDataRef = useRef<((data: string) => void) | null>(null);
|
</section>
|
||||||
const focusInputRef = useRef<(() => void) | null>(null);
|
);
|
||||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
}
|
||||||
const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false);
|
|
||||||
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
|
|
||||||
useVirtualKeyboard();
|
|
||||||
|
|
||||||
const {
|
const status = terminalStatuses[activeSessionId ?? "default"] ?? "connecting";
|
||||||
sessions,
|
|
||||||
activeSessionId,
|
|
||||||
setActiveSessionId,
|
|
||||||
createSession,
|
|
||||||
closeSession,
|
|
||||||
renameSession,
|
|
||||||
resetSession,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
} = useTerminalSessions(instanceId ?? "");
|
|
||||||
|
|
||||||
// Auto-create default session if none exist after loading completes
|
if (isMobile) {
|
||||||
useEffect(() => {
|
return (
|
||||||
if (!loading && sessions.length === 0 && !error && instanceId) {
|
<MobileTerminalView
|
||||||
void createSession("Session 1");
|
instanceId={instanceId}
|
||||||
}
|
sessions={sessions}
|
||||||
}, [loading, sessions.length, error, instanceId, createSession]);
|
sessionInfos={sessionInfos}
|
||||||
|
activeSessionId={activeSessionId ?? ""}
|
||||||
|
terminalRefs={terminalRefs}
|
||||||
|
status={status}
|
||||||
|
error={error}
|
||||||
|
loading={loading}
|
||||||
|
isKeyboardOpen={isKeyboardOpen}
|
||||||
|
keyboardHeight={keyboardHeight}
|
||||||
|
isVisible={headerAutoHide.isVisible}
|
||||||
|
activeModifier={activeModifier}
|
||||||
|
showSpecialKeysPanel={showSpecialKeysPanel}
|
||||||
|
onToggleHeader={headerAutoHide.toggle}
|
||||||
|
onNavigateBack={() => navigate("/sessions")}
|
||||||
|
onFontSizeChange={handleFontSizeChange}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onClose={handleClose}
|
||||||
|
onCreate={handleCreate}
|
||||||
|
onRename={handleRename}
|
||||||
|
onTerminalReady={handleTerminalReady}
|
||||||
|
onSendKey={handleSendKey}
|
||||||
|
onModifierChange={setActiveModifier}
|
||||||
|
onShowSpecialKeys={() => setShowSpecialKeysPanel(true)}
|
||||||
|
onHideSpecialKeys={() => setShowSpecialKeysPanel(false)}
|
||||||
|
onKeepFocus={() => {
|
||||||
|
/* focus handled by ref */
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure refs map is kept in sync with sessions
|
return (
|
||||||
useEffect(() => {
|
<DesktopTerminalView
|
||||||
for (const session of sessions) {
|
instanceId={instanceId}
|
||||||
if (!terminalRefs.current[session.id]) {
|
sessions={sessions}
|
||||||
terminalRefs.current[session.id] = React.createRef<TerminalRef>();
|
sessionInfos={sessionInfos}
|
||||||
}
|
activeSessionId={activeSessionId ?? ""}
|
||||||
}
|
terminalRefs={terminalRefs}
|
||||||
// Clean up refs for closed sessions
|
isFullscreen={isFullscreen}
|
||||||
const currentIds = new Set(sessions.map((s) => s.id));
|
status={status}
|
||||||
for (const id of Object.keys(terminalRefs.current)) {
|
error={error}
|
||||||
if (!currentIds.has(id)) {
|
loading={loading}
|
||||||
delete terminalRefs.current[id];
|
showResetConfirm={showResetConfirm}
|
||||||
}
|
onFullscreenClick={handleFullscreenClick}
|
||||||
}
|
onSelect={handleSelect}
|
||||||
}, [sessions]);
|
onClose={handleClose}
|
||||||
|
onCreate={handleCreate}
|
||||||
// Fit and focus active terminal when switching tabs
|
onRename={handleRename}
|
||||||
useEffect(() => {
|
onNavigateBack={() => navigate("/sessions")}
|
||||||
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
onToggleFullscreen={() => setIsFullscreen((p) => !p)}
|
||||||
const ref = terminalRefs.current[activeSessionId];
|
onFontSizeChange={handleFontSizeChange}
|
||||||
// Double rAF ensures layout has settled after the display:block switch
|
onShowResetConfirm={() => setShowResetConfirm(true)}
|
||||||
let raf1 = 0;
|
onHideResetConfirm={() => setShowResetConfirm(false)}
|
||||||
let raf2 = 0;
|
onReset={handleReset}
|
||||||
raf1 = requestAnimationFrame(() => {
|
onTerminalReady={handleTerminalReady}
|
||||||
raf2 = requestAnimationFrame(() => {
|
/>
|
||||||
ref.current?.fit();
|
);
|
||||||
ref.current?.focus();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(raf1);
|
|
||||||
cancelAnimationFrame(raf2);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [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,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Keep screen awake while terminal is open
|
|
||||||
useEffect(() => {
|
|
||||||
let wakeLock: WakeLockSentinel | null = null;
|
|
||||||
|
|
||||||
const requestWakeLock = async () => {
|
|
||||||
try {
|
|
||||||
if ("wakeLock" in navigator) {
|
|
||||||
wakeLock = await navigator.wakeLock.request("screen");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Wake lock may be denied; silently ignore
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void requestWakeLock();
|
|
||||||
|
|
||||||
const handleVisibilityChange = () => {
|
|
||||||
if (document.visibilityState === "visible") {
|
|
||||||
void requestWakeLock();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
||||||
wakeLock?.release().catch(() => {});
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Lock page scroll on mobile terminal so swipes scroll the terminal buffer,
|
|
||||||
// not the page.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isMobile) return;
|
|
||||||
document.documentElement.classList.add("terminal-page-open");
|
|
||||||
document.body.classList.add("terminal-page-open");
|
|
||||||
return () => {
|
|
||||||
document.documentElement.classList.remove("terminal-page-open");
|
|
||||||
document.body.classList.remove("terminal-page-open");
|
|
||||||
};
|
|
||||||
}, [isMobile]);
|
|
||||||
|
|
||||||
// Click outside terminal content/header to exit fullscreen
|
|
||||||
const handleFullscreenClick = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLElement>) => {
|
|
||||||
if (!isFullscreen) return;
|
|
||||||
const target = e.target as Node;
|
|
||||||
const current = e.currentTarget as HTMLElement;
|
|
||||||
const content = current.querySelector(".terminal-page-content");
|
|
||||||
const header = current.querySelector(".terminal-fullscreen-header");
|
|
||||||
if (content?.contains(target) || header?.contains(target)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsFullscreen(false);
|
|
||||||
},
|
|
||||||
[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],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTerminalReady = useCallback(
|
|
||||||
(
|
|
||||||
sendData: (data: string) => void,
|
|
||||||
status: TerminalStatus,
|
|
||||||
focusInput: () => void,
|
|
||||||
changeFontSize: (delta: number) => void,
|
|
||||||
) => {
|
|
||||||
setTerminalStatuses((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[activeSessionId ?? "default"]: status,
|
|
||||||
}));
|
|
||||||
sendDataRef.current = sendData;
|
|
||||||
focusInputRef.current = focusInput;
|
|
||||||
changeFontSizeRef.current = changeFontSize;
|
|
||||||
},
|
|
||||||
[activeSessionId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFontSizeChange = useCallback((delta: number) => {
|
|
||||||
changeFontSizeRef.current?.(delta);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSendKey = useCallback((data: string) => {
|
|
||||||
sendDataRef.current?.(data);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleReset = useCallback(() => {
|
|
||||||
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
|
||||||
terminalRefs.current[activeSessionId].current?.reset();
|
|
||||||
}
|
|
||||||
}, [activeSessionId]);
|
|
||||||
|
|
||||||
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) {
|
|
||||||
const activeSession = sessions.find((s) => s.id === activeSessionId);
|
|
||||||
const status =
|
|
||||||
terminalStatuses[activeSessionId ?? "default"] ?? "connecting";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className={`terminal-page mobile ${isFullscreen ? "fullscreen" : ""}`}
|
|
||||||
>
|
|
||||||
{/* Overlay status bar — floats over terminal, never resizes it */}
|
|
||||||
<div
|
|
||||||
className={`mobile-terminal-overlay ${headerAutoHide.isVisible ? "visible" : "hidden"}`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="mobile-terminal-toolbar">
|
|
||||||
<div className="mobile-terminal-toolbar-left">
|
|
||||||
<button
|
|
||||||
className="mobile-terminal-toolbtn"
|
|
||||||
onClick={() => navigate("/sessions")}
|
|
||||||
type="button"
|
|
||||||
aria-label="Back"
|
|
||||||
>
|
|
||||||
<Icon name="arrow-left" size="sm" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mobile-terminal-toolbar-center">
|
|
||||||
<span className="mobile-terminal-title">
|
|
||||||
{activeSession?.name || "Terminal"}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`mobile-terminal-status status-dot ${status}`}
|
|
||||||
aria-label={`Connection status: ${status}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mobile-terminal-toolbar-right">
|
|
||||||
<button
|
|
||||||
className="mobile-terminal-toolbtn"
|
|
||||||
onClick={() => handleFontSizeChange(-1)}
|
|
||||||
type="button"
|
|
||||||
aria-label="Decrease font size"
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: "0.75rem" }}>A-</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="mobile-terminal-toolbtn"
|
|
||||||
onClick={() => handleFontSizeChange(1)}
|
|
||||||
type="button"
|
|
||||||
aria-label="Increase font size"
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: "1rem" }}>A+</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="mobile-terminal-toolbtn"
|
|
||||||
onClick={() => navigate("/sessions")}
|
|
||||||
type="button"
|
|
||||||
aria-label="Exit terminal"
|
|
||||||
>
|
|
||||||
<Icon name="close" size="sm" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mobile-terminal-overlay-tabs">
|
|
||||||
<TerminalSessionTabs
|
|
||||||
sessions={sessionInfos}
|
|
||||||
activeSessionId={activeSessionId ?? ""}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
onClose={handleClose}
|
|
||||||
onCreate={handleCreate}
|
|
||||||
onRename={handleRename}
|
|
||||||
isMobile={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Terminal content — always fills full viewport */}
|
|
||||||
<div
|
|
||||||
className="terminal-page-content mobile-full"
|
|
||||||
style={{ paddingBottom: isKeyboardOpen ? keyboardHeight : 0 }}
|
|
||||||
onClick={() => headerAutoHide.toggle()}
|
|
||||||
>
|
|
||||||
{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}
|
|
||||||
showControls={false}
|
|
||||||
activeModifier={activeModifier}
|
|
||||||
onModifierChange={setActiveModifier}
|
|
||||||
onTerminalReady={handleTerminalReady}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{sessions.length === 0 && !loading && (
|
|
||||||
<div className="terminal-empty-state">
|
|
||||||
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SpecialKeysStrip
|
|
||||||
onSend={handleSendKey}
|
|
||||||
isVisible={!showSpecialKeysPanel}
|
|
||||||
onMoreClick={() => setShowSpecialKeysPanel(true)}
|
|
||||||
onKeepFocus={() => focusInputRef.current?.()}
|
|
||||||
activeModifier={activeModifier}
|
|
||||||
onModifierChange={setActiveModifier}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SpecialKeysPanel
|
|
||||||
onSend={handleSendKey}
|
|
||||||
isOpen={showSpecialKeysPanel}
|
|
||||||
onClose={() => setShowSpecialKeysPanel(false)}
|
|
||||||
onKeepFocus={() => focusInputRef.current?.()}
|
|
||||||
activeModifier={activeModifier}
|
|
||||||
onModifierChange={setActiveModifier}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className={`terminal-page ${isFullscreen ? "fullscreen" : ""}`}
|
|
||||||
onClick={handleFullscreenClick}
|
|
||||||
>
|
|
||||||
{!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>
|
|
||||||
)}
|
|
||||||
{isFullscreen ? (
|
|
||||||
<div className="terminal-fullscreen-header">
|
|
||||||
<div className="terminal-fullscreen-header-tabs">
|
|
||||||
<TerminalSessionTabs
|
|
||||||
sessions={sessionInfos}
|
|
||||||
activeSessionId={activeSessionId ?? ""}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
onClose={handleClose}
|
|
||||||
onCreate={handleCreate}
|
|
||||||
onRename={handleRename}
|
|
||||||
isMobile={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="terminal-fullscreen-header-controls">
|
|
||||||
<span
|
|
||||||
className={`terminal-fullscreen-status status-dot ${terminalStatuses[activeSessionId ?? "default"] ?? "connecting"}`}
|
|
||||||
aria-label={`Terminal status: ${terminalStatuses[activeSessionId ?? "default"] ?? "connecting"}`}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="terminal-header-button"
|
|
||||||
onClick={() => handleFontSizeChange(-1)}
|
|
||||||
type="button"
|
|
||||||
aria-label="Decrease font size"
|
|
||||||
>
|
|
||||||
A-
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="terminal-header-button"
|
|
||||||
onClick={() => handleFontSizeChange(1)}
|
|
||||||
type="button"
|
|
||||||
aria-label="Increase font size"
|
|
||||||
>
|
|
||||||
A+
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="terminal-header-button"
|
|
||||||
onClick={() => setShowResetConfirm(true)}
|
|
||||||
type="button"
|
|
||||||
aria-label="Reset terminal"
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="terminal-close"
|
|
||||||
onClick={() => setIsFullscreen(false)}
|
|
||||||
type="button"
|
|
||||||
title="Exit fullscreen (Esc)"
|
|
||||||
>
|
|
||||||
Exit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{showResetConfirm && (
|
|
||||||
<div className="terminal-reset-confirm">
|
|
||||||
<div className="terminal-reset-confirm-content">
|
|
||||||
<p>
|
|
||||||
Reset terminal? This will kill the current shell session and
|
|
||||||
start fresh.
|
|
||||||
</p>
|
|
||||||
<div className="terminal-reset-confirm-buttons">
|
|
||||||
<button
|
|
||||||
className="terminal-reset-confirm-button cancel"
|
|
||||||
onClick={() => setShowResetConfirm(false)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="terminal-reset-confirm-button confirm"
|
|
||||||
onClick={() => {
|
|
||||||
setShowResetConfirm(false);
|
|
||||||
handleReset();
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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}
|
|
||||||
showControls={!isFullscreen}
|
|
||||||
onTerminalReady={handleTerminalReady}
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user