mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
0ac351b41a
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.
989 lines
32 KiB
TypeScript
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;
|