Files
Netcatty/components/Terminal.tsx
T
LAPTOP-O016UC3M\Qi Chen 0ac351b41a Eliminates white flash by deferring window show until paint
Defers Electron window display until the renderer signals first
meaningful paint, avoiding initial blank screens or white flashes,
especially on startup and settings windows. Implements a handshake
using a renderer-to-main process message, color-matches background
to the current theme, and falls back to a timeout in development.

Also migrates font loading to npm packages, replaces Google Fonts
usage, and adds code-splitting for dialogs and quick switcher to
improve performance. Restricts console debug output to development.
2025-12-18 17:41:38 +08:00

989 lines
32 KiB
TypeScript

import { Terminal as XTerm } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { SerializeAddon } from "@xterm/addon-serialize";
import { SearchAddon } from "@xterm/addon-search";
import "@xterm/xterm/css/xterm.css";
import { Maximize2, Radio } from "lucide-react";
import React, { memo, useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { logger } from "../lib/logger";
import { cn } from "../lib/utils";
import {
Host,
Identity,
KnownHost,
SSHKey,
Snippet,
TerminalSession,
TerminalTheme,
TerminalSettings,
KeyBinding,
} from "../types";
import { resolveHostAuth } from "../domain/sshAuth";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
import SFTPModal from "./SFTPModal";
import { Button } from "./ui/button";
import { toast } from "./ui/toast";
import { TERMINAL_FONTS } from "../infrastructure/config/fonts";
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
import { TerminalConnectionDialog } from "./terminal/TerminalConnectionDialog";
import { TerminalToolbar } from "./terminal/TerminalToolbar";
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
import { createHighlightProcessor } from "./terminal/keywordHighlight";
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextActions";
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
interface TerminalProps {
host: Host;
keys: SSHKey[];
identities: Identity[];
snippets: Snippet[];
allHosts?: Host[];
knownHosts?: KnownHost[];
isVisible: boolean;
inWorkspace?: boolean;
isResizing?: boolean;
isFocusMode?: boolean;
isFocused?: boolean;
fontFamilyId: string;
fontSize: number;
terminalTheme: TerminalTheme;
terminalSettings?: TerminalSettings;
sessionId: string;
startupCommand?: string;
onUpdateTerminalThemeId?: (themeId: string) => void;
onUpdateTerminalFontFamilyId?: (fontFamilyId: string) => void;
onUpdateTerminalFontSize?: (fontSize: number) => void;
hotkeyScheme?: "disabled" | "mac" | "pc";
keyBindings?: KeyBinding[];
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
onStatusChange?: (sessionId: string, status: TerminalSession["status"]) => void;
onSessionExit?: (sessionId: string) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onOsDetected?: (hostId: string, distro: string) => void;
onCloseSession?: (sessionId: string) => void;
onUpdateHost?: (host: Host) => void;
onAddKnownHost?: (knownHost: KnownHost) => void;
onExpandToFocus?: () => void;
onCommandExecuted?: (
command: string,
hostId: string,
hostLabel: string,
sessionId: string,
) => void;
onSplitHorizontal?: () => void;
onSplitVertical?: () => void;
isBroadcastEnabled?: boolean;
onToggleBroadcast?: () => void;
onBroadcastInput?: (data: string, sourceSessionId: string) => void;
}
const TerminalComponent: React.FC<TerminalProps> = ({
host,
keys,
identities,
snippets,
allHosts = [],
knownHosts: _knownHosts = [],
isVisible,
inWorkspace,
isResizing,
isFocusMode,
isFocused,
fontFamilyId,
fontSize,
terminalTheme,
terminalSettings,
sessionId,
startupCommand,
onUpdateTerminalThemeId,
onUpdateTerminalFontFamilyId,
onUpdateTerminalFontSize,
hotkeyScheme = "disabled",
keyBindings = [],
onHotkeyAction,
onStatusChange,
onSessionExit,
onTerminalDataCapture,
onOsDetected,
onCloseSession,
onUpdateHost,
onAddKnownHost,
onExpandToFocus,
onCommandExecuted,
onSplitHorizontal,
onSplitVertical,
isBroadcastEnabled,
onToggleBroadcast,
onBroadcastInput,
}) => {
const CONNECTION_TIMEOUT = 12000;
const { t } = useI18n();
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const serializeAddonRef = useRef<SerializeAddon | null>(null);
const searchAddonRef = useRef<SearchAddon | null>(null);
const xtermRuntimeRef = useRef<XTermRuntime | null>(null);
const disposeDataRef = useRef<(() => void) | null>(null);
const disposeExitRef = useRef<(() => void) | null>(null);
const sessionRef = useRef<string | null>(null);
const hasConnectedRef = useRef(false);
const hasRunStartupCommandRef = useRef(false);
const commandBufferRef = useRef<string>("");
const terminalSettingsRef = useRef(terminalSettings);
terminalSettingsRef.current = terminalSettings;
const highlightProcessorRef = useRef<(text: string) => string>((t) => t);
useEffect(() => {
highlightProcessorRef.current = createHighlightProcessor(
terminalSettings?.keywordHighlightRules ?? [],
terminalSettings?.keywordHighlightEnabled ?? false,
);
}, [terminalSettings?.keywordHighlightEnabled, terminalSettings?.keywordHighlightRules]);
const hotkeySchemeRef = useRef(hotkeyScheme);
const keyBindingsRef = useRef(keyBindings);
const onHotkeyActionRef = useRef(onHotkeyAction);
hotkeySchemeRef.current = hotkeyScheme;
keyBindingsRef.current = keyBindings;
onHotkeyActionRef.current = onHotkeyAction;
const isBroadcastEnabledRef = useRef(isBroadcastEnabled);
const onBroadcastInputRef = useRef(onBroadcastInput);
isBroadcastEnabledRef.current = isBroadcastEnabled;
onBroadcastInputRef.current = onBroadcastInput;
const terminalBackend = useTerminalBackend();
const { resizeSession } = terminalBackend;
const [isScriptsOpen, setIsScriptsOpen] = useState(false);
const [status, setStatus] = useState<TerminalSession["status"]>("connecting");
const [error, setError] = useState<string | null>(null);
const lastToastedErrorRef = useRef<string | null>(null);
const [showLogs, setShowLogs] = useState(false);
const [progressLogs, setProgressLogs] = useState<string[]>([]);
const [timeLeft, setTimeLeft] = useState(CONNECTION_TIMEOUT / 1000);
const [isCancelling, setIsCancelling] = useState(false);
const [showSFTP, setShowSFTP] = useState(false);
const [progressValue, setProgressValue] = useState(15);
const [hasSelection, setHasSelection] = useState(false);
const statusRef = useRef<TerminalSession["status"]>(status);
statusRef.current = status;
const [chainProgress, setChainProgress] = useState<{
currentHop: number;
totalHops: number;
currentHostLabel: string;
} | null>(null);
const terminalSearch = useTerminalSearch({ searchAddonRef, termRef });
const {
isSearchOpen,
setIsSearchOpen,
searchMatchCount,
handleToggleSearch,
handleSearch,
handleFindNext,
handleFindPrevious,
handleCloseSearch,
} = terminalSearch;
useEffect(() => {
if (!error) {
lastToastedErrorRef.current = null;
return;
}
if (lastToastedErrorRef.current === error) return;
lastToastedErrorRef.current = error;
toast.error(error, t("terminal.connectionErrorTitle"));
}, [error, t]);
const pendingAuthRef = useRef<PendingAuth>(null);
const sessionStartersRef = useRef<ReturnType<typeof createTerminalSessionStarters> | null>(null);
const auth = useTerminalAuthState({
host,
pendingAuthRef,
termRef,
onUpdateHost,
onStartSsh: (term) => {
sessionStartersRef.current?.startSSH(term);
},
setStatus: (next) => setStatus(next),
setProgressLogs,
});
const [needsHostKeyVerification, setNeedsHostKeyVerification] = useState(false);
const [pendingHostKeyInfo, setPendingHostKeyInfo] = useState<HostKeyInfo | null>(null);
const pendingConnectionRef = useRef<(() => void) | null>(null);
const effectiveTheme = useMemo(() => {
if (host.theme) {
const hostTheme = TERMINAL_THEMES.find((t) => t.id === host.theme);
if (hostTheme) return hostTheme;
}
return terminalTheme;
}, [host.theme, terminalTheme]);
const resolvedChainHosts =
(host.hostChain?.hostIds
?.map((id) => allHosts.find((h) => h.id === id))
.filter(Boolean) as Host[]) || [];
const updateStatus = (next: TerminalSession["status"]) => {
setStatus(next);
hasConnectedRef.current = next === "connected";
onStatusChange?.(sessionId, next);
};
const cleanupSession = () => {
disposeDataRef.current?.();
disposeDataRef.current = null;
disposeExitRef.current?.();
disposeExitRef.current = null;
if (sessionRef.current) {
try {
terminalBackend.closeSession(sessionRef.current);
} catch (err) {
logger.warn("Failed to close SSH session", err);
}
}
sessionRef.current = null;
};
const teardown = () => {
cleanupSession();
xtermRuntimeRef.current?.dispose();
xtermRuntimeRef.current = null;
termRef.current = null;
fitAddonRef.current = null;
serializeAddonRef.current = null;
searchAddonRef.current = null;
};
const sessionStarters = createTerminalSessionStarters({
host,
keys,
identities,
resolvedChainHosts,
sessionId,
startupCommand,
terminalSettings,
terminalBackend,
sessionRef,
hasConnectedRef,
hasRunStartupCommandRef,
disposeDataRef,
disposeExitRef,
fitAddonRef,
serializeAddonRef,
highlightProcessorRef,
pendingAuthRef,
updateStatus,
setStatus,
setError,
setNeedsAuth: auth.setNeedsAuth,
setAuthRetryMessage: auth.setAuthRetryMessage,
setAuthPassword: auth.setAuthPassword,
setProgressLogs,
setProgressValue,
setChainProgress,
onSessionExit,
onTerminalDataCapture,
onOsDetected,
onCommandExecuted,
});
sessionStartersRef.current = sessionStarters;
useEffect(() => {
let disposed = false;
setError(null);
hasConnectedRef.current = false;
setProgressLogs([]);
setShowLogs(false);
setIsCancelling(false);
const boot = async () => {
try {
if (disposed || !containerRef.current) return;
const runtime = createXTermRuntime({
container: containerRef.current,
host,
fontFamilyId,
fontSize,
terminalTheme: effectiveTheme,
terminalSettingsRef,
terminalBackend,
sessionRef,
hotkeySchemeRef,
keyBindingsRef,
onHotkeyActionRef,
isBroadcastEnabledRef,
onBroadcastInputRef,
sessionId,
statusRef,
onCommandExecuted,
commandBufferRef,
setIsSearchOpen,
});
xtermRuntimeRef.current = runtime;
termRef.current = runtime.term;
fitAddonRef.current = runtime.fitAddon;
serializeAddonRef.current = runtime.serializeAddon;
searchAddonRef.current = runtime.searchAddon;
const term = runtime.term;
if (host.protocol === "local" || host.hostname === "localhost") {
setStatus("connecting");
setProgressLogs(["Initializing local shell..."]);
await sessionStarters.startLocal(term);
} else if (host.protocol === "telnet") {
setStatus("connecting");
setProgressLogs(["Initializing Telnet connection..."]);
await sessionStarters.startTelnet(term);
} else if (host.moshEnabled) {
setStatus("connecting");
setProgressLogs(["Initializing Mosh connection..."]);
await sessionStarters.startMosh(term);
} else {
const resolvedAuth = resolveHostAuth({ host, keys, identities });
const hasPassword = !!resolvedAuth.password;
const hasKey = !!resolvedAuth.keyId;
const hasPendingAuth = pendingAuthRef.current;
if (
!hasPassword &&
!hasKey &&
!hasPendingAuth &&
!resolvedAuth.username
) {
auth.setNeedsAuth(true);
setStatus("disconnected");
return;
}
setStatus("connecting");
setProgressLogs(["Initializing secure channel..."]);
await sessionStarters.startSSH(term);
}
} catch (err) {
logger.error("Failed to initialize terminal", err);
setError(err instanceof Error ? err.message : String(err));
updateStatus("disconnected");
}
};
boot();
return () => {
disposed = true;
if (onTerminalDataCapture && serializeAddonRef.current) {
try {
const terminalData = serializeAddonRef.current.serialize();
logger.info("[Terminal] Capturing data on unmount", { sessionId, dataLength: terminalData.length });
onTerminalDataCapture(sessionId, terminalData);
} catch (err) {
logger.warn("Failed to serialize terminal data on unmount:", err);
}
}
teardown();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- Effect only runs on host.id/sessionId change, internal functions are stable
}, [host.id, sessionId]);
// Connection timeline and timeout visuals
useEffect(() => {
if (status !== "connecting" || auth.needsAuth) return;
const scripted = [
"Resolving host and keys...",
"Negotiating ciphers...",
"Exchanging keys...",
"Authenticating user...",
"Waiting for server greeting...",
];
let idx = 0;
const stepTimer = setInterval(() => {
setProgressLogs((prev) => {
if (idx >= scripted.length) return prev;
const next = scripted[idx++];
return prev.includes(next) ? prev : [...prev, next];
});
}, 900);
setTimeLeft(CONNECTION_TIMEOUT / 1000);
const countdown = setInterval(() => {
setTimeLeft((prev) => (prev > 0 ? prev - 1 : 0));
}, 1000);
const timeout = setTimeout(() => {
setError("Connection timed out. Please try again.");
updateStatus("disconnected");
setProgressLogs((prev) => [...prev, "Connection timed out."]);
}, CONNECTION_TIMEOUT);
setProgressValue(5);
const prog = setInterval(() => {
setProgressValue((prev) => {
if (prev >= 95) return prev;
const remaining = 95 - prev;
const increment = Math.max(1, remaining * 0.15);
return Math.min(95, prev + increment);
});
}, 200);
return () => {
clearInterval(stepTimer);
clearInterval(countdown);
clearTimeout(timeout);
clearInterval(prog);
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- updateStatus is a stable internal helper
}, [status, auth.needsAuth]);
const safeFit = () => {
const fitAddon = fitAddonRef.current;
if (!fitAddon) return;
const runFit = () => {
try {
fitAddon.fit();
} catch (err) {
logger.warn("Fit failed", err);
}
};
if (
XTERM_PERFORMANCE_CONFIG.resize.useRAF &&
typeof requestAnimationFrame === "function"
) {
requestAnimationFrame(runFit);
} else {
runFit();
}
};
useEffect(() => {
if (termRef.current) {
const effectiveFontSize = host.fontSize || fontSize;
termRef.current.options.fontSize = effectiveFontSize;
termRef.current.options.theme = {
...effectiveTheme.colors,
selectionBackground: effectiveTheme.colors.selection,
};
if (terminalSettings) {
termRef.current.options.cursorStyle = terminalSettings.cursorShape;
termRef.current.options.cursorBlink = terminalSettings.cursorBlink;
termRef.current.options.scrollback = terminalSettings.scrollback;
termRef.current.options.fontWeight = terminalSettings.fontWeight as
| 100
| 200
| 300
| 400
| 500
| 600
| 700
| 800
| 900;
termRef.current.options.fontWeightBold = terminalSettings.fontWeightBold as
| 100
| 200
| 300
| 400
| 500
| 600
| 700
| 800
| 900;
termRef.current.options.lineHeight = 1 + terminalSettings.linePadding / 10;
termRef.current.options.drawBoldTextInBrightColors =
terminalSettings.drawBoldInBrightColors;
termRef.current.options.minimumContrastRatio =
terminalSettings.minimumContrastRatio;
termRef.current.options.scrollOnUserInput = terminalSettings.scrollOnInput;
termRef.current.options.altClickMovesCursor = !terminalSettings.altAsMeta;
termRef.current.options.wordSeparator = terminalSettings.wordSeparators;
}
setTimeout(() => safeFit(), 50);
}
}, [fontSize, effectiveTheme, terminalSettings, host.fontSize]);
useEffect(() => {
if (termRef.current) {
const effectiveFontSize = host.fontSize || fontSize;
termRef.current.options.fontSize = effectiveFontSize;
const hostFontId = host.fontFamily || fontFamilyId || "menlo";
const fontObj = TERMINAL_FONTS.find((f) => f.id === hostFontId) || TERMINAL_FONTS[0];
termRef.current.options.fontFamily = fontObj.family;
termRef.current.options.theme = {
...effectiveTheme.colors,
selectionBackground: effectiveTheme.colors.selection,
};
setTimeout(() => safeFit(), 50);
}
}, [host.fontSize, host.fontFamily, host.theme, fontFamilyId, fontSize, effectiveTheme]);
useEffect(() => {
if (isVisible && fitAddonRef.current) {
const timer = setTimeout(() => safeFit(), 50);
return () => clearTimeout(timer);
}
}, [isVisible]);
useEffect(() => {
let cancelled = false;
const waitForFonts = async () => {
try {
const fontFaceSet = document.fonts as FontFaceSet | undefined;
if (!fontFaceSet?.ready) return;
await fontFaceSet.ready;
if (cancelled) return;
const term = termRef.current as {
cols: number;
rows: number;
renderer?: { remeasureFont?: () => void };
} | null;
const fitAddon = fitAddonRef.current;
try {
term?.renderer?.remeasureFont?.();
} catch (err) {
logger.warn("Font remeasure failed", err);
}
try {
fitAddon?.fit();
} catch (err) {
logger.warn("Fit after fonts ready failed", err);
}
const id = sessionRef.current;
if (id && term) {
try {
resizeSession(id, term.cols, term.rows);
} catch (err) {
logger.warn("Resize session after fonts ready failed", err);
}
}
} catch (err) {
logger.warn("Waiting for fonts failed", err);
}
};
waitForFonts();
return () => {
cancelled = true;
};
}, [host.id, sessionId, resizeSession]);
useEffect(() => {
if (!containerRef.current || !fitAddonRef.current) return;
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
const observer = new ResizeObserver(() => {
if (isResizing) return;
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
resizeTimeout = setTimeout(() => {
safeFit();
}, 250);
});
observer.observe(containerRef.current);
return () => {
if (resizeTimeout) clearTimeout(resizeTimeout);
observer.disconnect();
};
}, [isVisible, isResizing]);
const prevIsResizingRef = useRef(isResizing);
useEffect(() => {
if (prevIsResizingRef.current && !isResizing && isVisible) {
const timer = setTimeout(() => {
safeFit();
}, 100);
return () => clearTimeout(timer);
}
prevIsResizingRef.current = isResizing;
}, [isResizing, isVisible]);
useEffect(() => {
if (!isVisible || !fitAddonRef.current) return;
const timer = setTimeout(() => {
safeFit();
}, 100);
return () => clearTimeout(timer);
}, [inWorkspace, isVisible]);
useEffect(() => {
const shouldAutoFocus = isVisible && termRef.current && (!inWorkspace || isFocusMode);
if (shouldAutoFocus) {
const timer = setTimeout(() => {
termRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
}
}, [isVisible, inWorkspace, isFocusMode]);
useEffect(() => {
if (isFocused && termRef.current && isVisible) {
const timer = setTimeout(() => {
termRef.current?.focus();
}, 10);
return () => clearTimeout(timer);
}
}, [isFocused, isVisible, sessionId]);
useEffect(() => {
const term = termRef.current;
if (!term) return;
const onSelectionChange = () => {
const selection = term.getSelection();
const hasText = !!selection && selection.length > 0;
setHasSelection(hasText);
if (hasText && terminalSettings?.copyOnSelect) {
navigator.clipboard.writeText(selection).catch((err) => {
logger.warn("Copy on select failed:", err);
});
}
};
term.onSelectionChange(onSelectionChange);
}, [terminalSettings?.copyOnSelect]);
useEffect(() => {
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
const handler = () => {
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
resizeTimeout = setTimeout(() => {
safeFit();
}, 250);
};
window.addEventListener("resize", handler);
return () => {
if (resizeTimeout) clearTimeout(resizeTimeout);
window.removeEventListener("resize", handler);
};
}, []);
const terminalContextActions = useTerminalContextActions({
termRef,
sessionRef,
terminalBackend,
onHasSelectionChange: setHasSelection,
});
const handleSnippetClick = (cmd: string) => {
if (sessionRef.current) {
terminalBackend.writeToSession(sessionRef.current, `${cmd}\r`);
setIsScriptsOpen(false);
termRef.current?.focus();
return;
}
termRef.current?.writeln("\r\n[No active SSH session]");
};
const handleCancelConnect = () => {
setIsCancelling(true);
auth.setNeedsAuth(false);
auth.setAuthRetryMessage(null);
setNeedsHostKeyVerification(false);
setPendingHostKeyInfo(null);
setError("Connection cancelled");
setProgressLogs((prev) => [...prev, "Cancelled by user."]);
cleanupSession();
updateStatus("disconnected");
setChainProgress(null);
setTimeout(() => setIsCancelling(false), 600);
onCloseSession?.(sessionId);
};
const handleHostKeyClose = () => {
setNeedsHostKeyVerification(false);
setPendingHostKeyInfo(null);
handleCancelConnect();
};
const handleHostKeyContinue = () => {
setNeedsHostKeyVerification(false);
if (pendingConnectionRef.current) {
pendingConnectionRef.current();
pendingConnectionRef.current = null;
}
setPendingHostKeyInfo(null);
};
const handleHostKeyAddAndContinue = () => {
if (pendingHostKeyInfo && onAddKnownHost) {
const newKnownHost: KnownHost = {
id: `kh-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
hostname: pendingHostKeyInfo.hostname,
port: pendingHostKeyInfo.port || host.port || 22,
keyType: pendingHostKeyInfo.keyType,
publicKey: pendingHostKeyInfo.fingerprint,
discoveredAt: Date.now(),
};
onAddKnownHost(newKnownHost);
}
setNeedsHostKeyVerification(false);
if (pendingConnectionRef.current) {
pendingConnectionRef.current();
pendingConnectionRef.current = null;
}
setPendingHostKeyInfo(null);
};
const handleRetry = () => {
if (!termRef.current) return;
cleanupSession();
auth.resetForRetry();
setStatus("connecting");
setError(null);
setProgressLogs(["Retrying secure channel..."]);
setShowLogs(true);
if (host.protocol === "local" || host.hostname === "localhost") {
sessionStarters.startLocal(termRef.current);
} else {
sessionStarters.startSSH(termRef.current);
}
};
const renderControls = (opts?: { showClose?: boolean }) => (
<TerminalToolbar
status={status}
snippets={snippets}
host={host}
defaultThemeId={terminalTheme.id}
defaultFontFamilyId={fontFamilyId}
defaultFontSize={fontSize}
onUpdateTerminalThemeId={onUpdateTerminalThemeId}
onUpdateTerminalFontFamilyId={onUpdateTerminalFontFamilyId}
onUpdateTerminalFontSize={onUpdateTerminalFontSize}
isScriptsOpen={isScriptsOpen}
setIsScriptsOpen={setIsScriptsOpen}
onOpenSFTP={() => setShowSFTP((v) => !v)}
onSnippetClick={handleSnippetClick}
onUpdateHost={onUpdateHost}
showClose={opts?.showClose}
onClose={() => onCloseSession?.(sessionId)}
isSearchOpen={isSearchOpen}
onToggleSearch={handleToggleSearch}
/>
);
const statusDotTone =
status === "connected"
? "bg-emerald-400"
: status === "connecting"
? "bg-amber-400"
: "bg-rose-500";
const _isConnecting = status === "connecting";
const _hasError = Boolean(error);
return (
<TerminalContextMenu
hasSelection={hasSelection}
hotkeyScheme={hotkeyScheme}
rightClickBehavior={terminalSettings?.rightClickBehavior}
onCopy={terminalContextActions.onCopy}
onPaste={terminalContextActions.onPaste}
onSelectAll={terminalContextActions.onSelectAll}
onClear={terminalContextActions.onClear}
onSelectWord={terminalContextActions.onSelectWord}
onSplitHorizontal={onSplitHorizontal}
onSplitVertical={onSplitVertical}
onClose={inWorkspace ? () => onCloseSession?.(sessionId) : undefined}
>
<div className="relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]">
<div className="absolute left-0 right-0 top-0 z-20 pointer-events-none">
<div className="flex items-center gap-1 px-2 py-1 bg-black/55 text-white backdrop-blur-md pointer-events-auto min-w-0">
<div className="flex-1 min-w-0 flex items-center gap-1 text-[11px] font-semibold">
<span
className={cn(
"truncate",
inWorkspace ? "max-w-[80px]" : "max-w-[200px]",
)}
>
{host.label}
</span>
<span
className={cn(
"inline-block h-2 w-2 rounded-full flex-shrink-0",
statusDotTone,
)}
/>
</div>
<div className="flex items-center gap-0.5 flex-shrink-0">
{inWorkspace && onToggleBroadcast && (
<Button
variant="ghost"
size="sm"
className={cn(
"h-6 w-6 p-0 hover:bg-white/10",
isBroadcastEnabled
? "text-emerald-400 hover:text-emerald-300"
: "text-white/70 hover:text-white",
)}
onClick={onToggleBroadcast}
title={
isBroadcastEnabled
? "Disable Broadcast Mode"
: "Enable Broadcast Mode"
}
>
<Radio size={12} />
</Button>
)}
{inWorkspace && !isFocusMode && onExpandToFocus && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-white/70 hover:text-white hover:bg-white/10"
onClick={onExpandToFocus}
title="Focus Mode"
>
<Maximize2 size={12} />
</Button>
)}
{renderControls({ showClose: inWorkspace })}
</div>
</div>
{isSearchOpen && (
<div className="pointer-events-auto">
<TerminalSearchBar
isOpen={isSearchOpen}
onClose={handleCloseSearch}
onSearch={handleSearch}
onFindNext={handleFindNext}
onFindPrevious={handleFindPrevious}
matchCount={searchMatchCount}
/>
</div>
)}
</div>
<div
className="h-full flex-1 min-w-0 transition-all duration-300 relative overflow-hidden pt-8"
style={{ backgroundColor: effectiveTheme.colors.background }}
>
<div
ref={containerRef}
className="absolute inset-x-0 bottom-0"
style={{
top: isSearchOpen ? "64px" : "40px",
paddingLeft: 6,
backgroundColor: effectiveTheme.colors.background,
}}
/>
{needsHostKeyVerification && pendingHostKeyInfo && (
<div className="absolute inset-0 z-30 bg-background">
<KnownHostConfirmDialog
host={host}
hostKeyInfo={pendingHostKeyInfo}
onClose={handleHostKeyClose}
onContinue={handleHostKeyContinue}
onAddAndContinue={handleHostKeyAddAndContinue}
/>
</div>
)}
{status !== "connected" && !needsHostKeyVerification && (
<TerminalConnectionDialog
host={host}
status={status}
error={error}
progressValue={progressValue}
chainProgress={chainProgress}
needsAuth={auth.needsAuth}
showLogs={showLogs}
_setShowLogs={setShowLogs}
keys={keys}
authProps={{
authMethod: auth.authMethod,
setAuthMethod: auth.setAuthMethod,
authUsername: auth.authUsername,
setAuthUsername: auth.setAuthUsername,
authPassword: auth.authPassword,
setAuthPassword: auth.setAuthPassword,
authKeyId: auth.authKeyId,
setAuthKeyId: auth.setAuthKeyId,
authPassphrase: auth.authPassphrase,
setAuthPassphrase: auth.setAuthPassphrase,
showAuthPassphrase: auth.showAuthPassphrase,
setShowAuthPassphrase: auth.setShowAuthPassphrase,
showAuthPassword: auth.showAuthPassword,
setShowAuthPassword: auth.setShowAuthPassword,
authRetryMessage: auth.authRetryMessage,
onSubmit: () => auth.submit(),
onSubmitWithoutSave: () => auth.submit({ saveToHost: false }),
onCancel: handleCancelConnect,
isValid: auth.isValid,
}}
progressProps={{
timeLeft,
isCancelling,
progressLogs,
onCancel: handleCancelConnect,
onRetry: handleRetry,
}}
/>
)}
</div>
<SFTPModal
host={host}
credentials={(() => {
const resolvedAuth = resolveHostAuth({ host, keys, identities });
return {
username: resolvedAuth.username,
hostname: host.hostname,
port: host.port,
password: resolvedAuth.password,
privateKey: resolvedAuth.key?.privateKey,
certificate: resolvedAuth.key?.certificate,
passphrase: resolvedAuth.passphrase,
publicKey: resolvedAuth.key?.publicKey,
keyId: resolvedAuth.keyId,
keySource: resolvedAuth.key?.source,
};
})()}
open={showSFTP && status === "connected"}
onClose={() => setShowSFTP(false)}
/>
</div>
</TerminalContextMenu>
);
};
const Terminal = memo(TerminalComponent);
Terminal.displayName = "Terminal";
export default Terminal;