Replaces all direct usage of browser globals and infrastructure service imports in UI components with dedicated application/state backend hooks. Introduces lint rules to prevent direct access to backend bridges and localStorage from components, promoting a cleaner separation of concerns and improved maintainability. Moves user preferences (e.g., port forwarding form mode) to persistent state hooks, updates port forwarding and SFTP logic to rely on backend hooks, and centralizes logging through a logger utility. Cleans up debug code and removes obsolete scripts from HTML. Improves testability, prepares for alternative backend implementations, and enforces architectural boundaries.
1937 lines
67 KiB
TypeScript
1937 lines
67 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 { WebglAddon } from "@xterm/addon-webgl";
|
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
|
import "@xterm/xterm/css/xterm.css";
|
|
import { Maximize2 } from "lucide-react";
|
|
import React, { memo, useEffect, useMemo, useRef, useState, useCallback } from "react";
|
|
import { logger } from "../lib/logger";
|
|
import { cn } from "../lib/utils";
|
|
import {
|
|
Host,
|
|
KnownHost,
|
|
SSHKey,
|
|
Snippet,
|
|
TerminalSession,
|
|
TerminalTheme,
|
|
TerminalSettings,
|
|
KeyBinding,
|
|
} from "../types";
|
|
import { checkAppShortcut, getAppLevelActions, getTerminalPassthroughActions } from "../application/state/useGlobalHotkeys";
|
|
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
|
import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
|
|
import SFTPModal from "./SFTPModal";
|
|
import { Button } from "./ui/button";
|
|
import { TERMINAL_FONTS } from "../infrastructure/config/fonts";
|
|
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
|
|
|
|
// Import terminal sub-components
|
|
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 {
|
|
XTERM_PERFORMANCE_CONFIG,
|
|
type XTermPlatform,
|
|
resolveXTermPerformanceConfig,
|
|
} from "../infrastructure/config/xtermPerformance";
|
|
|
|
interface TerminalProps {
|
|
host: Host;
|
|
keys: SSHKey[];
|
|
snippets: Snippet[];
|
|
allHosts?: Host[]; // All hosts for chain resolution
|
|
knownHosts?: KnownHost[]; // Known hosts for verification
|
|
isVisible: boolean;
|
|
inWorkspace?: boolean;
|
|
isResizing?: boolean;
|
|
isFocusMode?: boolean; // Whether workspace is in focus mode
|
|
isFocused?: boolean; // Whether this terminal should have keyboard focus (for split views)
|
|
fontSize: number;
|
|
terminalTheme: TerminalTheme;
|
|
terminalSettings?: TerminalSettings; // Global terminal settings
|
|
sessionId: string;
|
|
startupCommand?: string; // Command to run after connection (for snippet runner)
|
|
// Hotkey configuration
|
|
hotkeyScheme?: 'disabled' | 'mac' | 'pc';
|
|
keyBindings?: KeyBinding[];
|
|
// Hotkey action callbacks
|
|
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
|
|
onStatusChange?: (
|
|
sessionId: string,
|
|
status: TerminalSession["status"],
|
|
) => void;
|
|
onSessionExit?: (sessionId: string) => void;
|
|
onOsDetected?: (hostId: string, distro: string) => void;
|
|
onCloseSession?: (sessionId: string) => void;
|
|
onUpdateHost?: (host: Host) => void;
|
|
onAddKnownHost?: (knownHost: KnownHost) => void; // Callback to add host to known hosts
|
|
onExpandToFocus?: () => void; // Callback to switch workspace to focus mode
|
|
onCommandExecuted?: (
|
|
command: string,
|
|
hostId: string,
|
|
hostLabel: string,
|
|
sessionId: string,
|
|
) => void; // Callback when a command is executed
|
|
// Split actions
|
|
onSplitHorizontal?: () => void;
|
|
onSplitVertical?: () => void;
|
|
}
|
|
|
|
// xterm.js doesn't need async initialization like ghostty-web
|
|
|
|
const TerminalComponent: React.FC<TerminalProps> = ({
|
|
host,
|
|
keys,
|
|
snippets,
|
|
allHosts = [],
|
|
knownHosts: _knownHosts = [], // Reserved for future host key verification UI
|
|
isVisible,
|
|
inWorkspace,
|
|
isResizing,
|
|
isFocusMode,
|
|
isFocused,
|
|
fontSize,
|
|
terminalTheme,
|
|
terminalSettings,
|
|
sessionId,
|
|
startupCommand,
|
|
hotkeyScheme = 'disabled',
|
|
keyBindings = [],
|
|
onHotkeyAction,
|
|
onStatusChange,
|
|
onSessionExit,
|
|
onOsDetected,
|
|
onCloseSession,
|
|
onUpdateHost,
|
|
onAddKnownHost,
|
|
onExpandToFocus,
|
|
onCommandExecuted,
|
|
onSplitHorizontal,
|
|
onSplitVertical,
|
|
}) => {
|
|
const CONNECTION_TIMEOUT = 12000;
|
|
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 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); // Track if startup command has been executed
|
|
const commandBufferRef = useRef<string>(""); // Buffer for tracking typed commands
|
|
|
|
// Ref to store latest terminalSettings for use in boot function (avoids stale closure)
|
|
const terminalSettingsRef = useRef(terminalSettings);
|
|
terminalSettingsRef.current = terminalSettings;
|
|
|
|
// Keyword highlight processor - processes terminal output for keyword highlighting
|
|
const highlightProcessorRef = useRef<(text: string) => string>((t) => t);
|
|
|
|
// Update highlight processor when settings change
|
|
useEffect(() => {
|
|
highlightProcessorRef.current = createHighlightProcessor(
|
|
terminalSettings?.keywordHighlightRules ?? [],
|
|
terminalSettings?.keywordHighlightEnabled ?? false
|
|
);
|
|
}, [terminalSettings?.keywordHighlightEnabled, terminalSettings?.keywordHighlightRules]);
|
|
|
|
// Refs to store latest hotkey config for use in keyboard handler
|
|
const hotkeySchemeRef = useRef(hotkeyScheme);
|
|
const keyBindingsRef = useRef(keyBindings);
|
|
const onHotkeyActionRef = useRef(onHotkeyAction);
|
|
hotkeySchemeRef.current = hotkeyScheme;
|
|
keyBindingsRef.current = keyBindings;
|
|
onHotkeyActionRef.current = onHotkeyAction;
|
|
|
|
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 [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);
|
|
|
|
// Search state
|
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
|
const [searchMatchCount, setSearchMatchCount] = useState<{ current: number; total: number } | null>(null);
|
|
const searchTermRef = useRef<string>(''); // Store current search term for findNext/findPrevious
|
|
|
|
// Chain connection progress state
|
|
const [chainProgress, setChainProgress] = useState<{
|
|
currentHop: number;
|
|
totalHops: number;
|
|
currentHostLabel: string;
|
|
} | null>(null);
|
|
|
|
// Auth dialog state for hosts without credentials
|
|
const [needsAuth, setNeedsAuth] = useState(false);
|
|
const [authRetryMessage, setAuthRetryMessage] = useState<string | null>(null); // Error message for auth retry
|
|
const [authUsername, setAuthUsername] = useState(host.username || "root");
|
|
const [authMethod, setAuthMethod] = useState<"password" | "key">("password");
|
|
const [authPassword, setAuthPassword] = useState("");
|
|
const [authKeyId, setAuthKeyId] = useState<string | null>(null);
|
|
const [showAuthPassword, setShowAuthPassword] = useState(false);
|
|
const [saveCredentials, setSaveCredentials] = useState(true);
|
|
|
|
// Pending connection credentials (set after auth dialog submit)
|
|
const pendingAuthRef = useRef<{
|
|
username: string;
|
|
password?: string;
|
|
keyId?: string;
|
|
} | null>(null);
|
|
|
|
// Known host verification state
|
|
const [needsHostKeyVerification, setNeedsHostKeyVerification] =
|
|
useState(false);
|
|
const [pendingHostKeyInfo, setPendingHostKeyInfo] =
|
|
useState<HostKeyInfo | null>(null);
|
|
const pendingConnectionRef = useRef<(() => void) | null>(null);
|
|
|
|
// Calculate effective theme: host-specific theme takes priority over global
|
|
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]);
|
|
|
|
// Resolve host chain to actual host objects
|
|
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();
|
|
termRef.current?.dispose();
|
|
termRef.current = null;
|
|
fitAddonRef.current?.dispose();
|
|
fitAddonRef.current = null;
|
|
serializeAddonRef.current?.dispose();
|
|
serializeAddonRef.current = null;
|
|
searchAddonRef.current?.dispose();
|
|
searchAddonRef.current = null;
|
|
};
|
|
|
|
const runDistroDetection = async (key?: SSHKey) => {
|
|
if (!terminalBackend.execAvailable()) return;
|
|
try {
|
|
const res = await terminalBackend.execCommand({
|
|
hostname: host.hostname,
|
|
username: host.username || "root",
|
|
port: host.port || 22,
|
|
password: host.password, // Always include for fallback
|
|
privateKey: key?.privateKey,
|
|
command: "cat /etc/os-release 2>/dev/null || uname -a",
|
|
timeout: 8000,
|
|
});
|
|
const data = `${res.stdout || ""}\n${res.stderr || ""}`;
|
|
const idMatch = data.match(/ID=([\\w\\-]+)/i);
|
|
const distro = idMatch
|
|
? idMatch[1].replace(/"/g, "")
|
|
: (data.split(/\\s+/)[0] || "").toLowerCase();
|
|
if (distro) onOsDetected?.(host.id, distro);
|
|
} catch (err) {
|
|
logger.warn("OS probe failed", err);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
let disposed = false;
|
|
// Don't set status yet - will determine after checking auth requirements
|
|
setError(null);
|
|
hasConnectedRef.current = false;
|
|
setProgressLogs([]);
|
|
setShowLogs(false);
|
|
setIsCancelling(false);
|
|
|
|
const boot = async () => {
|
|
try {
|
|
if (disposed || !containerRef.current) return;
|
|
|
|
const platform: XTermPlatform = (() => {
|
|
if (
|
|
typeof process !== "undefined" &&
|
|
(process.platform === "darwin" ||
|
|
process.platform === "win32" ||
|
|
process.platform === "linux")
|
|
) {
|
|
return process.platform;
|
|
}
|
|
|
|
if (typeof navigator !== "undefined") {
|
|
const ua = navigator.userAgent.toLowerCase();
|
|
if (ua.includes("win")) return "win32";
|
|
if (ua.includes("linux")) return "linux";
|
|
}
|
|
|
|
return "darwin";
|
|
})();
|
|
|
|
const deviceMemoryGb =
|
|
typeof navigator !== "undefined" &&
|
|
typeof (navigator as { deviceMemory?: number }).deviceMemory === "number"
|
|
? (navigator as { deviceMemory?: number }).deviceMemory
|
|
: undefined;
|
|
|
|
const performanceConfig = resolveXTermPerformanceConfig({
|
|
platform,
|
|
deviceMemoryGb,
|
|
});
|
|
|
|
// Get font family from host config or use default fallback
|
|
const hostFontId = host.fontFamily || 'menlo';
|
|
const fontObj = TERMINAL_FONTS.find(f => f.id === hostFontId) || TERMINAL_FONTS[0];
|
|
const fontFamily = fontObj.family;
|
|
|
|
// Use host-specific font size if available, otherwise use the global fontSize prop
|
|
const effectiveFontSize = host.fontSize || fontSize;
|
|
|
|
// Apply terminal settings with defaults (use ref to get latest values)
|
|
const settings = terminalSettingsRef.current;
|
|
const cursorStyle = settings?.cursorShape ?? 'block';
|
|
const cursorBlink = settings?.cursorBlink ?? true;
|
|
const scrollback = settings?.scrollback ?? 10000;
|
|
const fontLigatures = settings?.fontLigatures ?? true;
|
|
const drawBoldTextInBrightColors = settings?.drawBoldInBrightColors ?? true;
|
|
const fontWeight = settings?.fontWeight ?? 400;
|
|
const fontWeightBold = settings?.fontWeightBold ?? 700;
|
|
// linePadding 0-10 maps to lineHeight 1.0-2.0
|
|
const lineHeight = 1 + (settings?.linePadding ?? 0) / 10;
|
|
const minimumContrastRatio = settings?.minimumContrastRatio ?? 1;
|
|
const scrollOnUserInput = settings?.scrollOnInput ?? true;
|
|
const altIsMeta = settings?.altAsMeta ?? false;
|
|
const wordSeparator = settings?.wordSeparators ?? " ()[]{}'\"";
|
|
|
|
const term = new XTerm({
|
|
...performanceConfig.options,
|
|
fontSize: effectiveFontSize,
|
|
fontFamily: fontFamily,
|
|
fontWeight: fontWeight as 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 'normal' | 'bold',
|
|
fontWeightBold: fontWeightBold as 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 'normal' | 'bold',
|
|
lineHeight,
|
|
cursorStyle,
|
|
cursorBlink,
|
|
scrollback,
|
|
allowProposedApi: fontLigatures, // Required for font ligatures
|
|
drawBoldTextInBrightColors,
|
|
minimumContrastRatio,
|
|
scrollOnUserInput,
|
|
altClickMovesCursor: !altIsMeta, // If altAsMeta is true, don't use alt for cursor movement
|
|
wordSeparator,
|
|
theme: {
|
|
...terminalTheme.colors,
|
|
selectionBackground: terminalTheme.colors.selection,
|
|
},
|
|
});
|
|
|
|
type MaybeRenderer = {
|
|
constructor?: { name?: string };
|
|
type?: string;
|
|
};
|
|
|
|
type IntrospectableTerminal = XTerm & {
|
|
_core?: {
|
|
_renderService?: {
|
|
_renderer?: MaybeRenderer;
|
|
};
|
|
};
|
|
options?: {
|
|
rendererType?: string;
|
|
};
|
|
};
|
|
|
|
const logRenderer = (attempt = 0) => {
|
|
// Peek into private renderer to tell if WebGL is active; stored on window for DevTools checks
|
|
const introspected = term as IntrospectableTerminal;
|
|
const renderer = introspected._core?._renderService?._renderer;
|
|
const candidates = [
|
|
renderer?.type,
|
|
renderer?.constructor?.name,
|
|
introspected.options?.rendererType,
|
|
];
|
|
const rendererName =
|
|
candidates.find((value) => typeof value === "string" && value.length > 0) ||
|
|
undefined;
|
|
const normalized = rendererName
|
|
? rendererName.toLowerCase().includes("webgl")
|
|
? "webgl"
|
|
: rendererName.toLowerCase().includes("canvas")
|
|
? "canvas"
|
|
: rendererName
|
|
: "unknown";
|
|
logger.info(`[XTerm] renderer=${normalized}`);
|
|
const scopedWindow = window as Window & { __xtermRenderer?: string };
|
|
scopedWindow.__xtermRenderer = normalized;
|
|
if (normalized === "unknown" && attempt < 3) {
|
|
setTimeout(() => logRenderer(attempt + 1), 150);
|
|
}
|
|
};
|
|
|
|
const fitAddon = new FitAddon();
|
|
term.loadAddon(fitAddon);
|
|
termRef.current = term;
|
|
fitAddonRef.current = fitAddon;
|
|
|
|
const serializeAddon = new SerializeAddon();
|
|
term.loadAddon(serializeAddon);
|
|
serializeAddonRef.current = serializeAddon;
|
|
|
|
// Load search addon for terminal text search
|
|
const searchAddon = new SearchAddon();
|
|
term.loadAddon(searchAddon);
|
|
searchAddonRef.current = searchAddon;
|
|
|
|
try {
|
|
term.open(containerRef.current);
|
|
|
|
// Load WebGL addon for GPU-accelerated rendering unless canvas is preferred for macOS/low-memory profiles
|
|
let webglLoaded = false;
|
|
const scopedWindow = window as Window & {
|
|
__xtermWebGLLoaded?: boolean;
|
|
__xtermRendererPreference?: string;
|
|
};
|
|
|
|
if (performanceConfig.useWebGLAddon) {
|
|
try {
|
|
// Try enabling the new glyph handler for faster glyph caching if supported by the addon version
|
|
const webglAddon = (() => {
|
|
const webglOptions: Record<string, unknown> = {
|
|
useCustomGlyphHandler: true,
|
|
};
|
|
try {
|
|
// xterm-addon-webgl >=0.18 supports this flag; older versions ignore/fail so we guard
|
|
const WebglCtor = WebglAddon as unknown as new (
|
|
options?: unknown,
|
|
) => WebglAddon;
|
|
return new WebglCtor(webglOptions);
|
|
} catch {
|
|
return new WebglAddon();
|
|
}
|
|
})();
|
|
webglAddon.onContextLoss(() => {
|
|
logger.warn("[XTerm] WebGL context loss detected, disposing addon");
|
|
webglAddon.dispose();
|
|
});
|
|
term.loadAddon(webglAddon);
|
|
webglLoaded = true;
|
|
} catch (webglErr) {
|
|
logger.warn(
|
|
"[XTerm] WebGL addon failed, using canvas renderer. Error:",
|
|
webglErr instanceof Error ? webglErr.message : webglErr,
|
|
);
|
|
// Canvas renderer will be used as fallback - it's actually faster on some Macs
|
|
}
|
|
} else {
|
|
logger.info(
|
|
"[XTerm] Skipping WebGL addon (canvas preferred for macOS profile or low-memory devices)",
|
|
);
|
|
}
|
|
|
|
// Store whether WebGL was successfully loaded for diagnostics
|
|
scopedWindow.__xtermWebGLLoaded = webglLoaded;
|
|
scopedWindow.__xtermRendererPreference = performanceConfig.preferCanvasRenderer ? "canvas" : "webgl";
|
|
|
|
// Load web links addon for clickable URLs
|
|
const webLinksAddon = new WebLinksAddon((event, uri) => {
|
|
// Check if the required modifier key is held (read from ref for real-time updates)
|
|
const currentLinkModifier = terminalSettingsRef.current?.linkModifier ?? 'none';
|
|
let shouldOpen = false;
|
|
switch (currentLinkModifier) {
|
|
case 'none':
|
|
shouldOpen = true;
|
|
break;
|
|
case 'ctrl':
|
|
shouldOpen = event.ctrlKey;
|
|
break;
|
|
case 'alt':
|
|
shouldOpen = event.altKey;
|
|
break;
|
|
case 'meta':
|
|
shouldOpen = event.metaKey;
|
|
break;
|
|
}
|
|
if (shouldOpen) {
|
|
// Open URL in default browser
|
|
if (terminalBackend.openExternalAvailable()) {
|
|
void terminalBackend.openExternal(uri);
|
|
} else {
|
|
window.open(uri, '_blank');
|
|
}
|
|
}
|
|
});
|
|
term.loadAddon(webLinksAddon);
|
|
|
|
logRenderer();
|
|
|
|
// Attach custom key event handler to intercept app-level shortcuts
|
|
// This allows keyboard shortcuts to work even when terminal is focused
|
|
// Always attach the handler - it will check the ref values dynamically
|
|
const appLevelActions = getAppLevelActions();
|
|
const terminalActions = getTerminalPassthroughActions();
|
|
|
|
term.attachCustomKeyEventHandler((e: KeyboardEvent) => {
|
|
// Handle Ctrl+F / Cmd+F for search regardless of hotkey settings
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'f' && e.type === 'keydown') {
|
|
e.preventDefault();
|
|
setIsSearchOpen(true);
|
|
return false;
|
|
}
|
|
|
|
// Read current values from refs
|
|
const currentScheme = hotkeySchemeRef.current;
|
|
const currentBindings = keyBindingsRef.current;
|
|
const hotkeyCallback = onHotkeyActionRef.current;
|
|
|
|
// Skip if hotkeys are disabled
|
|
if (currentScheme === 'disabled' || currentBindings.length === 0) {
|
|
return true; // Let xterm handle it
|
|
}
|
|
|
|
const isMac = currentScheme === 'mac';
|
|
|
|
// Check if this matches any of our shortcuts
|
|
const matched = checkAppShortcut(e, currentBindings, isMac);
|
|
if (!matched) return true; // Let xterm handle it
|
|
|
|
const { action } = matched;
|
|
|
|
// App-level actions: call the callback directly and prevent xterm from handling
|
|
if (appLevelActions.has(action)) {
|
|
e.preventDefault();
|
|
if (hotkeyCallback) {
|
|
hotkeyCallback(action, e);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Terminal-level actions: handle here
|
|
if (terminalActions.has(action)) {
|
|
e.preventDefault();
|
|
switch (action) {
|
|
case 'copy': {
|
|
const selection = term.getSelection();
|
|
if (selection) {
|
|
navigator.clipboard.writeText(selection);
|
|
}
|
|
break;
|
|
}
|
|
case 'paste': {
|
|
navigator.clipboard.readText().then((text) => {
|
|
const id = sessionRef.current;
|
|
if (id) terminalBackend.writeToSession(id, text);
|
|
});
|
|
break;
|
|
}
|
|
case 'selectAll': {
|
|
term.selectAll();
|
|
break;
|
|
}
|
|
case 'clearBuffer': {
|
|
term.clear();
|
|
break;
|
|
}
|
|
case 'searchTerminal': {
|
|
// Open search UI
|
|
setIsSearchOpen(true);
|
|
break;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
return true; // Let xterm handle other keys
|
|
});
|
|
|
|
// Add middle-click paste support
|
|
const middleClickPaste = settings?.middleClickPaste ?? true;
|
|
if (middleClickPaste && containerRef.current) {
|
|
const handleMiddleClick = async (e: MouseEvent) => {
|
|
if (e.button === 1) { // Middle mouse button
|
|
e.preventDefault();
|
|
try {
|
|
const text = await navigator.clipboard.readText();
|
|
if (text && sessionRef.current) {
|
|
terminalBackend.writeToSession(sessionRef.current, text);
|
|
}
|
|
} catch (err) {
|
|
logger.warn('[Terminal] Failed to paste from clipboard:', err);
|
|
}
|
|
}
|
|
};
|
|
containerRef.current.addEventListener('auxclick', handleMiddleClick);
|
|
// Store cleanup function
|
|
const container = containerRef.current;
|
|
const cleanup = () => container.removeEventListener('auxclick', handleMiddleClick);
|
|
// Add to disposal - we'll handle this in the main cleanup
|
|
const originalDispose = disposeDataRef.current;
|
|
disposeDataRef.current = () => {
|
|
cleanup();
|
|
originalDispose?.();
|
|
};
|
|
}
|
|
|
|
fitAddon.fit();
|
|
term.focus();
|
|
} catch (openErr) {
|
|
logger.error("[XTerm] Failed to open terminal:", openErr);
|
|
throw openErr;
|
|
}
|
|
|
|
term.onData((data) => {
|
|
const id = sessionRef.current;
|
|
if (id) {
|
|
terminalBackend.writeToSession(id, data);
|
|
|
|
// Track command input for shell history
|
|
if (status === "connected" && onCommandExecuted) {
|
|
// Handle control characters
|
|
if (data === "\r" || data === "\n") {
|
|
// Enter pressed - command submitted
|
|
const cmd = commandBufferRef.current.trim();
|
|
if (cmd) {
|
|
onCommandExecuted(cmd, host.id, host.label, sessionId);
|
|
}
|
|
commandBufferRef.current = "";
|
|
} else if (data === "\x7f" || data === "\b") {
|
|
// Backspace - remove last character
|
|
commandBufferRef.current = commandBufferRef.current.slice(
|
|
0,
|
|
-1,
|
|
);
|
|
} else if (data === "\x03") {
|
|
// Ctrl+C - clear buffer
|
|
commandBufferRef.current = "";
|
|
} else if (data === "\x15") {
|
|
// Ctrl+U - clear line
|
|
commandBufferRef.current = "";
|
|
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
|
// Regular printable character
|
|
commandBufferRef.current += data;
|
|
} else if (data.length > 1 && !data.startsWith("\x1b")) {
|
|
// Pasted text (multiple chars, not escape sequence)
|
|
commandBufferRef.current += data;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Add debouncing for resize events to prevent excessive calls on macOS
|
|
let resizeTimeout: NodeJS.Timeout | null = null;
|
|
const resizeDebounceMs = XTERM_PERFORMANCE_CONFIG.resize.debounceMs;
|
|
term.onResize(({ cols, rows }) => {
|
|
const id = sessionRef.current;
|
|
if (id) {
|
|
// Debounce resize to prevent rapid successive calls
|
|
if (resizeTimeout) {
|
|
clearTimeout(resizeTimeout);
|
|
}
|
|
resizeTimeout = setTimeout(() => {
|
|
terminalBackend.resizeSession(id, cols, rows);
|
|
resizeTimeout = null;
|
|
}, resizeDebounceMs); // Debounce for smooth resizing on macOS
|
|
}
|
|
});
|
|
|
|
if (host.protocol === "local" || host.hostname === "localhost") {
|
|
setStatus("connecting");
|
|
setProgressLogs(["Initializing local shell..."]);
|
|
await startLocal(term);
|
|
} else if (host.protocol === "telnet") {
|
|
setStatus("connecting");
|
|
setProgressLogs(["Initializing Telnet connection..."]);
|
|
await startTelnet(term);
|
|
} else if (host.moshEnabled) {
|
|
setStatus("connecting");
|
|
setProgressLogs(["Initializing Mosh connection..."]);
|
|
await startMosh(term);
|
|
} else {
|
|
// SSH connection (default)
|
|
// Check if host needs authentication info
|
|
const hasPassword = host.authMethod === "password" && host.password;
|
|
const hasKey = host.authMethod === "key" && host.identityFileId;
|
|
const hasPendingAuth = pendingAuthRef.current;
|
|
|
|
if (!hasPassword && !hasKey && !hasPendingAuth && !host.username) {
|
|
// No auth info available - show auth dialog without starting connection
|
|
setNeedsAuth(true);
|
|
// Keep status as disconnected - don't trigger timeout timer
|
|
setStatus("disconnected");
|
|
return;
|
|
}
|
|
|
|
setStatus("connecting");
|
|
setProgressLogs(["Initializing secure channel..."]);
|
|
await 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;
|
|
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(() => {
|
|
// Don't run timeout timer when showing auth dialog (user is entering credentials)
|
|
if (status !== "connecting" || 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;
|
|
// Smooth asymptotic approach - slows down as it gets higher
|
|
const remaining = 95 - prev;
|
|
// Larger increment since we update less frequently (200ms instead of 100ms)
|
|
const increment = Math.max(1, remaining * 0.15);
|
|
return Math.min(95, prev + increment);
|
|
});
|
|
}, 200); // Reduced from 100ms to 200ms to cut re-renders in half
|
|
|
|
return () => {
|
|
clearInterval(stepTimer);
|
|
clearInterval(countdown);
|
|
clearTimeout(timeout);
|
|
clearInterval(prog);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- updateStatus is a stable internal helper
|
|
}, [status, 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();
|
|
}
|
|
};
|
|
|
|
// Effect for global fontSize/terminalTheme/terminalSettings changes (from Settings)
|
|
useEffect(() => {
|
|
if (termRef.current) {
|
|
// Only apply global settings if host doesn't have specific overrides
|
|
const effectiveFontSize = host.fontSize || fontSize;
|
|
termRef.current.options.fontSize = effectiveFontSize;
|
|
|
|
// Use effectiveTheme which respects host-specific theme override
|
|
termRef.current.options.theme = {
|
|
...effectiveTheme.colors,
|
|
selectionBackground: effectiveTheme.colors.selection,
|
|
};
|
|
|
|
// Apply all terminal settings for real-time updates
|
|
if (terminalSettings) {
|
|
// Cursor settings
|
|
termRef.current.options.cursorStyle = terminalSettings.cursorShape;
|
|
termRef.current.options.cursorBlink = terminalSettings.cursorBlink;
|
|
|
|
// Buffer settings
|
|
termRef.current.options.scrollback = terminalSettings.scrollback;
|
|
|
|
// Font settings
|
|
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;
|
|
|
|
// Accessibility
|
|
termRef.current.options.minimumContrastRatio = terminalSettings.minimumContrastRatio;
|
|
|
|
// Input behavior
|
|
termRef.current.options.scrollOnUserInput = terminalSettings.scrollOnInput;
|
|
termRef.current.options.altClickMovesCursor = !terminalSettings.altAsMeta;
|
|
termRef.current.options.wordSeparator = terminalSettings.wordSeparators;
|
|
}
|
|
|
|
// Refit after settings change (especially important for lineHeight changes)
|
|
setTimeout(() => safeFit(), 50);
|
|
}
|
|
}, [fontSize, effectiveTheme, terminalSettings, host.fontSize]);
|
|
|
|
// Effect for host-specific font/theme changes (from ThemeCustomizeModal)
|
|
useEffect(() => {
|
|
if (termRef.current) {
|
|
// Apply host-specific font size
|
|
const effectiveFontSize = host.fontSize || fontSize;
|
|
termRef.current.options.fontSize = effectiveFontSize;
|
|
|
|
// Apply host-specific font family
|
|
const hostFontId = host.fontFamily || 'menlo';
|
|
const fontObj = TERMINAL_FONTS.find(f => f.id === hostFontId) || TERMINAL_FONTS[0];
|
|
termRef.current.options.fontFamily = fontObj.family;
|
|
|
|
// Apply effective theme (host-specific or global)
|
|
termRef.current.options.theme = {
|
|
...effectiveTheme.colors,
|
|
selectionBackground: effectiveTheme.colors.selection,
|
|
};
|
|
|
|
// Refit after changes
|
|
setTimeout(() => safeFit(), 50);
|
|
}
|
|
}, [host.fontSize, host.fontFamily, host.theme, fontSize, effectiveTheme]);
|
|
|
|
// Separate effect for visibility-triggered fit (less frequent)
|
|
useEffect(() => {
|
|
if (isVisible && fitAddonRef.current) {
|
|
// Small delay to ensure container is properly sized
|
|
const timer = setTimeout(() => safeFit(), 50);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [isVisible]);
|
|
|
|
// Re-fit once webfonts are ready so canvas sizing uses correct font metrics
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const waitForFonts = async () => {
|
|
try {
|
|
// FontFaceSet is available in modern browsers
|
|
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]);
|
|
|
|
// Debounced fit for resize operations - only fit when not actively resizing
|
|
useEffect(() => {
|
|
if (!containerRef.current || !fitAddonRef.current) return;
|
|
|
|
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const observer = new ResizeObserver(() => {
|
|
// Skip fit during active resize drag
|
|
if (isResizing) return;
|
|
|
|
// Clear previous timeout
|
|
if (resizeTimeout) {
|
|
clearTimeout(resizeTimeout);
|
|
}
|
|
// Wait 250ms after last resize event before fitting (increased for performance)
|
|
resizeTimeout = setTimeout(() => {
|
|
safeFit();
|
|
}, 250);
|
|
});
|
|
|
|
observer.observe(containerRef.current);
|
|
return () => {
|
|
if (resizeTimeout) clearTimeout(resizeTimeout);
|
|
observer.disconnect();
|
|
};
|
|
}, [isVisible, isResizing]);
|
|
|
|
// Fit when resizing ends (isResizing changes from true to false)
|
|
const prevIsResizingRef = useRef(isResizing);
|
|
useEffect(() => {
|
|
if (prevIsResizingRef.current && !isResizing && isVisible) {
|
|
// Resizing just ended, fit the terminal
|
|
const timer = setTimeout(() => {
|
|
safeFit();
|
|
}, 100);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
prevIsResizingRef.current = isResizing;
|
|
}, [isResizing, isVisible]);
|
|
|
|
// Re-fit when inWorkspace changes (terminal moves into/out of workspace)
|
|
useEffect(() => {
|
|
if (!isVisible || !fitAddonRef.current) return;
|
|
// Delay fit to allow layout changes to complete
|
|
const timer = setTimeout(() => {
|
|
safeFit();
|
|
}, 100);
|
|
return () => clearTimeout(timer);
|
|
}, [inWorkspace, isVisible]);
|
|
|
|
// Auto-focus terminal when tab becomes visible (only for solo tabs, not split views)
|
|
useEffect(() => {
|
|
// In split view mode (inWorkspace && !isFocusMode), focus is controlled by isFocused prop
|
|
// Only auto-focus for solo tabs or focus mode
|
|
const shouldAutoFocus = isVisible && termRef.current && (!inWorkspace || isFocusMode);
|
|
if (shouldAutoFocus) {
|
|
// Small delay to ensure the tab switch animation completes
|
|
const timer = setTimeout(() => {
|
|
termRef.current?.focus();
|
|
}, 50);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [isVisible, inWorkspace, isFocusMode]);
|
|
|
|
// Focus terminal when isFocused prop becomes true (for split view navigation)
|
|
useEffect(() => {
|
|
if (isFocused && termRef.current && isVisible) {
|
|
// Small delay to ensure state updates complete
|
|
const timer = setTimeout(() => {
|
|
termRef.current?.focus();
|
|
}, 10);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [isFocused, isVisible, sessionId]);
|
|
|
|
// Track terminal selection for context menu and copyOnSelect
|
|
useEffect(() => {
|
|
const term = termRef.current;
|
|
if (!term) return;
|
|
|
|
const onSelectionChange = () => {
|
|
const selection = term.getSelection();
|
|
const hasText = !!selection && selection.length > 0;
|
|
setHasSelection(hasText);
|
|
|
|
// Copy on select if enabled
|
|
if (hasText && terminalSettings?.copyOnSelect) {
|
|
navigator.clipboard.writeText(selection).catch(err => {
|
|
logger.warn('Copy on select failed:', err);
|
|
});
|
|
}
|
|
};
|
|
|
|
term.onSelectionChange(onSelectionChange);
|
|
// No need to return cleanup as xterm handles it when disposed
|
|
}, [terminalSettings?.copyOnSelect]);
|
|
|
|
useEffect(() => {
|
|
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const handler = () => {
|
|
// Clear previous timeout
|
|
if (resizeTimeout) {
|
|
clearTimeout(resizeTimeout);
|
|
}
|
|
// Wait 250ms after last resize event before fitting (increased for performance)
|
|
resizeTimeout = setTimeout(() => {
|
|
safeFit();
|
|
}, 250);
|
|
};
|
|
|
|
window.addEventListener("resize", handler);
|
|
return () => {
|
|
if (resizeTimeout) clearTimeout(resizeTimeout);
|
|
window.removeEventListener("resize", handler);
|
|
};
|
|
}, []);
|
|
|
|
const startSSH = async (term: XTerm) => {
|
|
try {
|
|
term.clear?.();
|
|
} catch (err) {
|
|
logger.warn("Failed to clear terminal before connect", err);
|
|
}
|
|
|
|
if (!terminalBackend.backendAvailable()) {
|
|
setError("Native SSH bridge unavailable. Launch via Electron app.");
|
|
term.writeln(
|
|
"\r\n[netcatty SSH bridge unavailable. Please run the desktop build to connect.]",
|
|
);
|
|
updateStatus("disconnected");
|
|
return;
|
|
}
|
|
|
|
// Use pending auth if available, otherwise use host config
|
|
const pendingAuth = pendingAuthRef.current;
|
|
const effectiveUsername = pendingAuth?.username || host.username || "root";
|
|
// Always include password if available for fallback authentication
|
|
const effectivePassword = pendingAuth?.password || host.password;
|
|
const effectiveKeyId = pendingAuth?.keyId || host.identityFileId;
|
|
|
|
const key = effectiveKeyId
|
|
? keys.find((k) => k.id === effectiveKeyId)
|
|
: undefined;
|
|
|
|
// Prepare proxy configuration if set
|
|
const proxyConfig = host.proxyConfig
|
|
? {
|
|
type: host.proxyConfig.type,
|
|
host: host.proxyConfig.host,
|
|
port: host.proxyConfig.port,
|
|
username: host.proxyConfig.username,
|
|
password: host.proxyConfig.password,
|
|
}
|
|
: undefined;
|
|
|
|
// Prepare jump host chain configuration
|
|
const jumpHosts = resolvedChainHosts.map((jumpHost) => {
|
|
const jumpKey = jumpHost.identityFileId
|
|
? keys.find((k) => k.id === jumpHost.identityFileId)
|
|
: undefined;
|
|
return {
|
|
hostname: jumpHost.hostname,
|
|
port: jumpHost.port || 22,
|
|
username: jumpHost.username || "root",
|
|
password: jumpHost.password, // Always include for fallback
|
|
privateKey: jumpKey?.privateKey,
|
|
label: jumpHost.label,
|
|
};
|
|
});
|
|
|
|
// Initialize chain progress if we have jump hosts
|
|
const totalHops = jumpHosts.length + 1; // jump hosts + target
|
|
let unsubscribeChainProgress: (() => void) | undefined;
|
|
|
|
if (jumpHosts.length > 0) {
|
|
setChainProgress({
|
|
currentHop: 1,
|
|
totalHops,
|
|
currentHostLabel:
|
|
jumpHosts[0]?.label || jumpHosts[0]?.hostname || host.hostname,
|
|
});
|
|
setProgressLogs((prev) => [
|
|
...prev,
|
|
`Starting chain connection (${totalHops} hops)...`,
|
|
]);
|
|
|
|
// Subscribe to chain progress events from IPC
|
|
{
|
|
const unsub = terminalBackend.onChainProgress(
|
|
(hop, total, label, status) => {
|
|
setChainProgress({
|
|
currentHop: hop,
|
|
totalHops: total,
|
|
currentHostLabel: label,
|
|
});
|
|
setProgressLogs((prev) => [
|
|
...prev,
|
|
`Chain ${hop} of ${total}: ${label} - ${status}`,
|
|
]);
|
|
// Update progress value based on chain hop
|
|
const hopProgress = (hop / total) * 80 + 10;
|
|
setProgressValue(Math.min(95, hopProgress));
|
|
},
|
|
);
|
|
if (unsub) unsubscribeChainProgress = unsub;
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Build environment variables, including TERM from settings
|
|
const termEnv: Record<string, string> = {
|
|
TERM: terminalSettings?.terminalEmulationType ?? 'xterm-256color',
|
|
};
|
|
|
|
// Add host-specific environment variables
|
|
if (host.environmentVariables) {
|
|
for (const { name, value } of host.environmentVariables) {
|
|
if (name) termEnv[name] = value;
|
|
}
|
|
}
|
|
|
|
const id = await terminalBackend.startSSHSession({
|
|
sessionId,
|
|
hostname: host.hostname,
|
|
username: effectiveUsername,
|
|
port: host.port || 22,
|
|
password: effectivePassword,
|
|
privateKey: key?.privateKey,
|
|
keyId: key?.id,
|
|
agentForwarding: host.agentForwarding,
|
|
cols: term.cols,
|
|
rows: term.rows,
|
|
charset: host.charset,
|
|
// Environment variables including TERM
|
|
env: termEnv,
|
|
// New: proxy and jump host configuration
|
|
proxy: proxyConfig,
|
|
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
|
|
});
|
|
|
|
// Clean up chain progress listener after successful connection
|
|
if (unsubscribeChainProgress) {
|
|
unsubscribeChainProgress();
|
|
}
|
|
|
|
sessionRef.current = id;
|
|
|
|
disposeDataRef.current = terminalBackend.onSessionData(id, (chunk) => {
|
|
// Apply keyword highlighting before writing to terminal
|
|
term.write(highlightProcessorRef.current(chunk));
|
|
if (!hasConnectedRef.current) {
|
|
updateStatus("connected");
|
|
setChainProgress(null); // Clear chain progress on connect
|
|
// Trigger fit after connection to ensure proper terminal size
|
|
setTimeout(() => {
|
|
if (fitAddonRef.current) {
|
|
try {
|
|
fitAddonRef.current.fit();
|
|
// Send updated size to remote
|
|
if (sessionRef.current) {
|
|
terminalBackend.resizeSession(sessionRef.current, term.cols, term.rows);
|
|
}
|
|
} catch (err) {
|
|
logger.warn("Post-connect fit failed", err);
|
|
}
|
|
}
|
|
}, 100);
|
|
}
|
|
});
|
|
|
|
disposeExitRef.current = terminalBackend.onSessionExit(id, (evt) => {
|
|
updateStatus("disconnected");
|
|
setChainProgress(null); // Clear chain progress on disconnect
|
|
term.writeln(
|
|
`\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
|
|
);
|
|
onSessionExit?.(sessionId);
|
|
});
|
|
|
|
// Run startup command from host config or snippet
|
|
const commandToRun = startupCommand || host.startupCommand;
|
|
if (commandToRun && !hasRunStartupCommandRef.current) {
|
|
hasRunStartupCommandRef.current = true;
|
|
setTimeout(() => {
|
|
if (sessionRef.current) {
|
|
terminalBackend.writeToSession(sessionRef.current, `${commandToRun}\r`);
|
|
// Track startup command execution in shell history
|
|
if (onCommandExecuted) {
|
|
onCommandExecuted(commandToRun, host.id, host.label, sessionId);
|
|
}
|
|
}
|
|
}, 600);
|
|
}
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
|
|
// Check if this is an authentication failure
|
|
const isAuthError =
|
|
message.toLowerCase().includes("authentication") ||
|
|
message.toLowerCase().includes("auth") ||
|
|
message.toLowerCase().includes("password") ||
|
|
message.toLowerCase().includes("permission denied");
|
|
|
|
if (isAuthError) {
|
|
// Show auth dialog for password retry
|
|
setError(null); // Clear error so we show auth dialog instead
|
|
setNeedsAuth(true);
|
|
setAuthRetryMessage(
|
|
"Authentication failed. Please check your credentials and try again.",
|
|
);
|
|
setAuthPassword(""); // Clear password for re-entry
|
|
setProgressLogs((prev) => [
|
|
...prev,
|
|
"Authentication failed. Please try again.",
|
|
]);
|
|
// Stay in connecting state to show auth dialog
|
|
setStatus("connecting");
|
|
} else {
|
|
setError(message);
|
|
term.writeln(`\r\n[Failed to start SSH: ${message}]`);
|
|
updateStatus("disconnected");
|
|
}
|
|
|
|
setChainProgress(null); // Clear chain progress on error
|
|
// Clean up chain progress listener on error
|
|
if (unsubscribeChainProgress) {
|
|
unsubscribeChainProgress();
|
|
}
|
|
}
|
|
|
|
// Trigger distro detection once connected (hidden exec, no terminal output)
|
|
setTimeout(() => runDistroDetection(key), 600);
|
|
};
|
|
|
|
const startTelnet = async (term: XTerm) => {
|
|
try {
|
|
term.clear?.();
|
|
} catch (err) {
|
|
logger.warn("Failed to clear terminal before connect", err);
|
|
}
|
|
|
|
if (!terminalBackend.telnetAvailable()) {
|
|
setError("Telnet bridge unavailable. Please run the desktop build.");
|
|
term.writeln(
|
|
"\r\n[Telnet bridge unavailable. Please run the desktop build.]",
|
|
);
|
|
updateStatus("disconnected");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Build environment variables, including TERM from settings
|
|
const telnetEnv: Record<string, string> = {
|
|
TERM: terminalSettings?.terminalEmulationType ?? 'xterm-256color',
|
|
};
|
|
|
|
// Add host-specific environment variables
|
|
if (host.environmentVariables) {
|
|
for (const { name, value } of host.environmentVariables) {
|
|
if (name) telnetEnv[name] = value;
|
|
}
|
|
}
|
|
|
|
const id = await terminalBackend.startTelnetSession({
|
|
sessionId,
|
|
hostname: host.hostname,
|
|
port: host.telnetPort || host.port || 23,
|
|
cols: term.cols,
|
|
rows: term.rows,
|
|
charset: host.charset,
|
|
env: telnetEnv,
|
|
});
|
|
|
|
sessionRef.current = id;
|
|
|
|
disposeDataRef.current = terminalBackend.onSessionData(id, (chunk) => {
|
|
// Apply keyword highlighting before writing to terminal
|
|
term.write(highlightProcessorRef.current(chunk));
|
|
if (!hasConnectedRef.current) {
|
|
updateStatus("connected");
|
|
setTimeout(() => {
|
|
if (fitAddonRef.current) {
|
|
try {
|
|
fitAddonRef.current.fit();
|
|
if (sessionRef.current) {
|
|
terminalBackend.resizeSession(sessionRef.current, term.cols, term.rows);
|
|
}
|
|
} catch (err) {
|
|
logger.warn("Post-connect fit failed", err);
|
|
}
|
|
}
|
|
}, 100);
|
|
}
|
|
});
|
|
|
|
disposeExitRef.current = terminalBackend.onSessionExit(id, (evt) => {
|
|
updateStatus("disconnected");
|
|
term.writeln(
|
|
`\r\n[Telnet session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
|
|
);
|
|
onSessionExit?.(sessionId);
|
|
});
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
setError(message);
|
|
term.writeln(`\r\n[Failed to start Telnet: ${message}]`);
|
|
updateStatus("disconnected");
|
|
}
|
|
};
|
|
|
|
const startMosh = async (term: XTerm) => {
|
|
try {
|
|
term.clear?.();
|
|
} catch (err) {
|
|
logger.warn("Failed to clear terminal before connect", err);
|
|
}
|
|
|
|
if (!terminalBackend.moshAvailable()) {
|
|
setError("Mosh bridge unavailable. Please run the desktop build.");
|
|
term.writeln(
|
|
"\r\n[Mosh bridge unavailable. Please run the desktop build.]",
|
|
);
|
|
updateStatus("disconnected");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Build environment variables, including TERM from settings
|
|
const moshEnv: Record<string, string> = {
|
|
TERM: terminalSettings?.terminalEmulationType ?? 'xterm-256color',
|
|
};
|
|
|
|
// Add host-specific environment variables
|
|
if (host.environmentVariables) {
|
|
for (const { name, value } of host.environmentVariables) {
|
|
if (name) moshEnv[name] = value;
|
|
}
|
|
}
|
|
|
|
const id = await terminalBackend.startMoshSession({
|
|
sessionId,
|
|
hostname: host.hostname,
|
|
username: host.username || "root",
|
|
port: host.port || 22,
|
|
moshServerPath: host.moshServerPath,
|
|
agentForwarding: host.agentForwarding,
|
|
cols: term.cols,
|
|
rows: term.rows,
|
|
charset: host.charset,
|
|
env: moshEnv,
|
|
});
|
|
|
|
sessionRef.current = id;
|
|
|
|
disposeDataRef.current = terminalBackend.onSessionData(id, (chunk) => {
|
|
// Apply keyword highlighting before writing to terminal
|
|
term.write(highlightProcessorRef.current(chunk));
|
|
if (!hasConnectedRef.current) {
|
|
updateStatus("connected");
|
|
setTimeout(() => {
|
|
if (fitAddonRef.current) {
|
|
try {
|
|
fitAddonRef.current.fit();
|
|
if (sessionRef.current) {
|
|
terminalBackend.resizeSession(sessionRef.current, term.cols, term.rows);
|
|
}
|
|
} catch (err) {
|
|
logger.warn("Post-connect fit failed", err);
|
|
}
|
|
}
|
|
}, 100);
|
|
}
|
|
});
|
|
|
|
disposeExitRef.current = terminalBackend.onSessionExit(id, (evt) => {
|
|
updateStatus("disconnected");
|
|
term.writeln(
|
|
`\r\n[Mosh session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
|
|
);
|
|
onSessionExit?.(sessionId);
|
|
});
|
|
|
|
// Run startup command if specified
|
|
const commandToRun = startupCommand || host.startupCommand;
|
|
if (commandToRun && !hasRunStartupCommandRef.current) {
|
|
hasRunStartupCommandRef.current = true;
|
|
setTimeout(() => {
|
|
if (sessionRef.current) {
|
|
terminalBackend.writeToSession(sessionRef.current, `${commandToRun}\r`);
|
|
if (onCommandExecuted) {
|
|
onCommandExecuted(commandToRun, host.id, host.label, sessionId);
|
|
}
|
|
}
|
|
}, 600);
|
|
}
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
setError(message);
|
|
term.writeln(`\r\n[Failed to start Mosh: ${message}]`);
|
|
updateStatus("disconnected");
|
|
}
|
|
};
|
|
|
|
const startLocal = async (term: XTerm) => {
|
|
try {
|
|
term.clear?.();
|
|
} catch (err) {
|
|
logger.warn("Failed to clear terminal before connect", err);
|
|
}
|
|
|
|
if (!terminalBackend.localAvailable()) {
|
|
setError("Local shell bridge unavailable. Please run the desktop build.");
|
|
term.writeln(
|
|
"\r\n[Local shell bridge unavailable. Please run the desktop build to spawn a local terminal.]",
|
|
);
|
|
updateStatus("disconnected");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const id = await terminalBackend.startLocalSession({
|
|
sessionId,
|
|
cols: term.cols,
|
|
rows: term.rows,
|
|
env: {
|
|
TERM: terminalSettings?.terminalEmulationType ?? 'xterm-256color',
|
|
},
|
|
});
|
|
sessionRef.current = id;
|
|
disposeDataRef.current = terminalBackend.onSessionData(id, (chunk) => {
|
|
// Apply keyword highlighting before writing to terminal
|
|
term.write(highlightProcessorRef.current(chunk));
|
|
if (!hasConnectedRef.current) {
|
|
updateStatus("connected");
|
|
// Trigger fit after connection to ensure proper terminal size
|
|
setTimeout(() => {
|
|
if (fitAddonRef.current) {
|
|
try {
|
|
fitAddonRef.current.fit();
|
|
// Send updated size to remote
|
|
if (sessionRef.current) {
|
|
terminalBackend.resizeSession(sessionRef.current, term.cols, term.rows);
|
|
}
|
|
} catch (err) {
|
|
logger.warn("Post-connect fit failed", err);
|
|
}
|
|
}
|
|
}, 100);
|
|
}
|
|
});
|
|
disposeExitRef.current = terminalBackend.onSessionExit(id, (evt) => {
|
|
updateStatus("disconnected");
|
|
term.writeln(
|
|
`\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
|
|
);
|
|
onSessionExit?.(sessionId);
|
|
});
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
setError(message);
|
|
term.writeln(`\r\n[Failed to start local shell: ${message}]`);
|
|
updateStatus("disconnected");
|
|
}
|
|
};
|
|
|
|
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);
|
|
setNeedsAuth(false);
|
|
setAuthRetryMessage(null); // Clear auth retry message
|
|
setNeedsHostKeyVerification(false);
|
|
setPendingHostKeyInfo(null);
|
|
setError("Connection cancelled");
|
|
setProgressLogs((prev) => [...prev, "Cancelled by user."]);
|
|
cleanupSession();
|
|
updateStatus("disconnected");
|
|
setChainProgress(null); // Clear chain progress on cancel
|
|
setTimeout(() => setIsCancelling(false), 600);
|
|
onCloseSession?.(sessionId);
|
|
};
|
|
|
|
// Handle known host verification - Close (cancel)
|
|
const handleHostKeyClose = () => {
|
|
setNeedsHostKeyVerification(false);
|
|
setPendingHostKeyInfo(null);
|
|
handleCancelConnect();
|
|
};
|
|
|
|
// Handle known host verification - Continue without adding
|
|
const handleHostKeyContinue = () => {
|
|
setNeedsHostKeyVerification(false);
|
|
// Resume connection without adding to known hosts
|
|
if (pendingConnectionRef.current) {
|
|
pendingConnectionRef.current();
|
|
pendingConnectionRef.current = null;
|
|
}
|
|
setPendingHostKeyInfo(null);
|
|
};
|
|
|
|
// Handle known host verification - Add and continue
|
|
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);
|
|
// Resume connection
|
|
if (pendingConnectionRef.current) {
|
|
pendingConnectionRef.current();
|
|
pendingConnectionRef.current = null;
|
|
}
|
|
setPendingHostKeyInfo(null);
|
|
};
|
|
|
|
const handleRetry = () => {
|
|
if (!termRef.current) return;
|
|
cleanupSession();
|
|
setNeedsAuth(false);
|
|
setAuthRetryMessage(null); // Clear auth retry message
|
|
pendingAuthRef.current = null;
|
|
setStatus("connecting");
|
|
setError(null);
|
|
setProgressLogs(["Retrying secure channel..."]);
|
|
setShowLogs(true);
|
|
if (host.protocol === "local" || host.hostname === "localhost") {
|
|
startLocal(termRef.current);
|
|
} else {
|
|
startSSH(termRef.current);
|
|
}
|
|
};
|
|
|
|
const isAuthValid = () => {
|
|
if (!authUsername.trim()) return false;
|
|
if (authMethod === "password") return authPassword.trim().length > 0;
|
|
if (authMethod === "key") return !!authKeyId;
|
|
return false;
|
|
};
|
|
|
|
const handleAuthSubmit = () => {
|
|
if (!isAuthValid()) return;
|
|
|
|
// Set pending auth credentials
|
|
pendingAuthRef.current = {
|
|
username: authUsername,
|
|
password: authMethod === "password" ? authPassword : undefined,
|
|
keyId: authMethod === "key" ? (authKeyId ?? undefined) : undefined,
|
|
};
|
|
|
|
// Save credentials to host if requested
|
|
if (saveCredentials && onUpdateHost) {
|
|
const updatedHost: Host = {
|
|
...host,
|
|
username: authUsername,
|
|
authMethod: authMethod,
|
|
password: authMethod === "password" ? authPassword : undefined,
|
|
identityFileId:
|
|
authMethod === "key" ? (authKeyId ?? undefined) : undefined,
|
|
};
|
|
onUpdateHost(updatedHost);
|
|
}
|
|
|
|
// Hide auth dialog and start connection
|
|
setNeedsAuth(false);
|
|
setAuthRetryMessage(null); // Clear any previous auth error message
|
|
setStatus("connecting");
|
|
setProgressLogs(["Authenticating with provided credentials..."]);
|
|
|
|
if (termRef.current) {
|
|
// Clear terminal before connecting
|
|
try {
|
|
termRef.current.clear?.();
|
|
} catch (err) {
|
|
logger.warn("Failed to clear terminal", err);
|
|
}
|
|
startSSH(termRef.current);
|
|
}
|
|
};
|
|
|
|
// Context menu handlers
|
|
const handleContextCopy = () => {
|
|
const term = termRef.current;
|
|
if (!term) return;
|
|
const selection = term.getSelection();
|
|
if (selection) {
|
|
navigator.clipboard.writeText(selection);
|
|
}
|
|
};
|
|
|
|
const handleContextPaste = async () => {
|
|
const term = termRef.current;
|
|
if (!term) return;
|
|
try {
|
|
const text = await navigator.clipboard.readText();
|
|
if (text && sessionRef.current) terminalBackend.writeToSession(sessionRef.current, text);
|
|
} catch (err) {
|
|
logger.warn("Failed to paste from clipboard", err);
|
|
}
|
|
};
|
|
|
|
const handleContextSelectAll = () => {
|
|
const term = termRef.current;
|
|
if (!term) return;
|
|
term.selectAll();
|
|
setHasSelection(true);
|
|
};
|
|
|
|
const handleContextClear = () => {
|
|
const term = termRef.current;
|
|
if (!term) return;
|
|
term.clear();
|
|
};
|
|
|
|
const handleContextSelectWord = () => {
|
|
const term = termRef.current;
|
|
if (!term) return;
|
|
// xterm.js selectWord selects the word under cursor
|
|
// Since we don't have a cursor position from right-click, we can select all as fallback
|
|
// A proper implementation would require tracking the click position
|
|
term.selectAll();
|
|
setHasSelection(true);
|
|
};
|
|
|
|
// Search handlers
|
|
const handleToggleSearch = useCallback(() => {
|
|
setIsSearchOpen((prev) => !prev);
|
|
// Clear match count when closing
|
|
if (isSearchOpen) {
|
|
setSearchMatchCount(null);
|
|
searchAddonRef.current?.clearDecorations();
|
|
}
|
|
}, [isSearchOpen]);
|
|
|
|
const handleSearch = useCallback((term: string): boolean => {
|
|
const searchAddon = searchAddonRef.current;
|
|
if (!searchAddon || !term) {
|
|
setSearchMatchCount(null);
|
|
return false;
|
|
}
|
|
|
|
// Store the search term for findNext/findPrevious
|
|
searchTermRef.current = term;
|
|
|
|
// Clear previous decorations
|
|
searchAddon.clearDecorations();
|
|
|
|
// Perform search (findNext from current position or start)
|
|
const found = searchAddon.findNext(term, {
|
|
regex: false,
|
|
caseSensitive: false,
|
|
wholeWord: false,
|
|
decorations: {
|
|
matchBackground: '#FFFF0044',
|
|
matchBorder: '#FFFF00',
|
|
matchOverviewRuler: '#FFFF00',
|
|
activeMatchBackground: '#FF880088',
|
|
activeMatchBorder: '#FF8800',
|
|
activeMatchColorOverviewRuler: '#FF8800',
|
|
},
|
|
});
|
|
|
|
// Update match count after search
|
|
// Note: xterm search addon doesn't expose match count directly
|
|
// We'll track it by counting via findAll (if available) or estimate
|
|
if (found) {
|
|
// For now, we don't have exact count from the addon
|
|
// We'll show a basic indicator
|
|
setSearchMatchCount({ current: 1, total: 1 }); // Placeholder
|
|
} else {
|
|
setSearchMatchCount({ current: 0, total: 0 });
|
|
}
|
|
|
|
return found;
|
|
}, []);
|
|
|
|
const handleFindNext = useCallback((): boolean => {
|
|
const searchAddon = searchAddonRef.current;
|
|
const term = searchTermRef.current;
|
|
if (!searchAddon || !term) return false;
|
|
const found = searchAddon.findNext(term, {
|
|
regex: false,
|
|
caseSensitive: false,
|
|
wholeWord: false,
|
|
decorations: {
|
|
matchBackground: '#FFFF0044',
|
|
matchBorder: '#FFFF00',
|
|
matchOverviewRuler: '#FFFF00',
|
|
activeMatchBackground: '#FF880088',
|
|
activeMatchBorder: '#FF8800',
|
|
activeMatchColorOverviewRuler: '#FF8800',
|
|
},
|
|
});
|
|
return found;
|
|
}, []);
|
|
|
|
const handleFindPrevious = useCallback((): boolean => {
|
|
const searchAddon = searchAddonRef.current;
|
|
const term = searchTermRef.current;
|
|
if (!searchAddon || !term) return false;
|
|
const found = searchAddon.findPrevious(term, {
|
|
regex: false,
|
|
caseSensitive: false,
|
|
wholeWord: false,
|
|
decorations: {
|
|
matchBackground: '#FFFF0044',
|
|
matchBorder: '#FFFF00',
|
|
matchOverviewRuler: '#FFFF00',
|
|
activeMatchBackground: '#FF880088',
|
|
activeMatchBorder: '#FF8800',
|
|
activeMatchColorOverviewRuler: '#FF8800',
|
|
},
|
|
});
|
|
return found;
|
|
}, []);
|
|
|
|
const handleCloseSearch = useCallback(() => {
|
|
setIsSearchOpen(false);
|
|
setSearchMatchCount(null);
|
|
searchAddonRef.current?.clearDecorations();
|
|
// Refocus terminal after closing search
|
|
termRef.current?.focus();
|
|
}, []);
|
|
|
|
const renderControls = (opts?: { showClose?: boolean }) => (
|
|
<TerminalToolbar
|
|
status={status}
|
|
snippets={snippets}
|
|
host={host}
|
|
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";
|
|
// Reserved for future status indicator enhancements
|
|
const _isConnecting = status === "connecting";
|
|
const _hasError = Boolean(error);
|
|
|
|
return (
|
|
<TerminalContextMenu
|
|
hasSelection={hasSelection}
|
|
hotkeyScheme={hotkeyScheme}
|
|
rightClickBehavior={terminalSettings?.rightClickBehavior}
|
|
onCopy={handleContextCopy}
|
|
onPaste={handleContextPaste}
|
|
onSelectAll={handleContextSelectAll}
|
|
onClear={handleContextClear}
|
|
onSelectWord={handleContextSelectWord}
|
|
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]">
|
|
{/* Unified statusbar for both single host and workspace modes */}
|
|
<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">
|
|
{/* Expand to focus mode button - only show in workspace split view mode */}
|
|
{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>
|
|
{/* Search bar - full width below status bar */}
|
|
{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 }}
|
|
/>
|
|
{error && (
|
|
<div className="absolute bottom-3 left-3 text-xs text-destructive bg-background/80 border border-destructive/40 rounded px-3 py-2 shadow-lg">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Known Host Verification Dialog */}
|
|
{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={needsAuth}
|
|
showLogs={showLogs}
|
|
_setShowLogs={setShowLogs}
|
|
keys={keys}
|
|
authProps={{
|
|
authMethod,
|
|
setAuthMethod,
|
|
authUsername,
|
|
setAuthUsername,
|
|
authPassword,
|
|
setAuthPassword,
|
|
authKeyId,
|
|
setAuthKeyId,
|
|
showAuthPassword,
|
|
setShowAuthPassword,
|
|
authRetryMessage,
|
|
onSubmit: handleAuthSubmit,
|
|
onSubmitWithoutSave: () => {
|
|
setSaveCredentials(false);
|
|
handleAuthSubmit();
|
|
},
|
|
onCancel: handleCancelConnect,
|
|
isValid: isAuthValid(),
|
|
}}
|
|
progressProps={{
|
|
timeLeft,
|
|
isCancelling,
|
|
progressLogs,
|
|
onCancel: handleCancelConnect,
|
|
onRetry: handleRetry,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* SFTP Modal - rendered outside terminal container to avoid affecting terminal width */}
|
|
<SFTPModal
|
|
host={host}
|
|
credentials={{
|
|
username: host.username,
|
|
hostname: host.hostname,
|
|
port: host.port,
|
|
password: host.password, // Always include for fallback
|
|
privateKey: host.identityFileId
|
|
? keys.find((k) => k.id === host.identityFileId)?.privateKey
|
|
: undefined,
|
|
}}
|
|
open={showSFTP && status === "connected"}
|
|
onClose={() => setShowSFTP(false)}
|
|
/>
|
|
</div>
|
|
</TerminalContextMenu>
|
|
);
|
|
};
|
|
|
|
// Memoized Terminal - only re-renders when props change
|
|
const Terminal = memo(TerminalComponent);
|
|
Terminal.displayName = "Terminal";
|
|
|
|
export default Terminal;
|