Files
Netcatty/components/Terminal.tsx
T
LAPTOP-O016UC3M\Qi Chen c6fdfc0123 Adds main-process cloud sync support with detailed errors
Enables network operations for cloud sync providers (WebDAV, S3)
to be proxied via the main process, improving reliability and
support for Electron environments.

Improves error reporting for cloud sync connections with
detailed, context-rich error messages in the UI to assist
debugging and user support.

Refines hotkey debug logging, only enabling verbose output
when explicitly requested.

Relates to user-reported sync reliability and error clarity.
2025-12-22 18:15:50 +08:00

1000 lines
33 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-0.5 backdrop-blur-md pointer-events-auto min-w-0 border-b-[0.5px]"
style={{
backgroundColor: effectiveTheme.colors.background,
color: effectiveTheme.colors.foreground,
borderColor: `color-mix(in srgb, ${effectiveTheme.colors.foreground} 8%, ${effectiveTheme.colors.background} 92%)`,
['--terminal-toolbar-fg' as never]: effectiveTheme.colors.foreground,
['--terminal-toolbar-bg' as never]: effectiveTheme.colors.background,
['--terminal-toolbar-btn' as never]: `color-mix(in srgb, ${effectiveTheme.colors.background} 88%, ${effectiveTheme.colors.foreground} 12%)`,
['--terminal-toolbar-btn-hover' as never]: `color-mix(in srgb, ${effectiveTheme.colors.background} 78%, ${effectiveTheme.colors.foreground} 22%)`,
['--terminal-toolbar-btn-active' as never]: `color-mix(in srgb, ${effectiveTheme.colors.background} 68%, ${effectiveTheme.colors.foreground} 32%)`,
}}
>
<div className="flex items-center gap-1 text-[11px] font-semibold">
<span className="whitespace-nowrap">{host.label}</span>
<span
className={cn(
"inline-block h-2 w-2 rounded-full flex-shrink-0",
statusDotTone,
)}
/>
</div>
<div className="flex-1" />
<div className="flex items-center gap-0.5 flex-shrink-0">
{inWorkspace && onToggleBroadcast && (
<Button
variant="secondary"
size="icon"
className={cn(
"h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)]",
"bg-transparent hover:bg-transparent",
isBroadcastEnabled && "text-green-500",
)}
onClick={onToggleBroadcast}
title={
isBroadcastEnabled
? t("terminal.toolbar.broadcastDisable")
: t("terminal.toolbar.broadcastEnable")
}
aria-label={
isBroadcastEnabled
? t("terminal.toolbar.broadcastDisable")
: t("terminal.toolbar.broadcastEnable")
}
>
<Radio size={12} />
</Button>
)}
{inWorkspace && !isFocusMode && onExpandToFocus && (
<Button
variant="secondary"
size="icon"
className="h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent"
onClick={onExpandToFocus}
title={t("terminal.toolbar.focusMode")}
aria-label={t("terminal.toolbar.focusMode")}
>
<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;