mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
2522 lines
100 KiB
TypeScript
2522 lines
100 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 { Activity, Cpu, Clock3, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, Sparkles, SquareArrowOutUpRight } from "lucide-react";
|
|
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
import { useI18n } from "../application/i18n/I18nProvider";
|
|
import { detectLocalOs } from "../lib/localShell";
|
|
import { logger } from "../lib/logger";
|
|
import { cn, normalizeLineEndings, wrapBracketedPaste } from "../lib/utils";
|
|
import {
|
|
Host,
|
|
Snippet,
|
|
TerminalSession,
|
|
} from "../types";
|
|
import { resolveSnippetCommand } from "./SnippetExecutionProvider";
|
|
import {
|
|
shouldEnableNativeUserInputAutoScroll,
|
|
shouldScrollOnTerminalInput,
|
|
} from "../domain/terminalScroll";
|
|
import {
|
|
applyCustomAccentToTerminalTheme,
|
|
resolveHostTerminalThemeId,
|
|
type TerminalHostUpdate,
|
|
} from "../domain/terminalAppearance";
|
|
import {
|
|
createTerminalEncodingStorageKey,
|
|
isTerminalEncodingPreference,
|
|
resolveInitialTerminalEncoding,
|
|
shouldSyncTerminalEncodingOnAttach,
|
|
terminalEncodingPreferenceToCharset,
|
|
type TerminalEncodingPreference,
|
|
type TerminalEncodingAttachConnection,
|
|
} from "../domain/terminalEncodingPreference";
|
|
import { resolveRestoreCwdIntent } from "../domain/sessionRestore";
|
|
import {
|
|
buildTerminalContextReadResult,
|
|
buildTerminalContextSnapshotText,
|
|
normalizeTerminalContextRange,
|
|
resolveTerminalContextLineWindow,
|
|
type TerminalContextReader,
|
|
} from "../domain/terminalContextRead";
|
|
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
|
|
import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
|
|
import { resolveHostAuth } from "../domain/sshAuth";
|
|
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
|
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
|
import { readOptionalStoredStringValue, useStoredString } from "../application/state/useStoredString";
|
|
import { useSessionLogBackend } from "../application/state/useSessionLogBackend";
|
|
import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore";
|
|
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
|
|
import { Button } from "./ui/button";
|
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
|
import { toast } from "./ui/toast";
|
|
import { useAvailableFonts } from "../application/state/fontStore";
|
|
import { composeFontFamilyStack, type SupportedPlatform } from "../infrastructure/config/cjkFonts";
|
|
import { resolveTerminalFontFamilyId } from "../infrastructure/config/fonts";
|
|
import { getBuiltinTerminalThemeById } from "../infrastructure/config/terminalThemes";
|
|
import {
|
|
STORAGE_KEY_TERMINAL_COMPOSE_BAR_OPEN,
|
|
STORAGE_KEY_TERMINAL_ENCODING_BY_HOST_PREFIX,
|
|
} from "../infrastructure/config/storageKeys";
|
|
import { useCustomThemes } from "../application/state/customThemeStore";
|
|
|
|
import { TerminalConnectionDialog } from "./terminal/TerminalConnectionDialog";
|
|
import { SSH_TCP_CONNECT_TIMEOUT_MS } from "./terminal/connectionTimeouts";
|
|
import { HostKeyInfo } from "./terminal/TerminalHostKeyVerification";
|
|
import { createKnownHostFromHostKeyInfo, toHostKeyInfo } from "./terminal/hostKeyVerification";
|
|
import { TerminalToolbar } from "./terminal/TerminalToolbar";
|
|
import { ScriptRecordingIndicator } from "./terminal/ScriptRecordingIndicator";
|
|
import { ScriptSaveRecordingDialog } from "./scripts/ScriptSaveRecordingDialog";
|
|
import { registerScreenSnapshotProvider } from "@/infrastructure/scripts/screenSnapshotRegistry.ts";
|
|
import { useScriptRecorder } from "@/application/state/useScriptRecorder.ts";
|
|
import { getScriptRecordingSnapshot, setScriptRecordingState } from "@/application/state/scriptRecordingStore.ts";
|
|
import {
|
|
runAutomationScript,
|
|
runConnectScriptsSequential,
|
|
subscribeScriptRuns,
|
|
pauseScriptRun,
|
|
resumeScriptRun,
|
|
stopScriptRun,
|
|
} from "@/application/state/scriptAutomationCoordinator.ts";
|
|
import { resolveConnectScriptsForHost, hasUnresolvedConnectScriptBindings } from "@/domain/hostConnectScripts.ts";
|
|
import { isVaultInitialized } from "@/application/state/vaultInitStore.ts";
|
|
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
|
|
import { ScriptExecutionOverlay } from "./terminal/ScriptExecutionOverlay";
|
|
import { isScriptSnippet } from "@/domain/snippetScript.ts";
|
|
import { useOutputTriggers } from "@/application/state/useOutputTriggers.ts";
|
|
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
|
|
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
|
|
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
|
|
import { ZmodemOverwriteDialog } from "./terminal/ZmodemOverwriteDialog";
|
|
import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
|
|
import { createReplaySafeTerminalLogSanitizer } from "./terminal/replaySafeTerminalLog";
|
|
import { createConnectionLogBuffer } from "./terminal/connectionLogBuffer";
|
|
import { createProgrammaticCommandLogRewriter, type ProgrammaticCommandLogRewrite } from "./terminal/programmaticCommandLog";
|
|
import { getSessionLogInitialLine } from "./terminal/sessionLogInitialLine";
|
|
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
|
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
|
|
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
|
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
|
|
import { terminalAltKeyOptions } from "./terminal/runtime/altKeyOptions";
|
|
import {
|
|
createPromptLineBreakState,
|
|
type PromptLineBreakState,
|
|
} from "./terminal/runtime/promptLineBreak";
|
|
import {
|
|
prepareSudoAutofillInput,
|
|
type SudoPasswordAutofill,
|
|
} from "./terminal/runtime/terminalSudoAutofill";
|
|
import {
|
|
recordTerminalCommandExecution,
|
|
shouldRecordShellHistory,
|
|
} from "./terminal/runtime/terminalCommandExecution";
|
|
import { shouldPreserveTerminalFocusOnMouseDown } from "./terminal/toolbarFocus";
|
|
import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport";
|
|
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";
|
|
import { useTerminalDragDrop } from "./terminal/hooks/useTerminalDragDrop";
|
|
import { useTerminalFilePaste } from "./terminal/hooks/useTerminalFilePaste";
|
|
import { TerminalAutocomplete } from "./terminal/TerminalAutocomplete";
|
|
import { resolveTerminalAutocompleteSettings } from "./terminal/autocomplete/terminalAutocompleteSettings";
|
|
import { buildOsc7SetupExecCommand, runOsc7SetupAction, shouldOfferOsc7SetupAction } from "./terminal/osc7Setup";
|
|
import {
|
|
getRemoteClipboardImageUploadErrorMessageKey,
|
|
type RemoteClipboardImageUploadResult,
|
|
} from "./terminal/clipboardImagePaste";
|
|
import { createTerminalCwdTracker, resolvePreferredTerminalCwd } from "./terminal/sftpCwd";
|
|
import { useTerminalEffects } from "./terminal/useTerminalEffects";
|
|
import { useTerminalHibernateEffect } from "./terminal/useTerminalHibernateEffect";
|
|
import { readActiveTerminalBufferTextRange } from "./terminal/terminalContextBuffer";
|
|
import {
|
|
appendHibernatePendingBuffer,
|
|
isTerminalAlternateScreenActive,
|
|
serializeTerminalForHibernate,
|
|
} from "./terminal/terminalHibernateRuntime";
|
|
import {
|
|
ackTerminalSessionFlow,
|
|
clearTerminalSessionFlowAck,
|
|
flushTerminalSessionFlowAck,
|
|
} from "./terminal/runtime/terminalFlowAckBuffer";
|
|
import { releaseTerminalFlowBeforeHibernate } from "./terminal/runtime/terminalSessionAttachment";
|
|
import {
|
|
isTerminalFileTransferActive,
|
|
resolveHibernateKeepRendererCount,
|
|
resolveHibernatePreferWasmSerialize,
|
|
resolveHibernateSkipAltScreen,
|
|
resolveTerminalHibernateDelayMs,
|
|
resolveTerminalHibernateEnabled,
|
|
resolveTerminalHibernateReplayChunkBytes,
|
|
type TerminalHibernateWakePayload,
|
|
} from "../domain/terminalHibernate";
|
|
import { terminalHiddenRendererStore } from "../application/state/terminalHiddenRendererStore";
|
|
import {
|
|
wakeTerminalFromHibernate,
|
|
type TerminalRuntimeRefs,
|
|
} from "./terminal/terminalRuntimeMount";
|
|
import type { CreateXTermRuntimeContext } from "./terminal/runtime/createXTermRuntime";
|
|
import { TerminalView } from "./terminal/TerminalView";
|
|
import {
|
|
getInitialTerminalStatus,
|
|
shouldStartTerminalBackend,
|
|
} from "./terminal/restoredSessionGate";
|
|
import {
|
|
AUTO_RUN_SNIPPET_LINE_DELAY_MS,
|
|
forceSyncRenderAfterResize,
|
|
MAX_CONNECTION_LOG_DATA_CHARS,
|
|
shouldDelayAutoRunSnippetInput,
|
|
shouldHideConnectingDialogForConnectionReuse,
|
|
shouldShowTerminalConnectionDialog,
|
|
type TerminalProps,
|
|
} from "./terminal/terminalHelpers";
|
|
import { terminalPropsAreEqual } from "./terminal/terminalMemo";
|
|
|
|
const TerminalComponent: React.FC<TerminalProps> = ({
|
|
host,
|
|
keys,
|
|
identities,
|
|
snippets,
|
|
snippetPackages = [],
|
|
compactToolbar = false,
|
|
lineTimestampsAvailable = true,
|
|
chainHosts = [],
|
|
appearanceTheme,
|
|
knownHosts = [],
|
|
isVisible,
|
|
paneLayoutKey,
|
|
inWorkspace,
|
|
isResizing,
|
|
isFocusMode,
|
|
isFocused,
|
|
fontFamilyId,
|
|
fontSize,
|
|
terminalTheme,
|
|
followAppTerminalTheme = false,
|
|
accentMode = "theme",
|
|
customAccent = "",
|
|
terminalSettings,
|
|
sessionId,
|
|
restoreState,
|
|
shellType,
|
|
lastCwd,
|
|
restoreTerminalCwd = false,
|
|
startupCommand,
|
|
noAutoRun,
|
|
multiLineRunMode,
|
|
pendingScriptId,
|
|
pendingScript,
|
|
reuseConnectionFromSessionId,
|
|
serialConfig,
|
|
hotkeyScheme = "disabled",
|
|
disableTerminalFontZoom = false,
|
|
keyBindings = [],
|
|
onHotkeyAction,
|
|
onTerminalFontSizeChange,
|
|
onStatusChange,
|
|
onSessionExit,
|
|
onTerminalDataCapture,
|
|
onOsDetected,
|
|
onCloseSession,
|
|
onUpdateHost,
|
|
onAddKnownHost,
|
|
onExpandToFocus,
|
|
onCommandExecuted,
|
|
onCommandSubmitted,
|
|
onSplitHorizontal,
|
|
onSplitVertical,
|
|
onOpenSftp,
|
|
onTerminalCwdChange,
|
|
onTerminalTitleChange,
|
|
onTerminalBell,
|
|
onTerminalOutput,
|
|
onTerminalContextReaderChange,
|
|
onOpenScripts,
|
|
onOpenHistory,
|
|
onOpenTheme,
|
|
onOpenSystem,
|
|
isBroadcastEnabled,
|
|
onToggleBroadcast,
|
|
onToggleComposeBar,
|
|
isWorkspaceComposeBarOpen,
|
|
onBroadcastInput,
|
|
onBroadcastInterruptPriorityChange,
|
|
onSnippetExecutorChange,
|
|
onProgrammaticCommandLogRewriteChange,
|
|
sessionLog,
|
|
sshDebugLogEnabled,
|
|
sudoAutofillPassword,
|
|
showSelectionAIAction = true,
|
|
onAddSelectionToAI,
|
|
sessionDisplayName,
|
|
onRename,
|
|
onDetach,
|
|
onStartSessionDrag,
|
|
onEndSessionDrag,
|
|
onDetachPointerDown,
|
|
onDetachDragStart,
|
|
onDetachDragEnd,
|
|
}) => {
|
|
const layoutSuppressActive = useTerminalLayoutSuppressActive();
|
|
const deferTerminalResize = isResizing || layoutSuppressActive;
|
|
const deferTerminalResizeRef = useRef(deferTerminalResize);
|
|
deferTerminalResizeRef.current = deferTerminalResize;
|
|
|
|
// Initial TCP dial timeout. Authentication prompts use their own backend timeout.
|
|
const CONNECTION_TIMEOUT = SSH_TCP_CONNECT_TIMEOUT_MS;
|
|
const { t } = useI18n();
|
|
const connectScriptsConsumedRef = useRef(false);
|
|
const connectScriptsCompletedIdsRef = useRef(new Set<string>());
|
|
const connectScriptsInFlightRef = useRef(false);
|
|
const pendingScriptRunIdRef = useRef<string | null>(null);
|
|
const pendingScriptHandledRef = useRef<Snippet | null>(null);
|
|
const [saveRecordingOpen, setSaveRecordingOpen] = useState(false);
|
|
const [recordedCode, setRecordedCode] = useState('');
|
|
const recorder = useScriptRecorder(sessionId);
|
|
const recorderRef = useRef(recorder);
|
|
recorderRef.current = recorder;
|
|
const passwordPromptActiveRef = useRef(false);
|
|
const [activeScriptRun, setActiveScriptRun] = useState<import('@/types/global/netcatty-bridge-script.d.ts').ScriptRun | undefined>(undefined);
|
|
const dismissedScriptRunIdRef = useRef<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
return subscribeScriptRuns((runs) => {
|
|
const sessionRuns = runs.filter((run) => run.sessionId === sessionId);
|
|
const liveRun = sessionRuns.find((run) => run.status === 'running' || run.status === 'paused');
|
|
if (liveRun) {
|
|
dismissedScriptRunIdRef.current = null;
|
|
setActiveScriptRun(liveRun);
|
|
return;
|
|
}
|
|
|
|
const finishedRun = sessionRuns
|
|
.filter((run) =>
|
|
(run.status === 'completed' || run.status === 'failed')
|
|
&& run.runId !== dismissedScriptRunIdRef.current,
|
|
)
|
|
.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))[0];
|
|
|
|
setActiveScriptRun(finishedRun);
|
|
});
|
|
}, [sessionId]);
|
|
|
|
const dismissScriptOverlay = useCallback(() => {
|
|
if (activeScriptRun) {
|
|
dismissedScriptRunIdRef.current = activeScriptRun.runId;
|
|
}
|
|
setActiveScriptRun(undefined);
|
|
}, [activeScriptRun]);
|
|
const outputTriggers = useOutputTriggers({
|
|
sessionId,
|
|
hostId: host.id,
|
|
snippets,
|
|
onRunScript: (snippet, sid) => runAutomationScript({ snippet, sessionId: sid }).catch((err) => {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message);
|
|
throw err;
|
|
}),
|
|
});
|
|
const appendOutputTriggerOutputRef = useRef(outputTriggers.appendOutput);
|
|
appendOutputTriggerOutputRef.current = outputTriggers.appendOutput;
|
|
const noteOutputTriggerUserInputRef = useRef(outputTriggers.noteUserInput);
|
|
noteOutputTriggerUserInputRef.current = outputTriggers.noteUserInput;
|
|
const availableFonts = useAvailableFonts();
|
|
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 terminalCwdTracker = useMemo(() => createTerminalCwdTracker(), []);
|
|
const knownCwdRef = useRef<string | undefined>(undefined);
|
|
const disposeDataRef = useRef<(() => void) | null>(null);
|
|
const disposeExitRef = useRef<(() => void) | null>(null);
|
|
const disposeTelnetEchoModeRef = useRef<(() => void) | null>(null);
|
|
const hibernatedRef = useRef(false);
|
|
const softHiddenRef = useRef(false);
|
|
const hasRuntimeRef = useRef(false);
|
|
const hibernateSnapshotRef = useRef("");
|
|
const hibernateViewportSnapshotRef = useRef("");
|
|
const hibernateScrollbackSnapshotRef = useRef("");
|
|
const hibernateContextSnapshotRef = useRef("");
|
|
const hibernateContextViewportSnapshotRef = useRef("");
|
|
const hibernateContextScrollbackSnapshotRef = useRef("");
|
|
const hibernatePendingBufferRef = useRef("");
|
|
const hibernateAlternateScreenRef = useRef(false);
|
|
const wakeInProgressRef = useRef(false);
|
|
const sessionRef = useRef<string | null>(null);
|
|
const isBootActiveRef = useRef(false);
|
|
const hasConnectedRef = useRef(false);
|
|
const hasRunStartupCommandRef = useRef(false);
|
|
const restoreCwdIntentRef = useRef<{ cwd: string; command: string } | null>(null);
|
|
const suppressHostStartupCommandRef = useRef(false);
|
|
// Token for an in-flight retry chain. handleRetry sets this to a fresh
|
|
// symbol; any cancel/close/teardown/subsequent-retry invalidates it. The
|
|
// chained xterm.write callbacks verify the token before proceeding so a
|
|
// cancelled retry can't fire a startNewSession after the fact.
|
|
const retryTokenRef = useRef<symbol | null>(null);
|
|
const terminalDataCapturedRef = useRef(false);
|
|
const connectionLogBufferRef = useRef(createConnectionLogBuffer(MAX_CONNECTION_LOG_DATA_CHARS));
|
|
const terminalLogSanitizerRef = useRef(createReplaySafeTerminalLogSanitizer());
|
|
const commandLogRewriterRef = useRef(createProgrammaticCommandLogRewriter());
|
|
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
|
|
const commandBufferRef = useRef<string>("");
|
|
const promptLineBreakStateRef = useRef<PromptLineBreakState>(createPromptLineBreakState());
|
|
const [hasMouseTracking, setHasMouseTracking] = useState(false);
|
|
const mouseTrackingRef = useRef(false);
|
|
const serialLineBufferRef = useRef<string>("");
|
|
const telnetLocalEchoRef = useRef(false);
|
|
|
|
const terminalSettingsRef = useRef(terminalSettings);
|
|
terminalSettingsRef.current = terminalSettings;
|
|
const handleUpdateHostFromTerminal = useCallback((hostUpdate: TerminalHostUpdate) => {
|
|
onUpdateHost?.(hostUpdate as Host);
|
|
}, [onUpdateHost]);
|
|
onTerminalDataCaptureRef.current = onTerminalDataCapture;
|
|
const isVisibleRef = useRef(isVisible);
|
|
isVisibleRef.current = isVisible;
|
|
const pendingOutputScrollRef = useRef(false);
|
|
const lastFittedSizeRef = useRef<{ width: number; height: number } | null>(null);
|
|
const fontWeightFixupDoneRef = useRef(false);
|
|
|
|
const captureTerminalLogData = useCallback((data: string) => {
|
|
const readableCommandData = commandLogRewriterRef.current.append(data);
|
|
const replaySafeData = terminalLogSanitizerRef.current.append(readableCommandData);
|
|
if (!replaySafeData) return;
|
|
connectionLogBufferRef.current.append(replaySafeData);
|
|
}, []);
|
|
|
|
const finalizeTerminalLogData = useCallback(() => {
|
|
const readableCommandData = commandLogRewriterRef.current.finish();
|
|
if (readableCommandData) {
|
|
const replaySafeData = terminalLogSanitizerRef.current.append(readableCommandData);
|
|
if (replaySafeData) {
|
|
connectionLogBufferRef.current.append(replaySafeData);
|
|
}
|
|
}
|
|
const replaySafeData = terminalLogSanitizerRef.current.finish();
|
|
if (replaySafeData) {
|
|
connectionLogBufferRef.current.append(replaySafeData);
|
|
}
|
|
return connectionLogBufferRef.current.toString();
|
|
}, []);
|
|
|
|
const readTerminalContext = useCallback<TerminalContextReader>(async (request) => {
|
|
if (request.sessionId !== sessionId) {
|
|
return { ok: false, error: `Terminal context reader is registered for "${sessionId}", not "${request.sessionId}".` };
|
|
}
|
|
|
|
const range = normalizeTerminalContextRange(request.range);
|
|
const term = termRef.current;
|
|
if (term) {
|
|
const alternateScreen = isTerminalAlternateScreenActive(term);
|
|
const activeBuffer = term.buffer.active as typeof term.buffer.active & {
|
|
viewportY?: number;
|
|
};
|
|
const totalLines = Math.max(0, activeBuffer.length);
|
|
const viewportStartLine = alternateScreen
|
|
? 0
|
|
: Math.max(0, activeBuffer.viewportY ?? Math.max(0, totalLines - term.rows));
|
|
const viewportEndLine = totalLines > 0
|
|
? Math.min(totalLines - 1, viewportStartLine + Math.max(1, term.rows) - 1)
|
|
: -1;
|
|
const lineWindow = resolveTerminalContextLineWindow({
|
|
range,
|
|
totalLines,
|
|
viewportStartLine,
|
|
viewportEndLine,
|
|
startLine: request.startLine,
|
|
maxLines: request.maxLines,
|
|
});
|
|
let content = '';
|
|
if (lineWindow.endLine >= lineWindow.startLine) {
|
|
content = readActiveTerminalBufferTextRange(term, {
|
|
startLine: lineWindow.startLine,
|
|
endLine: lineWindow.endLine,
|
|
});
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
sessionId,
|
|
label: sessionDisplayName ?? host.label,
|
|
range,
|
|
content,
|
|
totalLines,
|
|
startLine: lineWindow.startLine,
|
|
endLine: lineWindow.endLine,
|
|
returnedLines: lineWindow.endLine >= lineWindow.startLine
|
|
? lineWindow.endLine - lineWindow.startLine + 1
|
|
: 0,
|
|
hasMoreBefore: lineWindow.startLine > 0,
|
|
hasMoreAfter: lineWindow.endLine >= 0 && lineWindow.endLine < totalLines - 1,
|
|
source: 'live',
|
|
alternateScreen,
|
|
};
|
|
}
|
|
|
|
const snapshot = buildTerminalContextSnapshotText({
|
|
scrollbackText: hibernateContextScrollbackSnapshotRef.current,
|
|
viewportText: hibernateContextViewportSnapshotRef.current,
|
|
pendingText: hibernatePendingBufferRef.current,
|
|
});
|
|
const fullText = snapshot.fullText || hibernateContextSnapshotRef.current;
|
|
|
|
if (!fullText) {
|
|
return { ok: false, error: `Terminal session "${sessionId}" has no readable terminal buffer yet.` };
|
|
}
|
|
|
|
return buildTerminalContextReadResult({
|
|
sessionId,
|
|
label: sessionDisplayName ?? host.label,
|
|
fullText,
|
|
range,
|
|
startLine: request.startLine,
|
|
maxLines: request.maxLines,
|
|
source: 'snapshot',
|
|
alternateScreen: hibernateAlternateScreenRef.current,
|
|
viewportStartLine: snapshot.viewportStartLine,
|
|
viewportEndLine: snapshot.viewportEndLine,
|
|
});
|
|
}, [host.label, sessionDisplayName, sessionId]);
|
|
|
|
useEffect(() => {
|
|
onTerminalContextReaderChange?.(sessionId, readTerminalContext);
|
|
return () => onTerminalContextReaderChange?.(sessionId, null);
|
|
}, [onTerminalContextReaderChange, readTerminalContext, sessionId]);
|
|
|
|
useEffect(() => {
|
|
commandLogRewriterRef.current = createProgrammaticCommandLogRewriter();
|
|
}, [sessionId]);
|
|
|
|
const queueProgrammaticCommandLogRewrite = useCallback((rewrite: ProgrammaticCommandLogRewrite) => {
|
|
commandLogRewriterRef.current.queueRewrite(rewrite);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
onProgrammaticCommandLogRewriteChange?.(sessionId, queueProgrammaticCommandLogRewrite);
|
|
return () => onProgrammaticCommandLogRewriteChange?.(sessionId, null);
|
|
}, [onProgrammaticCommandLogRewriteChange, queueProgrammaticCommandLogRewrite, sessionId]);
|
|
|
|
const writeLocalTerminalData = useCallback((data: string) => {
|
|
if (!data) return;
|
|
captureTerminalLogData(data);
|
|
termRef.current?.write(data);
|
|
}, [captureTerminalLogData]);
|
|
|
|
const hotkeySchemeRef = useRef(hotkeyScheme);
|
|
const disableTerminalFontZoomRef = useRef(disableTerminalFontZoom);
|
|
const keyBindingsRef = useRef(keyBindings);
|
|
const onHotkeyActionRef = useRef(onHotkeyAction);
|
|
hotkeySchemeRef.current = hotkeyScheme;
|
|
disableTerminalFontZoomRef.current = disableTerminalFontZoom;
|
|
keyBindingsRef.current = keyBindings;
|
|
onHotkeyActionRef.current = onHotkeyAction;
|
|
|
|
const isBroadcastEnabledRef = useRef(isBroadcastEnabled);
|
|
const onBroadcastInputRef = useRef(onBroadcastInput);
|
|
isBroadcastEnabledRef.current = isBroadcastEnabled;
|
|
onBroadcastInputRef.current = onBroadcastInput;
|
|
|
|
// Snippets ref for shortkey support in terminal
|
|
const snippetsRef = useRef(snippets);
|
|
snippetsRef.current = snippets;
|
|
|
|
// Autocomplete handler refs — populated by <TerminalAutocomplete> so the
|
|
// xterm runtime (and a few effects here) can drive the hook without making
|
|
// Terminal re-render on every suggestion update.
|
|
const autocompleteKeyEventRef = useRef<((e: KeyboardEvent) => boolean) | undefined>(undefined);
|
|
const autocompleteInputRef = useRef<((data: string) => void) | undefined>(undefined);
|
|
const autocompleteRepositionRef = useRef<(() => void) | undefined>(undefined);
|
|
const autocompleteCloseRef = useRef<(() => void) | undefined>(undefined);
|
|
const sudoHintRef = useRef<((active: boolean) => boolean) | undefined>(undefined);
|
|
|
|
const terminalBackend = useTerminalBackend();
|
|
const { startManualSessionLog, stopManualSessionLog, getManualSessionLogStatus } = useSessionLogBackend();
|
|
const {
|
|
resizeSession,
|
|
receiveSerialYmodem,
|
|
selectDirectory,
|
|
selectDirectoryAvailable,
|
|
selectFile,
|
|
selectFileAvailable,
|
|
sendSerialYmodem,
|
|
serialYmodemAvailable,
|
|
serialYmodemReceiveAvailable,
|
|
setSessionEncoding,
|
|
} = terminalBackend;
|
|
|
|
|
|
|
|
// isScriptsOpen state removed - scripts now handled by side panel
|
|
const [status, setStatus] = useState<TerminalSession["status"]>(() => (
|
|
getInitialTerminalStatus()
|
|
));
|
|
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 [isSessionLogging, setIsSessionLogging] = useState(false);
|
|
const [progressValue, setProgressValue] = useState(15);
|
|
const [hasSelection, setHasSelection] = useState(false);
|
|
const [selectionOverlayPosition, setSelectionOverlayPosition] = useState<{ left: number; top: number } | null>(null);
|
|
const [isDisconnectedDialogDismissed, setIsDisconnectedDialogDismissed] = useState(false);
|
|
const [connectionReuseFellBack, setConnectionReuseFellBack] = useState(false);
|
|
|
|
const statusRef = useRef<TerminalSession["status"]>(status);
|
|
statusRef.current = status;
|
|
const getSessionConnectedRef = useRef(() => statusRef.current === "connected" && Boolean(sessionRef.current));
|
|
getSessionConnectedRef.current = () => statusRef.current === "connected" && Boolean(sessionRef.current);
|
|
const sudoAutofillRef = useRef<SudoPasswordAutofill | null>(null);
|
|
const sudoAutofillPasswordRef = useRef(sudoAutofillPassword);
|
|
sudoAutofillPasswordRef.current = sudoAutofillPassword;
|
|
|
|
const [chainProgress, setChainProgress] = useState<{
|
|
currentHop: number;
|
|
totalHops: number;
|
|
currentHostLabel: string;
|
|
} | null>(null);
|
|
const [isConnectionAwaitingUserInput, setIsConnectionAwaitingUserInput] = useState(false);
|
|
const [isConnectionPastTcpDial, setIsConnectionPastTcpDial] = useState(false);
|
|
|
|
// pendingUploadEntries removed - drag-drop uploads now handled by SftpSidePanel
|
|
const [isComposeBarOpen, setIsComposeBarOpen] = useStoredBoolean(
|
|
STORAGE_KEY_TERMINAL_COMPOSE_BAR_OPEN,
|
|
false,
|
|
);
|
|
const terminalEncodingStorageKey = createTerminalEncodingStorageKey(
|
|
STORAGE_KEY_TERMINAL_ENCODING_BY_HOST_PREFIX,
|
|
host,
|
|
);
|
|
const initialRememberedTerminalEncoding = readOptionalStoredStringValue(
|
|
terminalEncodingStorageKey,
|
|
isTerminalEncodingPreference,
|
|
);
|
|
const [, setRememberedTerminalEncoding] = useStoredString(
|
|
terminalEncodingStorageKey,
|
|
'utf-8',
|
|
isTerminalEncodingPreference,
|
|
);
|
|
const [terminalEncoding, setTerminalEncoding] = useState<TerminalEncodingPreference>(() => {
|
|
return resolveInitialTerminalEncoding(
|
|
host?.charset,
|
|
initialRememberedTerminalEncoding,
|
|
);
|
|
});
|
|
const terminalEncodingRef = useRef(terminalEncoding);
|
|
terminalEncodingRef.current = terminalEncoding;
|
|
const hasRememberedTerminalEncodingRef = useRef(initialRememberedTerminalEncoding !== null);
|
|
// True only after the user actively picks an encoding from the toolbar.
|
|
// onSessionAttached uses this to decide whether to override the backend's
|
|
// initial charset for telnet/serial reconnects — on a first attach we
|
|
// must not overwrite arbitrary host.charset values (latin1/shift_jis/...)
|
|
// that the UI's two-value state can't represent.
|
|
const userPickedEncodingRef = useRef(false);
|
|
|
|
const terminalSearch = useTerminalSearch({ searchAddonRef, termRef });
|
|
const {
|
|
isSearchOpen,
|
|
searchMatchCount,
|
|
searchFocusToken,
|
|
requestSearchFocus,
|
|
handleToggleSearch,
|
|
handleSearch,
|
|
handleFindNext,
|
|
handleFindPrevious,
|
|
handleCloseSearch,
|
|
} = terminalSearch;
|
|
|
|
const prepareProgrammaticSudoInput = useCallback((data: string): string => {
|
|
if (
|
|
statusRef.current !== "connected" ||
|
|
(isBroadcastEnabledRef.current && onBroadcastInputRef.current)
|
|
) {
|
|
return data;
|
|
}
|
|
const pastedCommand = data.match(/^([^\r\n]+)(\r\n|\r|\n)$/);
|
|
if (!pastedCommand || !shouldRecordShellHistory(pastedCommand[1], termRef.current)) {
|
|
return data;
|
|
}
|
|
prepareSudoAutofillInput(data, null, sudoAutofillRef.current);
|
|
return data;
|
|
}, []);
|
|
|
|
// Terminal autocomplete — onAcceptText writes directly to session (no CustomEvent)
|
|
const autocompleteAcceptTextRef = useRef<((text: string) => void) | undefined>(undefined);
|
|
autocompleteAcceptTextRef.current = (text: string) => {
|
|
const id = sessionRef.current;
|
|
if (id && text) {
|
|
let textToWrite = text;
|
|
let handledSubmittedInput = false;
|
|
if (
|
|
host.protocol !== "serial" &&
|
|
statusRef.current === "connected" &&
|
|
!(isBroadcastEnabledRef.current && onBroadcastInputRef.current)
|
|
) {
|
|
const preparedText = prepareProgrammaticSudoInput(text);
|
|
handledSubmittedInput = preparedText !== text;
|
|
textToWrite = preparedText;
|
|
}
|
|
|
|
// Serial line mode: buffer text and handle local echo instead of direct send
|
|
if (host.protocol === "serial" && serialConfig?.lineMode) {
|
|
for (const ch of text) {
|
|
if (ch === "\r") {
|
|
const line = serialLineBufferRef.current + "\r";
|
|
terminalBackend.writeToSession(id, line);
|
|
serialLineBufferRef.current = "";
|
|
if (serialConfig?.localEcho) writeLocalTerminalData("\r\n");
|
|
} else if (ch === "\x15") {
|
|
if (serialConfig?.localEcho && serialLineBufferRef.current.length > 0) {
|
|
writeLocalTerminalData("\b \b".repeat(serialLineBufferRef.current.length));
|
|
}
|
|
serialLineBufferRef.current = "";
|
|
} else if (ch === "\b" || ch === "\x7f") {
|
|
if (serialLineBufferRef.current.length > 0) {
|
|
serialLineBufferRef.current = serialLineBufferRef.current.slice(0, -1);
|
|
if (serialConfig?.localEcho) writeLocalTerminalData("\b \b");
|
|
}
|
|
} else if (ch.charCodeAt(0) >= 32) {
|
|
serialLineBufferRef.current += ch;
|
|
if (serialConfig?.localEcho) writeLocalTerminalData(ch);
|
|
}
|
|
}
|
|
// Still update commandBuffer and broadcast for serial line mode
|
|
// (fall through to shared bookkeeping below — don't return early)
|
|
} else if (host.protocol === "serial" && serialConfig?.localEcho) {
|
|
// Serial character mode with local echo: echo accepted text locally
|
|
terminalBackend.writeToSession(id, textToWrite);
|
|
for (const ch of text) {
|
|
if (ch === "\r") {
|
|
writeLocalTerminalData("\r\n");
|
|
} else if (ch.charCodeAt(0) >= 32) {
|
|
writeLocalTerminalData(ch);
|
|
}
|
|
}
|
|
} else {
|
|
terminalBackend.writeToSession(id, textToWrite);
|
|
}
|
|
|
|
// Broadcast to other sessions if broadcast mode is enabled
|
|
if (isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
|
|
onBroadcastInputRef.current(text, sessionId);
|
|
}
|
|
|
|
// Update command buffer for onCommandExecuted tracking
|
|
for (const ch of text) {
|
|
if (handledSubmittedInput) {
|
|
commandBufferRef.current = "";
|
|
break;
|
|
} else if (ch === "\r" || ch === "\n") {
|
|
const rawCommand = commandBufferRef.current;
|
|
if (recorderRef.current.isRecording) {
|
|
void recorderRef.current.recordEnter({
|
|
sensitive: passwordPromptActiveRef.current,
|
|
});
|
|
passwordPromptActiveRef.current = false;
|
|
}
|
|
recordTerminalCommandExecution(rawCommand, {
|
|
host,
|
|
sessionId,
|
|
onCommandExecuted,
|
|
onCommandSubmitted,
|
|
commandBufferRef,
|
|
promptLineBreakStateRef,
|
|
}, termRef.current);
|
|
} else if (ch === "\x15") {
|
|
// Ctrl+U: clear line — reset command buffer (fuzzy match sends this)
|
|
commandBufferRef.current = "";
|
|
recorderRef.current.recordClearLine();
|
|
} else if (ch === "\b" || ch === "\x7f") {
|
|
// Backspace: remove last character (Windows fuzzy replacement uses \b)
|
|
commandBufferRef.current = commandBufferRef.current.slice(0, -1);
|
|
recorderRef.current.recordBackspace();
|
|
} else if (ch.charCodeAt(0) >= 32) {
|
|
commandBufferRef.current += ch;
|
|
recorderRef.current.recordInput(ch);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Autocomplete config — the hook itself lives in <TerminalAutocomplete> so
|
|
// its state updates don't re-render this component (see render below).
|
|
// For local protocol the effective OS is the client OS: synthetic fallback
|
|
// hosts (TerminalLayer) and saved-host defaults (HostDetailsPanel) both
|
|
// stamp os: "linux", which mis-routes the autocomplete clear sequence to
|
|
// Ctrl-U on Windows where cmd/PowerShell render it literally (#1112).
|
|
const autocompleteHostOs: "linux" | "windows" | "macos" = host.protocol === "local"
|
|
? detectLocalOs(navigator.userAgent || navigator.platform)
|
|
: (host.os || "linux");
|
|
const autocompleteSettings = resolveTerminalAutocompleteSettings({
|
|
protocol: host.protocol,
|
|
terminalSettings,
|
|
});
|
|
|
|
const resolveSftpInitialPath = useCallback(async (options?: { preferFreshBackend?: boolean }): Promise<string | undefined> => {
|
|
const cwd = await resolvePreferredTerminalCwd({
|
|
rendererCwd: terminalCwdTracker.getRendererCwd(),
|
|
sessionId: sessionRef.current,
|
|
getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options),
|
|
preferFreshBackend: options?.preferFreshBackend,
|
|
});
|
|
return cwd ?? undefined;
|
|
}, [terminalBackend, terminalCwdTracker]);
|
|
|
|
const clearTerminalCwd = useCallback((options?: { persistRestoreMetadata?: boolean }) => {
|
|
terminalCwdTracker.clearRendererCwd();
|
|
knownCwdRef.current = undefined;
|
|
if (options?.persistRestoreMetadata === false) return;
|
|
onTerminalCwdChange?.(sessionId, null);
|
|
}, [onTerminalCwdChange, sessionId, terminalCwdTracker]);
|
|
|
|
// Classify the host's device family from the *detected* distro and the
|
|
// explicit deviceType only. This intentionally bypasses
|
|
// getEffectiveHostDistro(): the manual distro override (`distroMode:
|
|
// 'manual'` + `manualDistro`) is a purely cosmetic icon choice, and a
|
|
// user who pinned e.g. an "ubuntu" icon on what is actually a Cisco /
|
|
// Huawei host must not silently re-enable POSIX-shell probes against it.
|
|
// Several features gate on this — the working-directory probe below, the
|
|
// /etc/os-release probe, and the periodic server-stats poll (#674) —
|
|
// because each opens an extra exec channel that strict network-device
|
|
// CLIs reject or log as a new AAA session, and on Huawei VRP closes the
|
|
// whole session (#1043).
|
|
const detectedDeviceClass = classifyDistroId(host.distro);
|
|
const isNetworkDevice =
|
|
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
|
|
const remoteDragDropUsesZmodem = supportsZmodemTerminalDragDrop(host, isNetworkDevice);
|
|
|
|
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
|
|
const isLocalConnection = host.protocol === "local";
|
|
const isSerialConnection = host.protocol === "serial";
|
|
const supportsRemoteImagePaste =
|
|
!isLocalConnection &&
|
|
!isSerialConnection &&
|
|
host.protocol !== "telnet" &&
|
|
host.protocol !== "mosh" &&
|
|
!host.moshEnabled &&
|
|
host.protocol !== "et" &&
|
|
!host.etEnabled;
|
|
|
|
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, never for
|
|
// network devices. See isNetworkDevice above for why the gating uses the
|
|
// raw detected distro / explicit deviceType (not getEffectiveHostDistro);
|
|
// #674 covers the AAA-log-flood motivation for stats specifically.
|
|
const isSupportedOs =
|
|
!isNetworkDevice &&
|
|
(host.os === 'linux' || host.os === 'macos' || detectedDeviceClass === 'linux-like');
|
|
const isSystemSidebarEligible =
|
|
!!onOpenSystem &&
|
|
isSupportedOs &&
|
|
!isLocalConnection &&
|
|
!isSerialConnection &&
|
|
host.protocol !== 'telnet';
|
|
// Server-stats polling now lives inside <TerminalServerStats> (rendered by
|
|
// TerminalView) so its ~5s refresh only re-renders that widget, not the whole
|
|
// terminal. We just forward `isSupportedOs` via ctx.
|
|
|
|
const zmodem = useZmodemTransfer(sessionId);
|
|
const [ymodemInProgress, setYmodemInProgress] = useState(false);
|
|
|
|
const zmodemToastedRef = useRef(false);
|
|
|
|
const pendingAuthRef = useRef<PendingAuth>(null);
|
|
useEffect(() => {
|
|
sudoAutofillRef.current?.updatePassword(sudoAutofillPassword);
|
|
}, [sudoAutofillPassword]);
|
|
const sessionStartersRef = useRef<ReturnType<typeof createTerminalSessionStarters> | null>(null);
|
|
const auth = useTerminalAuthState({
|
|
host,
|
|
pendingAuthRef,
|
|
termRef,
|
|
onUpdateHost: handleUpdateHostFromTerminal,
|
|
onStartSession: (term) => {
|
|
const starters = sessionStartersRef.current;
|
|
if (!starters) return;
|
|
if (host.moshEnabled) {
|
|
starters.startMosh(term);
|
|
return;
|
|
}
|
|
if (host.etEnabled) {
|
|
starters.startEt(term);
|
|
return;
|
|
}
|
|
starters.startSSH(term);
|
|
},
|
|
setStatus: (next) => setStatus(next),
|
|
setProgressLogs,
|
|
});
|
|
|
|
const [needsHostKeyVerification, setNeedsHostKeyVerification] = useState(false);
|
|
const [pendingHostKeyInfo, setPendingHostKeyInfo] = useState<HostKeyInfo | null>(null);
|
|
const [pendingHostKeyRequestId, setPendingHostKeyRequestId] = useState<string | null>(null);
|
|
const pendingConnectionRef = useRef<(() => void) | null>(null);
|
|
|
|
// OSC-52 clipboard read prompt
|
|
const [osc52ReadPromptVisible, setOsc52ReadPromptVisible] = useState(false);
|
|
const osc52ReadResolverRef = useRef<((allowed: boolean) => void) | null>(null);
|
|
const [osc7SetupOpen, setOsc7SetupOpen] = useState(false);
|
|
const [osc7SetupRunning, setOsc7SetupRunning] = useState(false);
|
|
const handleOsc52ReadRequest = useCallback((): Promise<boolean> => {
|
|
// Reject if terminal is not visible (background tab) — user can't see the prompt
|
|
if (!isVisibleRef.current) return Promise.resolve(false);
|
|
// Reject if another prompt is already pending (avoid resolver overwrite)
|
|
if (osc52ReadResolverRef.current) return Promise.resolve(false);
|
|
return new Promise((resolve) => {
|
|
osc52ReadResolverRef.current = resolve;
|
|
setOsc52ReadPromptVisible(true);
|
|
});
|
|
}, []);
|
|
const handleOsc52ReadResponse = useCallback((allowed: boolean) => {
|
|
setOsc52ReadPromptVisible(false);
|
|
osc52ReadResolverRef.current?.(allowed);
|
|
osc52ReadResolverRef.current = null;
|
|
// Restore focus to terminal
|
|
termRef.current?.focus();
|
|
}, []);
|
|
|
|
const handleOsc7SetupOpenChange = useCallback((open: boolean) => {
|
|
setOsc7SetupOpen(open);
|
|
if (!open) {
|
|
queueMicrotask(() => termRef.current?.focus());
|
|
}
|
|
}, []);
|
|
|
|
const handleOsc7SetupConfirm = useCallback(() => {
|
|
if (status !== "connected") {
|
|
handleOsc7SetupOpenChange(false);
|
|
return;
|
|
}
|
|
if (osc7SetupRunning) return;
|
|
const currentCwd = terminalCwdTracker.getRendererCwd() ?? knownCwdRef.current;
|
|
if (!currentCwd) {
|
|
toast.error(t("terminal.osc7Setup.failed"));
|
|
return;
|
|
}
|
|
setOsc7SetupRunning(true);
|
|
void runOsc7SetupAction({
|
|
status,
|
|
sessionId,
|
|
setupCommand: buildOsc7SetupExecCommand(currentCwd),
|
|
setupOsc7Tracking: terminalBackend.setupOsc7Tracking,
|
|
writeToSession: terminalBackend.writeToSession,
|
|
writeLocalTerminalData,
|
|
}).then((result) => {
|
|
handleOsc7SetupOpenChange(false);
|
|
if (result.success) {
|
|
toast.success(t("terminal.osc7Setup.configured"));
|
|
return;
|
|
}
|
|
toast.error(result.error || t("terminal.osc7Setup.failed"));
|
|
}).catch((error) => {
|
|
toast.error(error instanceof Error ? error.message : t("terminal.osc7Setup.failed"));
|
|
}).finally(() => {
|
|
setOsc7SetupRunning(false);
|
|
});
|
|
}, [
|
|
handleOsc7SetupOpenChange,
|
|
osc7SetupRunning,
|
|
sessionId,
|
|
status,
|
|
t,
|
|
terminalCwdTracker,
|
|
terminalBackend.setupOsc7Tracking,
|
|
terminalBackend.writeToSession,
|
|
writeLocalTerminalData,
|
|
]);
|
|
|
|
const handleTopOverlayMouseDownCapture = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
if (e.button !== 0) return;
|
|
if (!shouldPreserveTerminalFocusOnMouseDown(e.target)) return;
|
|
e.preventDefault();
|
|
}, []);
|
|
|
|
// Subscribe to custom theme changes so editing triggers re-render
|
|
const customThemes = useCustomThemes();
|
|
const hasFontSizeOverride = host.fontSizeOverride === true || (host.fontSizeOverride === undefined && host.fontSize != null);
|
|
const hasFontFamilyOverride = host.fontFamilyOverride === true || (host.fontFamilyOverride === undefined && !!host.fontFamily);
|
|
const hasFontWeightOverride = host.fontWeightOverride === true || (host.fontWeightOverride === undefined && host.fontWeight != null);
|
|
const effectiveFontSize = useMemo(
|
|
() => (hasFontSizeOverride && host.fontSize != null ? host.fontSize : fontSize),
|
|
[fontSize, hasFontSizeOverride, host.fontSize],
|
|
);
|
|
const effectiveFontWeight = useMemo(
|
|
() => (hasFontWeightOverride && host.fontWeight != null ? host.fontWeight : (terminalSettings?.fontWeight ?? 400)),
|
|
[terminalSettings?.fontWeight, hasFontWeightOverride, host.fontWeight],
|
|
);
|
|
const resolvedFontFamily = useMemo(() => {
|
|
const hostFontId = hasFontFamilyOverride && host.fontFamily
|
|
? host.fontFamily
|
|
: fontFamilyId;
|
|
const resolvedFontId = resolveTerminalFontFamilyId(
|
|
hostFontId,
|
|
typeof navigator !== "undefined" ? navigator.platform : "",
|
|
);
|
|
const selectedFont = availableFonts.find((f) => f.id === resolvedFontId) || availableFonts[0];
|
|
const platform: SupportedPlatform =
|
|
typeof navigator !== "undefined" && /Mac/i.test(navigator.platform)
|
|
? "darwin"
|
|
: typeof navigator !== "undefined" && /Win/i.test(navigator.platform)
|
|
? "win32"
|
|
: "linux";
|
|
return composeFontFamilyStack({
|
|
primaryFamily: selectedFont.family,
|
|
userFallback: terminalSettings?.fallbackFont ?? "",
|
|
latinFontId: resolvedFontId,
|
|
platform,
|
|
});
|
|
}, [availableFonts, fontFamilyId, hasFontFamilyOverride, host.fontFamily, terminalSettings?.fallbackFont]);
|
|
|
|
const effectiveTheme = useMemo(() => {
|
|
if (appearanceTheme) return appearanceTheme;
|
|
if (followAppTerminalTheme) {
|
|
return applyCustomAccentToTerminalTheme(terminalTheme, accentMode, customAccent);
|
|
}
|
|
const themeId = resolveHostTerminalThemeId(
|
|
{ theme: host.theme, themeOverride: host.themeOverride } as Pick<Host, 'theme' | 'themeOverride'>,
|
|
terminalTheme.id,
|
|
);
|
|
let baseTheme = terminalTheme;
|
|
if (themeId) {
|
|
const hostTheme = getBuiltinTerminalThemeById(themeId)
|
|
|| customThemes.find((t) => t.id === themeId);
|
|
if (hostTheme) baseTheme = hostTheme;
|
|
}
|
|
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
|
}, [accentMode, appearanceTheme, customAccent, customThemes, followAppTerminalTheme, host.theme, host.themeOverride, terminalTheme]);
|
|
|
|
const resolvedChainHosts =
|
|
chainHosts;
|
|
|
|
const updateStatus = (next: TerminalSession["status"]) => {
|
|
setStatus(next);
|
|
hasConnectedRef.current = next === "connected";
|
|
onStatusChange?.(sessionId, next);
|
|
};
|
|
const updateStatusRef = useRef(updateStatus);
|
|
updateStatusRef.current = updateStatus;
|
|
|
|
const prepareRestoredReconnect = useCallback(() => {
|
|
if (restoreState !== "restored-disconnected") {
|
|
suppressHostStartupCommandRef.current = false;
|
|
restoreCwdIntentRef.current = null;
|
|
return;
|
|
}
|
|
|
|
suppressHostStartupCommandRef.current = true;
|
|
restoreCwdIntentRef.current = resolveRestoreCwdIntent({
|
|
enabled: restoreTerminalCwd,
|
|
session: {
|
|
status: "disconnected",
|
|
restoreState,
|
|
protocol: host.protocol,
|
|
shellType,
|
|
lastCwd,
|
|
moshEnabled: host.moshEnabled,
|
|
etEnabled: host.etEnabled,
|
|
},
|
|
isNetworkDevice,
|
|
});
|
|
}, [
|
|
host.etEnabled,
|
|
host.moshEnabled,
|
|
host.protocol,
|
|
isNetworkDevice,
|
|
lastCwd,
|
|
restoreState,
|
|
restoreTerminalCwd,
|
|
shellType,
|
|
]);
|
|
|
|
const handleTerminalDataCaptureOnce = useCallback((
|
|
capturedSessionId: string,
|
|
data: string,
|
|
options?: { finalized?: boolean },
|
|
) => {
|
|
const captureHandler = onTerminalDataCaptureRef.current;
|
|
if (!captureHandler || terminalDataCapturedRef.current) return;
|
|
terminalDataCapturedRef.current = true;
|
|
const capturedData = options?.finalized
|
|
? data
|
|
: (finalizeTerminalLogData() || data);
|
|
captureHandler(capturedSessionId, capturedData);
|
|
}, [finalizeTerminalLogData]);
|
|
|
|
const cleanupSession = () => {
|
|
const closingSessionId = sessionRef.current;
|
|
disposeDataRef.current?.();
|
|
disposeDataRef.current = null;
|
|
disposeExitRef.current?.();
|
|
disposeExitRef.current = null;
|
|
disposeTelnetEchoModeRef.current?.();
|
|
disposeTelnetEchoModeRef.current = null;
|
|
telnetLocalEchoRef.current = false;
|
|
|
|
if (closingSessionId) {
|
|
const activeTerm = termRef.current;
|
|
if (activeTerm) {
|
|
releaseTerminalFlowBeforeHibernate(terminalBackend, activeTerm, closingSessionId, {
|
|
resumeBackend: false,
|
|
});
|
|
} else {
|
|
flushTerminalSessionFlowAck(closingSessionId);
|
|
clearTerminalSessionFlowAck(closingSessionId);
|
|
}
|
|
try {
|
|
terminalBackend.closeSession(closingSessionId);
|
|
} catch (err) {
|
|
logger.warn("Failed to close SSH session", err);
|
|
}
|
|
}
|
|
sessionRef.current = null;
|
|
};
|
|
|
|
const disposeRuntimeOnly = () => {
|
|
xtermRuntimeRef.current?.dispose();
|
|
xtermRuntimeRef.current = null;
|
|
termRef.current = null;
|
|
fitAddonRef.current = null;
|
|
serializeAddonRef.current = null;
|
|
searchAddonRef.current = null;
|
|
hasRuntimeRef.current = false;
|
|
};
|
|
|
|
const forceCloseHibernatedSession = useCallback(() => {
|
|
if (!terminalDataCapturedRef.current) {
|
|
const hibernatedData = hibernateSnapshotRef.current + hibernatePendingBufferRef.current;
|
|
if (hibernatedData) {
|
|
handleTerminalDataCaptureOnce(sessionId, hibernatedData);
|
|
}
|
|
}
|
|
disposeDataRef.current?.();
|
|
disposeDataRef.current = null;
|
|
disposeExitRef.current?.();
|
|
disposeExitRef.current = null;
|
|
disposeTelnetEchoModeRef.current?.();
|
|
disposeTelnetEchoModeRef.current = null;
|
|
telnetLocalEchoRef.current = false;
|
|
const closingSessionId = sessionRef.current;
|
|
if (closingSessionId) {
|
|
flushTerminalSessionFlowAck(closingSessionId);
|
|
clearTerminalSessionFlowAck(closingSessionId);
|
|
try {
|
|
terminalBackend.closeSession(closingSessionId);
|
|
} catch (err) {
|
|
logger.warn("Failed to close hibernated session", err);
|
|
}
|
|
}
|
|
sessionRef.current = null;
|
|
hibernatedRef.current = false;
|
|
softHiddenRef.current = false;
|
|
hibernateSnapshotRef.current = "";
|
|
hibernateViewportSnapshotRef.current = "";
|
|
hibernateScrollbackSnapshotRef.current = "";
|
|
hibernateContextSnapshotRef.current = "";
|
|
hibernateContextViewportSnapshotRef.current = "";
|
|
hibernateContextScrollbackSnapshotRef.current = "";
|
|
hibernatePendingBufferRef.current = "";
|
|
hibernateAlternateScreenRef.current = false;
|
|
terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
|
}, [handleTerminalDataCaptureOnce, sessionId, terminalBackend]);
|
|
|
|
const beginHibernatedSessionListeners = useCallback((backendId: string) => {
|
|
disposeDataRef.current?.();
|
|
flushTerminalSessionFlowAck(backendId);
|
|
terminalBackend.setSessionFlowPaused?.(backendId, false);
|
|
hibernatePendingBufferRef.current = "";
|
|
disposeDataRef.current = terminalBackend.onSessionData(
|
|
backendId,
|
|
(chunk) => {
|
|
hibernatePendingBufferRef.current = appendHibernatePendingBuffer(
|
|
hibernatePendingBufferRef.current,
|
|
chunk,
|
|
);
|
|
ackTerminalSessionFlow(terminalBackend, backendId, chunk.length);
|
|
},
|
|
{ replayBacklog: true },
|
|
);
|
|
|
|
disposeExitRef.current?.();
|
|
disposeExitRef.current = terminalBackend.onSessionExit(backendId, (evt) => {
|
|
disposeTelnetEchoModeRef.current?.();
|
|
disposeTelnetEchoModeRef.current = null;
|
|
telnetLocalEchoRef.current = false;
|
|
updateStatusRef.current("disconnected");
|
|
if (evt.error) {
|
|
setError(evt.error);
|
|
}
|
|
const exitMessage = `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
|
|
hibernatePendingBufferRef.current = appendHibernatePendingBuffer(
|
|
hibernatePendingBufferRef.current,
|
|
exitMessage,
|
|
);
|
|
onSessionExit?.(sessionId, evt);
|
|
});
|
|
}, [onSessionExit, sessionId, terminalBackend]);
|
|
|
|
const applyHibernateSnapshot = useCallback((
|
|
snapshot: {
|
|
snapshot: string;
|
|
viewportSnapshot: string;
|
|
scrollbackSnapshot: string;
|
|
contextSnapshot?: string;
|
|
contextViewportSnapshot?: string;
|
|
contextScrollbackSnapshot?: string;
|
|
alternateScreen: boolean;
|
|
},
|
|
) => {
|
|
hibernateSnapshotRef.current = snapshot.snapshot;
|
|
hibernateViewportSnapshotRef.current = snapshot.viewportSnapshot;
|
|
hibernateScrollbackSnapshotRef.current = snapshot.scrollbackSnapshot;
|
|
hibernateContextSnapshotRef.current = snapshot.contextSnapshot ?? "";
|
|
hibernateContextViewportSnapshotRef.current = snapshot.contextViewportSnapshot ?? "";
|
|
hibernateContextScrollbackSnapshotRef.current = snapshot.contextScrollbackSnapshot ?? "";
|
|
hibernateAlternateScreenRef.current = snapshot.alternateScreen;
|
|
}, []);
|
|
|
|
const fullHibernateRuntime = useCallback(async () => {
|
|
if (hibernatedRef.current || softHiddenRef.current || !termRef.current || !serializeAddonRef.current) return;
|
|
const backendId = sessionRef.current;
|
|
if (!backendId) return;
|
|
|
|
terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
|
softHiddenRef.current = false;
|
|
|
|
const snapshot = await serializeTerminalForHibernate(
|
|
termRef.current,
|
|
serializeAddonRef.current,
|
|
{ preferWasm: resolveHibernatePreferWasmSerialize(terminalSettings) },
|
|
);
|
|
|
|
if (snapshot.alternateScreen && snapshot.snapshot.length === 0) {
|
|
logger.info("[Terminal] Skipping hibernate: alternate screen snapshot unavailable", { sessionId });
|
|
return;
|
|
}
|
|
|
|
applyHibernateSnapshot(snapshot);
|
|
isBootActiveRef.current = false;
|
|
if (termRef.current) {
|
|
releaseTerminalFlowBeforeHibernate(terminalBackend, termRef.current, backendId);
|
|
}
|
|
disposeDataRef.current?.();
|
|
disposeDataRef.current = null;
|
|
disposeExitRef.current?.();
|
|
disposeExitRef.current = null;
|
|
disposeRuntimeOnly();
|
|
beginHibernatedSessionListeners(backendId);
|
|
hibernatedRef.current = true;
|
|
logger.info("[Terminal] Hibernated runtime", {
|
|
sessionId,
|
|
snapshotChars: hibernateSnapshotRef.current.length,
|
|
viewportChars: hibernateViewportSnapshotRef.current.length,
|
|
scrollbackChars: hibernateScrollbackSnapshotRef.current.length,
|
|
alternateScreen: snapshot.alternateScreen,
|
|
});
|
|
}, [
|
|
applyHibernateSnapshot,
|
|
beginHibernatedSessionListeners,
|
|
sessionId,
|
|
terminalBackend,
|
|
terminalSettings,
|
|
]);
|
|
|
|
const hideRuntimeOnly = useCallback(() => {
|
|
if (hibernatedRef.current || softHiddenRef.current || !hasRuntimeRef.current) return;
|
|
xtermRuntimeRef.current?.suspendWebglRenderer();
|
|
terminalHiddenRendererStore.markSoftHidden(sessionId);
|
|
softHiddenRef.current = true;
|
|
logger.info("[Terminal] Soft-hidden runtime", { sessionId });
|
|
}, [sessionId]);
|
|
|
|
const hibernateRuntime = useCallback(() => {
|
|
if (hibernatedRef.current || softHiddenRef.current || !termRef.current) return;
|
|
|
|
if (
|
|
isTerminalAlternateScreenActive(termRef.current)
|
|
&& resolveHibernateSkipAltScreen(terminalSettings)
|
|
) {
|
|
logger.info("[Terminal] Skipping hibernate: alternate screen active", { sessionId });
|
|
return;
|
|
}
|
|
|
|
const keepCount = resolveHibernateKeepRendererCount(terminalSettings);
|
|
if (keepCount > 0 && terminalHiddenRendererStore.getSoftHiddenCount() < keepCount) {
|
|
hideRuntimeOnly();
|
|
return;
|
|
}
|
|
|
|
if (keepCount > 0) {
|
|
const victim = terminalHiddenRendererStore.pickEvictionCandidate(keepCount);
|
|
if (victim && victim !== sessionId) {
|
|
terminalHiddenRendererStore.requestEviction(victim);
|
|
}
|
|
}
|
|
|
|
void fullHibernateRuntime();
|
|
}, [fullHibernateRuntime, hideRuntimeOnly, sessionId, terminalSettings]);
|
|
|
|
const terminalRuntimeRefs = useMemo<TerminalRuntimeRefs>(() => ({
|
|
xtermRuntimeRef,
|
|
termRef,
|
|
fitAddonRef,
|
|
serializeAddonRef,
|
|
searchAddonRef,
|
|
hasRuntimeRef,
|
|
}), []);
|
|
|
|
const xTermRuntimeContextRef = useRef<Omit<CreateXTermRuntimeContext, "container" | "initiallyVisible"> | null>(null);
|
|
|
|
const teardown = () => {
|
|
isBootActiveRef.current = false;
|
|
retryTokenRef.current = null;
|
|
restoreCwdIntentRef.current = null;
|
|
suppressHostStartupCommandRef.current = false;
|
|
cleanupSession();
|
|
disposeRuntimeOnly();
|
|
};
|
|
|
|
const sessionStarters = createTerminalSessionStarters({
|
|
host,
|
|
keys,
|
|
identities,
|
|
knownHosts,
|
|
resolvedChainHosts,
|
|
sessionId,
|
|
reuseConnectionFromSessionId,
|
|
startupCommand,
|
|
noAutoRun,
|
|
multiLineRunMode,
|
|
shellType,
|
|
suppressHostStartupCommandRef,
|
|
terminalSettings,
|
|
terminalSettingsRef,
|
|
terminalBackend,
|
|
serialConfig,
|
|
telnetLocalEchoRef,
|
|
isVisibleRef,
|
|
isBootActiveRef,
|
|
pendingOutputScrollRef,
|
|
sessionRef,
|
|
hasConnectedRef,
|
|
hasRunStartupCommandRef,
|
|
restoreCwdIntentRef,
|
|
disposeDataRef,
|
|
disposeExitRef,
|
|
disposeTelnetEchoModeRef,
|
|
fitAddonRef,
|
|
serializeAddonRef,
|
|
pendingAuthRef,
|
|
promptLineBreakStateRef,
|
|
sudoAutofillRef,
|
|
onSudoHint: (active: boolean) => sudoHintRef.current?.(active) ?? false,
|
|
updateStatus,
|
|
setStatus,
|
|
setError,
|
|
setNeedsAuth: auth.setNeedsAuth,
|
|
setAuthRetryMessage: auth.setAuthRetryMessage,
|
|
setAuthPassword: auth.setAuthPassword,
|
|
setProgressLogs,
|
|
setProgressValue,
|
|
setChainProgress,
|
|
setIsConnectionAwaitingUserInput,
|
|
setIsConnectionPastTcpDial,
|
|
t,
|
|
onSessionAttached: (id: string) => {
|
|
clearTerminalCwd({ persistRestoreMetadata: false });
|
|
// SSH: always sync. Its backend starts in utf-8 regardless of
|
|
// host.charset, so the push is what keeps the UI state aligned
|
|
// across reconnects — including localhost SSH targets, hence
|
|
// hostname isn't in the gate.
|
|
const isLocal = host.protocol === 'local' || host.id?.startsWith('local-');
|
|
const isSerial = host.protocol === 'serial' || host.id?.startsWith('serial-');
|
|
const isTelnet = host.protocol === 'telnet';
|
|
const isMosh = host.protocol === 'mosh' || host.moshEnabled;
|
|
const isEt = host.protocol === 'et' || host.etEnabled;
|
|
const isSSH = !isLocal && !isSerial && !isTelnet && !isMosh && !isEt;
|
|
const encodingAttachConnection: TerminalEncodingAttachConnection = isSSH
|
|
? 'ssh'
|
|
: isTelnet
|
|
? 'telnet'
|
|
: isSerial
|
|
? 'serial'
|
|
: 'other';
|
|
// Telnet / serial: the backend already applied host.charset unless
|
|
// a remembered per-host choice exists. Remembered choices are explicit
|
|
// user preferences and must win on reconnect, including saved serial hosts.
|
|
// (including arbitrary iconv labels like latin1 / shift_jis that
|
|
// the UI's two-value state can't represent) through start*Session
|
|
// options, so don't clobber it on first attach without a stored choice.
|
|
if (shouldSyncTerminalEncodingOnAttach({
|
|
connection: encodingAttachConnection,
|
|
userPickedEncoding: userPickedEncodingRef.current,
|
|
hasRememberedEncoding: hasRememberedTerminalEncodingRef.current,
|
|
})) {
|
|
setSessionEncoding(id, terminalEncodingRef.current);
|
|
}
|
|
},
|
|
onRestoreCwdIntentConsumed: (cwd: string) => {
|
|
knownCwdRef.current = cwd;
|
|
},
|
|
onSessionExit: (closedSessionId, evt) => {
|
|
clearTerminalCwd();
|
|
onSessionExit?.(closedSessionId, evt);
|
|
},
|
|
onTerminalDataCapture: handleTerminalDataCaptureOnce,
|
|
onTerminalOutput: onTerminalOutput
|
|
? (chunk: string) => {
|
|
if (/password|passphrase|口令/i.test(chunk)) {
|
|
passwordPromptActiveRef.current = true;
|
|
}
|
|
appendOutputTriggerOutputRef.current(chunk);
|
|
onTerminalOutput(sessionId, chunk);
|
|
}
|
|
: (chunk: string) => {
|
|
if (/password|passphrase|口令/i.test(chunk)) {
|
|
passwordPromptActiveRef.current = true;
|
|
}
|
|
appendOutputTriggerOutputRef.current(chunk);
|
|
},
|
|
onTerminalLogData: captureTerminalLogData,
|
|
onProgrammaticCommandLogRewrite: queueProgrammaticCommandLogRewrite,
|
|
onOsDetected,
|
|
onCommandExecuted,
|
|
onCommandSubmitted,
|
|
sessionLog,
|
|
sshDebugLogEnabled,
|
|
sudoAutofillPassword,
|
|
sudoAutofillPasswordRef,
|
|
});
|
|
sessionStartersRef.current = sessionStarters;
|
|
|
|
useEffect(() => {
|
|
if (status === 'disconnected') {
|
|
connectScriptsConsumedRef.current = false;
|
|
connectScriptsCompletedIdsRef.current = new Set();
|
|
}
|
|
}, [status]);
|
|
|
|
useEffect(() => {
|
|
pendingScriptRunIdRef.current = null;
|
|
pendingScriptHandledRef.current = null;
|
|
}, [pendingScript?.id, pendingScriptId]);
|
|
|
|
const isPendingScriptAlreadyHandled = useCallback((snippet: Snippet) => {
|
|
if (snippet.id) {
|
|
return pendingScriptRunIdRef.current === snippet.id;
|
|
}
|
|
return pendingScriptHandledRef.current === snippet;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (status !== 'connected') return;
|
|
|
|
let pendingOne: Snippet | undefined;
|
|
if (pendingScript && isScriptSnippet(pendingScript)) {
|
|
if (!isPendingScriptAlreadyHandled(pendingScript)) {
|
|
pendingOne = pendingScript;
|
|
}
|
|
} else if (pendingScriptId) {
|
|
const script = snippets.find((item) => item.id === pendingScriptId && isScriptSnippet(item));
|
|
if (script && !isPendingScriptAlreadyHandled(script)) {
|
|
pendingOne = script;
|
|
}
|
|
}
|
|
|
|
const shouldEvaluateConnect = !connectScriptsConsumedRef.current;
|
|
const hasPendingWork = Boolean(pendingOne);
|
|
if (!shouldEvaluateConnect && !hasPendingWork) return;
|
|
if (connectScriptsInFlightRef.current) return;
|
|
|
|
// Defer until xterm has rendered login output and the main-process output tap
|
|
// has populated SessionOutputBuffer (avoids waitForPrompt racing an empty buffer).
|
|
const timer = window.setTimeout(() => {
|
|
const runPending = Boolean(pendingOne);
|
|
const connectQueueNow = connectScriptsConsumedRef.current
|
|
? []
|
|
: resolveConnectScriptsForHost(host, snippets).filter(
|
|
(item) => item.id && !connectScriptsCompletedIdsRef.current.has(item.id),
|
|
);
|
|
|
|
const scriptsToRun: Snippet[] = [];
|
|
for (const item of connectQueueNow) {
|
|
scriptsToRun.push(item);
|
|
}
|
|
if (runPending && pendingOne && !scriptsToRun.some((entry) => entry.id === pendingOne.id)) {
|
|
scriptsToRun.push(pendingOne);
|
|
}
|
|
|
|
const resolvedConnectScripts = resolveConnectScriptsForHost(host, snippets);
|
|
const allConnectScriptsDone = resolvedConnectScripts.length === 0
|
|
|| resolvedConnectScripts.every(
|
|
(item) => item.id && connectScriptsCompletedIdsRef.current.has(item.id),
|
|
);
|
|
|
|
if (scriptsToRun.length === 0) {
|
|
if (
|
|
!connectScriptsConsumedRef.current
|
|
&& allConnectScriptsDone
|
|
&& isVaultInitialized()
|
|
&& snippets.length > 0
|
|
&& !hasUnresolvedConnectScriptBindings(host, snippets)
|
|
) {
|
|
connectScriptsConsumedRef.current = true;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const pendingScriptToMark = runPending ? pendingOne : undefined;
|
|
const connectIdsInBatch = new Set(
|
|
connectQueueNow.map((item) => item.id).filter((id): id is string => Boolean(id)),
|
|
);
|
|
|
|
connectScriptsInFlightRef.current = true;
|
|
|
|
void runConnectScriptsSequential({
|
|
scripts: scriptsToRun,
|
|
sessionId,
|
|
sessionMeta: {
|
|
connected: true,
|
|
hostname: host.hostname,
|
|
username: host.username,
|
|
},
|
|
onScriptComplete: (snippet) => {
|
|
if (snippet.id && connectIdsInBatch.has(snippet.id)) {
|
|
connectScriptsCompletedIdsRef.current.add(snippet.id);
|
|
}
|
|
if (pendingScriptToMark && snippet === pendingScriptToMark) {
|
|
if (snippet.id) {
|
|
pendingScriptRunIdRef.current = snippet.id;
|
|
} else {
|
|
pendingScriptHandledRef.current = snippet;
|
|
}
|
|
}
|
|
},
|
|
})
|
|
.then(() => {
|
|
const resolvedAfterRun = resolveConnectScriptsForHost(host, snippets);
|
|
const doneAfterRun = resolvedAfterRun.length === 0
|
|
|| resolvedAfterRun.every(
|
|
(item) => item.id && connectScriptsCompletedIdsRef.current.has(item.id),
|
|
);
|
|
if (doneAfterRun) {
|
|
connectScriptsConsumedRef.current = true;
|
|
}
|
|
})
|
|
.catch(async (err) => {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message);
|
|
connectScriptsConsumedRef.current = true;
|
|
|
|
const pendingStillNeeded = pendingScriptToMark && (
|
|
pendingScriptToMark.id
|
|
? pendingScriptRunIdRef.current !== pendingScriptToMark.id
|
|
: pendingScriptHandledRef.current !== pendingScriptToMark
|
|
);
|
|
if (!pendingStillNeeded) return;
|
|
|
|
try {
|
|
await runConnectScriptsSequential({
|
|
scripts: [pendingScriptToMark],
|
|
sessionId,
|
|
sessionMeta: {
|
|
connected: true,
|
|
hostname: host.hostname,
|
|
username: host.username,
|
|
},
|
|
onScriptComplete: (snippet) => {
|
|
if (snippet.id) {
|
|
pendingScriptRunIdRef.current = snippet.id;
|
|
} else {
|
|
pendingScriptHandledRef.current = snippet;
|
|
}
|
|
},
|
|
});
|
|
} catch (pendingErr) {
|
|
const pendingMessage = pendingErr instanceof Error ? pendingErr.message : String(pendingErr);
|
|
toast.error(pendingMessage.includes('Observer mode') ? t('scripts.observer.blocked') : pendingMessage);
|
|
}
|
|
})
|
|
.finally(() => {
|
|
connectScriptsInFlightRef.current = false;
|
|
});
|
|
}, 400);
|
|
|
|
return () => window.clearTimeout(timer);
|
|
}, [host, isPendingScriptAlreadyHandled, pendingScript, pendingScriptId, sessionId, snippets, status, t]);
|
|
|
|
useEffect(() => {
|
|
return registerScreenSnapshotProvider(sessionId, () => {
|
|
const term = termRef.current;
|
|
if (!term?.buffer?.active) {
|
|
return { rows: 24, cols: 80, currentRow: 0, lines: [] };
|
|
}
|
|
const buffer = term.buffer.active;
|
|
const lines: string[] = [];
|
|
for (let row = 0; row < term.rows; row += 1) {
|
|
lines.push(buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? '');
|
|
}
|
|
return {
|
|
rows: term.rows,
|
|
cols: term.cols,
|
|
currentRow: buffer.baseY + buffer.cursorY,
|
|
lines,
|
|
};
|
|
});
|
|
}, [sessionId]);
|
|
|
|
useEffect(() => {
|
|
const startHandler = (event: Event) => {
|
|
const detail = (event as CustomEvent<{ sessionId?: string }>).detail;
|
|
if (detail?.sessionId !== sessionId) return;
|
|
void recorderRef.current.startRecording();
|
|
};
|
|
const stopHandler = (event: Event) => {
|
|
const detail = (event as CustomEvent<{ sessionId?: string }>).detail;
|
|
if (detail?.sessionId !== sessionId) return;
|
|
if (!recorderRef.current.isRecording) return;
|
|
void recorderRef.current.stopRecording().then(({ code }) => {
|
|
setRecordedCode(code);
|
|
setSaveRecordingOpen(true);
|
|
});
|
|
};
|
|
window.addEventListener('netcatty:script:recording:start', startHandler);
|
|
window.addEventListener('netcatty:script:recording:stop', stopHandler);
|
|
return () => {
|
|
window.removeEventListener('netcatty:script:recording:start', startHandler);
|
|
window.removeEventListener('netcatty:script:recording:stop', stopHandler);
|
|
};
|
|
}, [sessionId]);
|
|
|
|
useEffect(() => {
|
|
if (recorder.isRecording) {
|
|
setScriptRecordingState(sessionId, recorder.isPaused);
|
|
} else if (getScriptRecordingSnapshot().sessionId === sessionId) {
|
|
setScriptRecordingState(null);
|
|
}
|
|
}, [recorder.isRecording, recorder.isPaused, sessionId]);
|
|
|
|
useEffect(() => () => {
|
|
if (getScriptRecordingSnapshot().sessionId === sessionId) {
|
|
setScriptRecordingState(null);
|
|
}
|
|
}, [sessionId]);
|
|
|
|
useEffect(() => {
|
|
setConnectionReuseFellBack(false);
|
|
if (!reuseConnectionFromSessionId) return undefined;
|
|
|
|
return terminalBackend.onConnectionReuseFallback?.((fallbackSessionId) => {
|
|
if (fallbackSessionId === sessionId) {
|
|
setConnectionReuseFellBack(true);
|
|
}
|
|
});
|
|
}, [reuseConnectionFromSessionId, sessionId, terminalBackend]);
|
|
|
|
const safeFit = (options?: { force?: boolean; requireVisible?: boolean }) => {
|
|
const fitAddon = fitAddonRef.current;
|
|
if (!fitAddon) return;
|
|
if (options?.requireVisible && !isVisibleRef.current) return;
|
|
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
const width = container.clientWidth;
|
|
const height = container.clientHeight;
|
|
if (width <= 0 || height <= 0) {
|
|
// Terminal is hidden — invalidate the cached size so that when it
|
|
// becomes visible again, a non-forced fit won't be suppressed by a
|
|
// stale size match (e.g. after font metrics changed while hidden).
|
|
lastFittedSizeRef.current = null;
|
|
return;
|
|
}
|
|
|
|
if (!options?.force) {
|
|
const lastSize = lastFittedSizeRef.current;
|
|
if (lastSize && lastSize.width === width && lastSize.height === height) {
|
|
autocompleteRepositionRef.current?.();
|
|
return;
|
|
}
|
|
}
|
|
|
|
const runFit = () => {
|
|
try {
|
|
const term = termRef.current;
|
|
if (!term) return;
|
|
|
|
const buffer = term.buffer.active;
|
|
const wasPinnedToBottom = buffer.viewportY >= buffer.baseY;
|
|
const savedViewportY = buffer.viewportY;
|
|
|
|
const dimensions = fitAddon.proposeDimensions();
|
|
if (!dimensions || Number.isNaN(dimensions.cols) || Number.isNaN(dimensions.rows)) return;
|
|
|
|
lastFittedSizeRef.current = { width, height };
|
|
// addon-fit 0.11 clears the renderer before resizing, which can show
|
|
// as a one-frame WebGL blink during layout changes. Resize directly
|
|
// using the proposed dimensions to preserve the existing behavior
|
|
// without forcing a blank intermediate frame.
|
|
if (term.cols !== dimensions.cols || term.rows !== dimensions.rows) {
|
|
term.resize(dimensions.cols, dimensions.rows);
|
|
forceSyncRenderAfterResize(term);
|
|
}
|
|
|
|
// Preserve scroll position across resize (superset/Tabby pattern).
|
|
if (wasPinnedToBottom) {
|
|
term.scrollToBottom();
|
|
} else {
|
|
const targetY = Math.min(savedViewportY, term.buffer.active.baseY);
|
|
if (term.buffer.active.viewportY !== targetY) {
|
|
term.scrollToLine(targetY);
|
|
}
|
|
}
|
|
term.refresh(0, Math.max(0, term.rows - 1));
|
|
|
|
if (typeof requestAnimationFrame === "function") {
|
|
requestAnimationFrame(() => {
|
|
autocompleteRepositionRef.current?.();
|
|
});
|
|
} else {
|
|
autocompleteRepositionRef.current?.();
|
|
}
|
|
} catch (err) {
|
|
logger.warn("Fit failed", err);
|
|
}
|
|
};
|
|
|
|
if (
|
|
XTERM_PERFORMANCE_CONFIG.resize.useRAF &&
|
|
typeof requestAnimationFrame === "function"
|
|
) {
|
|
requestAnimationFrame(runFit);
|
|
} else {
|
|
runFit();
|
|
}
|
|
};
|
|
|
|
const prevIsResizingRef = useRef(isResizing);
|
|
|
|
const disableBracketedPasteRef = useRef(terminalSettings?.disableBracketedPaste ?? false);
|
|
disableBracketedPasteRef.current = terminalSettings?.disableBracketedPaste ?? false;
|
|
|
|
// True only while createXTermRuntime is programmatically restoring the
|
|
// selection right after a keystroke (preserveSelectionOnInput). Lets
|
|
// copy-on-select skip a redundant clipboard write that would otherwise
|
|
// clobber whatever the user copied elsewhere in the meantime.
|
|
const isRestoringSelectionRef = useRef(false);
|
|
|
|
const scrollOnPasteRef = useRef(terminalSettings?.scrollOnPaste ?? true);
|
|
scrollOnPasteRef.current = terminalSettings?.scrollOnPaste ?? true;
|
|
const clearWipesScrollbackRef = useRef(terminalSettings?.clearWipesScrollback ?? true);
|
|
clearWipesScrollbackRef.current = terminalSettings?.clearWipesScrollback ?? true;
|
|
|
|
const scrollToBottomAfterProgrammaticInput = useCallback((data: string) => {
|
|
if (termRef.current && shouldScrollOnTerminalInput(terminalSettingsRef.current, data)) {
|
|
termRef.current.scrollToBottom();
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const bridge = netcattyBridge.get();
|
|
const dispose = bridge?.onScriptSessionInput?.(({ sessionId: sid, data }) => {
|
|
if (sid !== sessionId) return;
|
|
scrollToBottomAfterProgrammaticInput(data);
|
|
});
|
|
return dispose;
|
|
}, [scrollToBottomAfterProgrammaticInput, sessionId]);
|
|
|
|
useEffect(() => {
|
|
if (!activeScriptRun) return;
|
|
termRef.current?.scrollToBottom();
|
|
}, [activeScriptRun]);
|
|
|
|
const broadcastUserPasteData = useCallback((data: string) => {
|
|
if (sessionRef.current && isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
|
|
onBroadcastInputRef.current(data, sessionId);
|
|
return true;
|
|
}
|
|
return false;
|
|
}, [sessionId]);
|
|
|
|
const executeSnippetCommand = useCallback((
|
|
command: string,
|
|
noAutoRun?: boolean,
|
|
options?: { broadcast?: boolean; multiLineRunMode?: Snippet["multiLineRunMode"] },
|
|
) => {
|
|
const term = termRef.current;
|
|
const id = sessionRef.current;
|
|
if (!term || !id) return;
|
|
|
|
let data = normalizeLineEndings(command);
|
|
const lineDelayMs = shouldDelayAutoRunSnippetInput(data, {
|
|
noAutoRun,
|
|
multiLineRunMode: options?.multiLineRunMode,
|
|
})
|
|
? AUTO_RUN_SNIPPET_LINE_DELAY_MS
|
|
: undefined;
|
|
const isMultiLine = data.includes('\n');
|
|
// Wrap in bracketed paste BEFORE appending \r so the Enter is sent
|
|
// outside the paste markers — otherwise shells treat it as pasted text
|
|
// instead of a submit action.
|
|
if (!lineDelayMs && isMultiLine && term.modes.bracketedPasteMode && !disableBracketedPasteRef.current) {
|
|
data = wrapBracketedPaste(data);
|
|
}
|
|
if (!noAutoRun) data = `${data}\r`;
|
|
|
|
// Broadcast the exact bytes the active session receives so peers mirror it,
|
|
// including the bracketed-paste wrapping and the auto-run \r. Broadcasting
|
|
// the raw (un-wrapped) form would let a multi-line noAutoRun snippet run
|
|
// line-by-line on peers, since handleBroadcastInput writes bytes directly
|
|
// without re-wrapping. Without broadcasting at all, accepting a snippet in
|
|
// broadcast mode would clear peer input (the clear keystrokes already go
|
|
// through the broadcast-aware path) but never send the command.
|
|
if (options?.broadcast !== false && isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
|
|
onBroadcastInputRef.current(data, sessionId, {
|
|
noAutoRun,
|
|
...(lineDelayMs ? { lineDelayMs } : {}),
|
|
});
|
|
}
|
|
|
|
data = prepareProgrammaticSudoInput(data);
|
|
terminalBackend.writeToSession(id, data, {
|
|
automated: true,
|
|
...(lineDelayMs ? { lineDelayMs } : {}),
|
|
});
|
|
scrollToBottomAfterProgrammaticInput(data);
|
|
term.focus();
|
|
}, [prepareProgrammaticSudoInput, scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId]);
|
|
|
|
const executeSnippet = useCallback(async (snippet: Snippet) => {
|
|
if (isScriptSnippet(snippet)) {
|
|
try {
|
|
await runAutomationScript({ snippet, sessionId });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message);
|
|
}
|
|
return;
|
|
}
|
|
const command = await resolveSnippetCommand(snippet);
|
|
if (command === null) return;
|
|
executeSnippetCommand(command, snippet.noAutoRun, {
|
|
multiLineRunMode: snippet.multiLineRunMode,
|
|
});
|
|
}, [executeSnippetCommand, sessionId, t]);
|
|
|
|
const onSnippetShortkeyRef = useRef(executeSnippet);
|
|
onSnippetShortkeyRef.current = executeSnippet;
|
|
|
|
const handleClipboardImageUploadResult = useCallback((result: RemoteClipboardImageUploadResult) => {
|
|
const messageKey = getRemoteClipboardImageUploadErrorMessageKey(result);
|
|
if (messageKey) toast.error(t(messageKey));
|
|
}, [t]);
|
|
|
|
const terminalContextActions = useTerminalContextActions({
|
|
termRef,
|
|
sourceSessionId: sessionId,
|
|
sessionRef,
|
|
onHasSelectionChange: setHasSelection,
|
|
scrollOnPasteRef,
|
|
clearWipesScrollbackRef,
|
|
isBroadcastEnabledRef,
|
|
onBroadcastInputRef,
|
|
isLocalConnection,
|
|
supportsRemoteImagePaste,
|
|
terminalBackend,
|
|
getRemoteCwd: () => resolveSftpInitialPath({ preferFreshBackend: true }),
|
|
scrollToBottomAfterProgrammaticInput,
|
|
onClipboardImageUploadResult: handleClipboardImageUploadResult,
|
|
});
|
|
// Kept fresh on every render so the mouseTracking capture handler at
|
|
// handleContextMenuCapture (which is bound once per sessionId) can
|
|
// still invoke the latest paste / select-word callbacks without
|
|
// re-binding on every action identity change. See #941.
|
|
const terminalContextActionsRef = useRef(terminalContextActions);
|
|
terminalContextActionsRef.current = terminalContextActions;
|
|
|
|
const handleAddSelectionToAI = useCallback(() => {
|
|
const selection = termRef.current?.getSelection() ?? "";
|
|
if (!selection.trim()) return;
|
|
onAddSelectionToAI?.(sessionId, selection);
|
|
}, [onAddSelectionToAI, sessionId]);
|
|
|
|
const handleSetTerminalEncoding = useCallback((encoding: TerminalEncodingPreference) => {
|
|
setTerminalEncoding(encoding);
|
|
setRememberedTerminalEncoding(encoding);
|
|
userPickedEncodingRef.current = true;
|
|
if (host.id && host.protocol !== 'local' && !host.id.startsWith('local-') && !host.id.startsWith('serial-')) {
|
|
handleUpdateHostFromTerminal({
|
|
id: host.id,
|
|
charset: terminalEncodingPreferenceToCharset(encoding),
|
|
});
|
|
}
|
|
if (sessionRef.current) {
|
|
setSessionEncoding(sessionRef.current, encoding);
|
|
}
|
|
}, [handleUpdateHostFromTerminal, host.id, host.protocol, setRememberedTerminalEncoding, setSessionEncoding]);
|
|
|
|
const handleOpenSFTP = useCallback(async () => {
|
|
if (onOpenSftp) {
|
|
// Delegate to parent (TerminalLayer) for shared SFTP side panel
|
|
const initialPath = await resolveSftpInitialPath();
|
|
onOpenSftp(host, initialPath, undefined, sessionId);
|
|
return;
|
|
}
|
|
|
|
// Fallback: toggle internal SFTP state (shouldn't happen with new architecture)
|
|
if (showSFTP) {
|
|
setShowSFTP(false);
|
|
return;
|
|
}
|
|
setShowSFTP(true);
|
|
}, [host, onOpenSftp, resolveSftpInitialPath, sessionId, showSFTP]);
|
|
|
|
const handleSendYmodem = useCallback(async () => {
|
|
if (!isSerialConnection || statusRef.current !== "connected") return;
|
|
if (!selectFileAvailable() || !serialYmodemAvailable()) {
|
|
toast.error(t("terminal.ymodem.unavailable"));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const filePath = await selectFile(
|
|
t("terminal.ymodem.selectFile"),
|
|
undefined,
|
|
[{ name: t("terminal.ymodem.allFiles"), extensions: ["*"] }],
|
|
);
|
|
if (!filePath) return;
|
|
|
|
const fileName = filePath.split(/[\\/]/).pop() || filePath;
|
|
toast.info(t("terminal.ymodem.started", { fileName }));
|
|
setYmodemInProgress(true);
|
|
const result = await sendSerialYmodem(sessionRef.current || sessionId, filePath);
|
|
if (result.success) {
|
|
toast.success(t("terminal.ymodem.complete", { fileName: result.fileName || fileName }));
|
|
} else {
|
|
toast.error(result.error || t("terminal.ymodem.failed"));
|
|
}
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : t("terminal.ymodem.failed"));
|
|
} finally {
|
|
setYmodemInProgress(false);
|
|
}
|
|
}, [isSerialConnection, selectFile, selectFileAvailable, sendSerialYmodem, serialYmodemAvailable, sessionId, t]);
|
|
|
|
const handleReceiveYmodem = useCallback(async () => {
|
|
if (!isSerialConnection || statusRef.current !== "connected") return;
|
|
if (!selectDirectoryAvailable() || !serialYmodemReceiveAvailable()) {
|
|
toast.error(t("terminal.ymodem.unavailable"));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const destinationDir = await selectDirectory(t("terminal.ymodem.selectReceiveDirectory"));
|
|
if (!destinationDir) return;
|
|
|
|
toast.info(t("terminal.ymodem.receiveStarted"));
|
|
setYmodemInProgress(true);
|
|
const result = await receiveSerialYmodem(sessionRef.current || sessionId, destinationDir);
|
|
if (result.success) {
|
|
if (result.fileCount && result.fileCount > 1) {
|
|
toast.success(t("terminal.ymodem.receiveCompleteMultiple", { count: result.fileCount }));
|
|
} else if (result.fileName) {
|
|
toast.success(t("terminal.ymodem.receiveComplete", { fileName: result.fileName }));
|
|
} else {
|
|
toast.success(t("terminal.ymodem.receiveEmpty"));
|
|
}
|
|
} else {
|
|
toast.error(t("terminal.ymodem.receiveFailed"));
|
|
}
|
|
} catch {
|
|
toast.error(t("terminal.ymodem.receiveFailed"));
|
|
} finally {
|
|
setYmodemInProgress(false);
|
|
}
|
|
}, [
|
|
isSerialConnection,
|
|
receiveSerialYmodem,
|
|
selectDirectory,
|
|
selectDirectoryAvailable,
|
|
serialYmodemReceiveAvailable,
|
|
sessionId,
|
|
t,
|
|
]);
|
|
|
|
const handleCancelConnect = () => {
|
|
if (pendingHostKeyRequestId) {
|
|
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
|
|
}
|
|
retryTokenRef.current = null;
|
|
restoreCwdIntentRef.current = null;
|
|
setIsCancelling(true);
|
|
auth.setNeedsAuth(false);
|
|
auth.setAuthRetryMessage(null);
|
|
setNeedsHostKeyVerification(false);
|
|
setPendingHostKeyInfo(null);
|
|
setPendingHostKeyRequestId(null);
|
|
setError("Connection cancelled");
|
|
setProgressLogs((prev) => [...prev, "Cancelled by user."]);
|
|
cleanupSession();
|
|
updateStatus("disconnected");
|
|
setChainProgress(null);
|
|
setTimeout(() => setIsCancelling(false), 600);
|
|
onCloseSession?.(sessionId);
|
|
};
|
|
|
|
const handleDismissDisconnectedDialog = () => {
|
|
setIsDisconnectedDialogDismissed(true);
|
|
};
|
|
|
|
const handleCloseDisconnectedSession = () => {
|
|
retryTokenRef.current = null;
|
|
restoreCwdIntentRef.current = null;
|
|
onCloseSession?.(sessionId);
|
|
};
|
|
|
|
const handleHostKeyClose = () => {
|
|
setNeedsHostKeyVerification(false);
|
|
setPendingHostKeyInfo(null);
|
|
setPendingHostKeyRequestId(null);
|
|
handleCancelConnect();
|
|
};
|
|
|
|
const handleHostKeyContinue = () => {
|
|
if (pendingHostKeyRequestId) {
|
|
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, true, false);
|
|
}
|
|
setNeedsHostKeyVerification(false);
|
|
if (pendingConnectionRef.current) {
|
|
pendingConnectionRef.current();
|
|
pendingConnectionRef.current = null;
|
|
}
|
|
setPendingHostKeyInfo(null);
|
|
setPendingHostKeyRequestId(null);
|
|
};
|
|
|
|
const handleHostKeyAddAndContinue = () => {
|
|
if (pendingHostKeyInfo && onAddKnownHost) {
|
|
onAddKnownHost(createKnownHostFromHostKeyInfo(pendingHostKeyInfo, host));
|
|
}
|
|
if (pendingHostKeyRequestId) {
|
|
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, true, true);
|
|
}
|
|
setNeedsHostKeyVerification(false);
|
|
if (pendingConnectionRef.current) {
|
|
pendingConnectionRef.current();
|
|
pendingConnectionRef.current = null;
|
|
}
|
|
setPendingHostKeyInfo(null);
|
|
setPendingHostKeyRequestId(null);
|
|
};
|
|
|
|
const handleRetry = () => {
|
|
if (!termRef.current) return;
|
|
prepareRestoredReconnect();
|
|
cleanupSession();
|
|
const term = termRef.current;
|
|
// Claim a fresh retry token. If the user cancels / closes / unmounts /
|
|
// kicks off another retry while the chained writes below are still
|
|
// queued, the token will be invalidated and our callbacks will abort
|
|
// before opening a ghost backend session with no owning UI.
|
|
const retryToken = Symbol("retry");
|
|
retryTokenRef.current = retryToken;
|
|
const retryStillActive = () => retryTokenRef.current === retryToken && termRef.current === term;
|
|
|
|
isBootActiveRef.current = true;
|
|
auth.resetForRetry();
|
|
terminalDataCapturedRef.current = false;
|
|
hasRunStartupCommandRef.current = false;
|
|
setIsDisconnectedDialogDismissed(false);
|
|
setConnectionReuseFellBack(false);
|
|
updateStatus("connecting");
|
|
setError(null);
|
|
setProgressLogs(["Retrying secure channel..."]);
|
|
setShowLogs(true);
|
|
|
|
const startNewSession = () => {
|
|
if (!retryStillActive()) return;
|
|
if (host.protocol === "serial") {
|
|
sessionStarters.startSerial(term);
|
|
} else if (host.protocol === "local" || host.hostname === "localhost") {
|
|
sessionStarters.startLocal(term);
|
|
} else if (host.protocol === "telnet") {
|
|
sessionStarters.startTelnet(term);
|
|
} else if (host.moshEnabled) {
|
|
sessionStarters.startMosh(term);
|
|
} else if (host.etEnabled) {
|
|
sessionStarters.startEt(term);
|
|
} else {
|
|
sessionStarters.startSSH(term);
|
|
}
|
|
};
|
|
|
|
// Chain the whole preparation through xterm.write callbacks so everything
|
|
// lands in strict order — see #695. xterm.write is async, so without
|
|
// chaining, a fast reconnect path (local/serial especially) can interleave
|
|
// the new session's first bytes with our reset sequence, corrupting the
|
|
// first screen.
|
|
//
|
|
// 1. Exit the alternate screen first. preserveTerminalViewportInScrollback
|
|
// is a no-op on the alt buffer (disconnect while in vim/less/top), so
|
|
// we must be on the normal buffer before preserving.
|
|
term.write('\x1b[?1049l', () => {
|
|
if (!retryStillActive()) return;
|
|
// 2. Push the previous session's viewport into scrollback so the user
|
|
// can still read it after reconnect.
|
|
preserveTerminalViewportInScrollback(term);
|
|
// 3. Soft terminal reset (DECSTR, \x1b[!p) resets VT220-era modes that
|
|
// full-screen apps may have left on — DECCKM (otherwise arrow keys
|
|
// emit SS3 and break readline history), keypad mode, SGR,
|
|
// insert/replace, origin, cursor visibility — without clearing the
|
|
// buffer. DECSTR does not cover xterm-specific extensions, so also
|
|
// explicitly disable mouse tracking (1000/1002/1003/1006) and
|
|
// bracketed paste (2004). Finally home the cursor.
|
|
term.write(
|
|
'\x1b[!p\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[H',
|
|
// 4. Only now — after every prep byte has been applied to the
|
|
// terminal — start the new session, so its first output can't
|
|
// interleave with the reset sequence.
|
|
startNewSession,
|
|
);
|
|
});
|
|
};
|
|
|
|
const shouldShowConnectionDialog = shouldShowTerminalConnectionDialog({
|
|
status,
|
|
isLocalConnection,
|
|
isSerialConnection,
|
|
isDisconnectedDialogDismissed,
|
|
hideConnectingDialogForConnectionReuse: shouldHideConnectingDialogForConnectionReuse({
|
|
reuseConnectionFromSessionId,
|
|
host,
|
|
connectionReuseFellBack,
|
|
}),
|
|
});
|
|
|
|
const {
|
|
handleDragEnter,
|
|
handleDragLeave,
|
|
handleDragOver,
|
|
handleDrop,
|
|
isDraggingOver,
|
|
} = useTerminalDragDrop({
|
|
host,
|
|
isLocalConnection,
|
|
isNetworkDevice,
|
|
onOpenSftp,
|
|
resolveSftpInitialPath,
|
|
scrollToBottomAfterProgrammaticInput,
|
|
sessionId,
|
|
sessionRef,
|
|
status,
|
|
t,
|
|
terminalBackend,
|
|
termRef,
|
|
});
|
|
|
|
useTerminalFilePaste({
|
|
isLocalConnection,
|
|
status,
|
|
termRef,
|
|
sessionRef,
|
|
terminalBackend,
|
|
scrollOnPasteRef,
|
|
onPasteData: broadcastUserPasteData,
|
|
scrollToBottomAfterProgrammaticInput,
|
|
containerRef,
|
|
});
|
|
|
|
const handleToggleSessionLog = useCallback(async () => {
|
|
const currentSessionId = sessionRef.current ?? sessionId;
|
|
if (!currentSessionId) {
|
|
toast.error("Session log bridge is unavailable");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const currentStatus = await getManualSessionLogStatus({ sessionId: currentSessionId });
|
|
if (currentStatus?.isLogging) {
|
|
const stopResult = await stopManualSessionLog({ sessionId: currentSessionId });
|
|
if (stopResult?.success) {
|
|
setIsSessionLogging(false);
|
|
} else {
|
|
toast.error(stopResult?.error || "Failed to stop session log");
|
|
}
|
|
return;
|
|
}
|
|
|
|
const startResult = await startManualSessionLog({
|
|
sessionId: currentSessionId,
|
|
sessionName: host.label || host.hostname || currentSessionId,
|
|
preferredDirectory: sessionLog?.directory,
|
|
initialLine: termRef.current ? getSessionLogInitialLine(termRef.current) : "",
|
|
});
|
|
if (startResult?.success) {
|
|
if (!startResult?.started && startResult?.canceled) return;
|
|
setIsSessionLogging(!!startResult?.started);
|
|
} else {
|
|
toast.error(startResult?.error || "Failed to start session log");
|
|
}
|
|
} catch (err) {
|
|
logger.error("[Terminal] Failed to toggle manual session log:", err);
|
|
toast.error("Failed to toggle session log");
|
|
}
|
|
}, [
|
|
getManualSessionLogStatus,
|
|
host.hostname,
|
|
host.label,
|
|
sessionId,
|
|
sessionLog?.directory,
|
|
startManualSessionLog,
|
|
stopManualSessionLog,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const currentSessionId = sessionRef.current ?? sessionId;
|
|
if (!currentSessionId) {
|
|
setIsSessionLogging(false);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
void getManualSessionLogStatus({ sessionId: currentSessionId }).then((result) => {
|
|
if (!cancelled) setIsSessionLogging(!!result?.isLogging);
|
|
}).catch(() => {
|
|
if (!cancelled) setIsSessionLogging(false);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [getManualSessionLogStatus, sessionId, status]);
|
|
|
|
const handleToolbarRecordingToggle = useCallback(() => {
|
|
const recording = getScriptRecordingSnapshot();
|
|
if (recording.sessionId && recording.sessionId !== sessionId) {
|
|
toast.error(t('scripts.recording.alreadyActive'));
|
|
return;
|
|
}
|
|
if (recording.sessionId === sessionId) {
|
|
window.dispatchEvent(new CustomEvent('netcatty:script:recording:stop', { detail: { sessionId } }));
|
|
return;
|
|
}
|
|
window.dispatchEvent(new CustomEvent('netcatty:script:recording:start', { detail: { sessionId } }));
|
|
}, [sessionId, t]);
|
|
|
|
const renderControls = useCallback((opts?: { showClose?: boolean }) => (
|
|
<TerminalToolbar
|
|
status={status}
|
|
host={host}
|
|
compactToolbar={compactToolbar}
|
|
snippets={snippets}
|
|
snippetPackages={snippetPackages}
|
|
onSnippetClick={(snippet) => { void executeSnippet(snippet); }}
|
|
onOpenSFTP={handleOpenSFTP}
|
|
onSendYmodem={isSerialConnection ? handleSendYmodem : undefined}
|
|
onReceiveYmodem={isSerialConnection ? handleReceiveYmodem : undefined}
|
|
onOpenScripts={onOpenScripts ?? (() => {})}
|
|
onOpenHistory={onOpenHistory}
|
|
onOpenTheme={onOpenTheme ?? (() => {})}
|
|
onConfigureOsc7={shouldOfferOsc7SetupAction({
|
|
protocol: host.protocol,
|
|
isLocalConnection,
|
|
isSerialConnection,
|
|
isNetworkDevice,
|
|
}) ? () => setOsc7SetupOpen(true) : undefined}
|
|
onUpdateHost={handleUpdateHostFromTerminal}
|
|
showClose={opts?.showClose}
|
|
onClose={() => onCloseSession?.(sessionId)}
|
|
isSearchOpen={isSearchOpen}
|
|
onToggleSearch={handleToggleSearch}
|
|
showLogButton
|
|
onToggleSessionLog={handleToggleSessionLog}
|
|
isSessionLogging={isSessionLogging}
|
|
isSessionLogDisabled={status !== "connected" && !isSessionLogging}
|
|
isComposeBarOpen={inWorkspace ? isWorkspaceComposeBarOpen : isComposeBarOpen}
|
|
onToggleComposeBar={inWorkspace ? onToggleComposeBar : () => setIsComposeBarOpen(prev => !prev)}
|
|
terminalEncoding={terminalEncoding}
|
|
onSetTerminalEncoding={handleSetTerminalEncoding}
|
|
recordingIndicator={recorder.isRecording ? (
|
|
<ScriptRecordingIndicator
|
|
elapsedMs={recorder.elapsedMs}
|
|
isPaused={recorder.isPaused}
|
|
onPause={recorder.pauseRecording}
|
|
onResume={recorder.resumeRecording}
|
|
onStop={() => {
|
|
void recorder.stopRecording().then(({ code }) => {
|
|
setRecordedCode(code);
|
|
setSaveRecordingOpen(true);
|
|
});
|
|
}}
|
|
/>
|
|
) : undefined}
|
|
onStartRecording={status === 'connected' ? handleToolbarRecordingToggle : undefined}
|
|
/>
|
|
), [
|
|
compactToolbar,
|
|
executeSnippet,
|
|
handleOpenSFTP,
|
|
handleReceiveYmodem,
|
|
handleSendYmodem,
|
|
handleSetTerminalEncoding,
|
|
handleToggleSessionLog,
|
|
handleToggleSearch,
|
|
handleToolbarRecordingToggle,
|
|
host,
|
|
inWorkspace,
|
|
isLocalConnection,
|
|
isNetworkDevice,
|
|
isSerialConnection,
|
|
isComposeBarOpen,
|
|
isSearchOpen,
|
|
isSessionLogging,
|
|
isWorkspaceComposeBarOpen,
|
|
onCloseSession,
|
|
onOpenScripts,
|
|
onOpenHistory,
|
|
onOpenTheme,
|
|
onToggleComposeBar,
|
|
setIsComposeBarOpen,
|
|
handleUpdateHostFromTerminal,
|
|
sessionId,
|
|
snippetPackages,
|
|
snippets,
|
|
status,
|
|
terminalEncoding,
|
|
recorder,
|
|
]);
|
|
|
|
const statusDotTone =
|
|
status === "connected"
|
|
? "bg-emerald-400"
|
|
: status === "connecting"
|
|
? "bg-amber-400"
|
|
: "bg-rose-500";
|
|
const terminalPreviewVars = useMemo(() => {
|
|
const { background, foreground, cursor } = effectiveTheme.colors;
|
|
return {
|
|
['--terminal-ui-bg' as never]: background,
|
|
['--terminal-ui-fg' as never]: foreground,
|
|
['--terminal-ui-border' as never]: `color-mix(in srgb, ${foreground} 8%, ${background} 92%)`,
|
|
['--terminal-ui-toolbar-btn' as never]: `color-mix(in srgb, ${background} 88%, ${foreground} 12%)`,
|
|
['--terminal-ui-toolbar-btn-hover' as never]: `color-mix(in srgb, ${background} 78%, ${foreground} 22%)`,
|
|
['--terminal-ui-toolbar-btn-active' as never]: `color-mix(in srgb, ${cursor} 78%, ${background} 22%)`,
|
|
};
|
|
}, [effectiveTheme.colors]);
|
|
|
|
const effectiveComposeBarOpen = inWorkspace ? !!isWorkspaceComposeBarOpen : isComposeBarOpen;
|
|
|
|
xTermRuntimeContextRef.current = {
|
|
host,
|
|
fontFamilyId,
|
|
resolvedFontFamily,
|
|
fontSize: effectiveFontSize,
|
|
terminalTheme: effectiveTheme,
|
|
terminalSettingsRef,
|
|
terminalBackend,
|
|
sessionRef,
|
|
hotkeySchemeRef,
|
|
disableTerminalFontZoomRef,
|
|
keyBindingsRef,
|
|
onHotkeyActionRef,
|
|
onTerminalFontSizeChange,
|
|
isBroadcastEnabledRef,
|
|
onBroadcastInputRef,
|
|
snippetsRef,
|
|
onSnippetShortkeyRef,
|
|
sessionId,
|
|
statusRef,
|
|
onCommandExecuted,
|
|
onCommandSubmitted,
|
|
commandBufferRef,
|
|
scriptRecorderRef: recorderRef,
|
|
passwordPromptActiveRef,
|
|
onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef,
|
|
promptLineBreakStateRef,
|
|
sudoAutofillRef,
|
|
requestSearchFocus,
|
|
serialLocalEcho: serialConfig?.localEcho,
|
|
serialLineMode: serialConfig?.lineMode,
|
|
serialLineBufferRef,
|
|
telnetLocalEchoRef,
|
|
onTerminalLogData: captureTerminalLogData,
|
|
onCwdChange: (cwd: string) => {
|
|
terminalCwdTracker.setRendererCwd(cwd);
|
|
knownCwdRef.current = cwd;
|
|
onTerminalCwdChange?.(sessionId, cwd);
|
|
},
|
|
onTitleChange: (title: string | null) => {
|
|
onTerminalTitleChange?.(sessionId, title);
|
|
},
|
|
onBell: () => {
|
|
onTerminalBell?.(sessionId);
|
|
},
|
|
onOsc52ReadRequest: handleOsc52ReadRequest,
|
|
onAutocompleteKeyEvent: (e: KeyboardEvent) => autocompleteKeyEventRef.current?.(e) ?? true,
|
|
onAutocompleteInput: (data: string) => autocompleteInputRef.current?.(data),
|
|
terminalContextActionsRef,
|
|
isRestoringSelectionRef,
|
|
};
|
|
|
|
const safeFitRef = useRef(safeFit);
|
|
safeFitRef.current = safeFit;
|
|
|
|
const wakeSoftHiddenRuntime = useCallback(() => {
|
|
if (!softHiddenRef.current) return;
|
|
terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
|
softHiddenRef.current = false;
|
|
xtermRuntimeRef.current?.ensureWebglRenderer();
|
|
xtermRuntimeRef.current?.clearTextureAtlas();
|
|
safeFitRef.current({ force: true });
|
|
}, [sessionId]);
|
|
|
|
useEffect(() => {
|
|
return terminalHiddenRendererStore.subscribe(() => {
|
|
if (!terminalHiddenRendererStore.consumeEvictionRequest(sessionId)) return;
|
|
if (!softHiddenRef.current || hibernatedRef.current) return;
|
|
softHiddenRef.current = false;
|
|
terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
|
void fullHibernateRuntime();
|
|
});
|
|
}, [fullHibernateRuntime, sessionId]);
|
|
|
|
const wakeFromHibernateRuntime = useCallback((
|
|
getPayload: () => TerminalHibernateWakePayload,
|
|
options: { sessionConnected: boolean },
|
|
): boolean | Promise<boolean> => {
|
|
if (wakeInProgressRef.current || hasRuntimeRef.current) {
|
|
logger.warn("[Terminal] Wake skipped", {
|
|
sessionId,
|
|
wakeInProgress: wakeInProgressRef.current,
|
|
hasRuntime: hasRuntimeRef.current,
|
|
});
|
|
return false;
|
|
}
|
|
const container = containerRef.current;
|
|
const runtimeContext = xTermRuntimeContextRef.current;
|
|
if (!container || !runtimeContext) {
|
|
logger.warn("[Terminal] Wake skipped: missing mount prerequisites", {
|
|
sessionId,
|
|
hasContainer: !!container,
|
|
hasRuntimeContext: !!runtimeContext,
|
|
});
|
|
return false;
|
|
}
|
|
if (options.sessionConnected && !sessionRef.current) {
|
|
logger.warn("[Terminal] Wake skipped: missing backend session", { sessionId });
|
|
return false;
|
|
}
|
|
|
|
wakeInProgressRef.current = true;
|
|
|
|
const stopHibernateListeners = () => {
|
|
const backendId = sessionRef.current;
|
|
disposeDataRef.current?.();
|
|
disposeDataRef.current = null;
|
|
disposeExitRef.current?.();
|
|
disposeExitRef.current = null;
|
|
if (backendId) {
|
|
flushTerminalSessionFlowAck(backendId);
|
|
clearTerminalSessionFlowAck(backendId);
|
|
terminalBackend.setSessionFlowPaused?.(backendId, false);
|
|
}
|
|
};
|
|
|
|
return wakeTerminalFromHibernate({
|
|
refs: terminalRuntimeRefs,
|
|
runtimeContext,
|
|
container,
|
|
getPayload,
|
|
stopHibernateListeners,
|
|
sessionConnected: options.sessionConnected,
|
|
getSessionConnected: () => getSessionConnectedRef.current(),
|
|
reattachSession: (term) => {
|
|
sessionStartersRef.current?.reattachSession(term);
|
|
},
|
|
safeFit: (...args) => safeFitRef.current(...args),
|
|
resizeSession,
|
|
forceSyncRenderAfterResize,
|
|
lastFittedSizeRef,
|
|
isBootActiveRef,
|
|
sessionId,
|
|
updateStatus: (next) => updateStatusRef.current(next),
|
|
replayChunkBytes: resolveTerminalHibernateReplayChunkBytes(terminalSettings),
|
|
}).then((ok) => ok).catch((err) => {
|
|
logger.error("[Terminal] Failed to resume from hibernate", err);
|
|
return false;
|
|
}).finally(() => {
|
|
wakeInProgressRef.current = false;
|
|
});
|
|
}, [sessionId, terminalBackend, terminalRuntimeRefs, resizeSession, terminalSettings]);
|
|
|
|
useTerminalHibernateEffect({
|
|
sessionId,
|
|
isVisible,
|
|
isVisibleRef,
|
|
getSessionConnectedRef,
|
|
status,
|
|
isSearchOpen,
|
|
hibernateEnabled: resolveTerminalHibernateEnabled(terminalSettings),
|
|
hibernateDelayMs: resolveTerminalHibernateDelayMs(terminalSettings),
|
|
fileTransferActive: isTerminalFileTransferActive({
|
|
zmodemActive: zmodem.active,
|
|
ymodemInProgress,
|
|
isDraggingOver,
|
|
}),
|
|
hibernatedRef,
|
|
softHiddenRef,
|
|
hibernatePendingBufferRef,
|
|
hibernateSnapshotRef,
|
|
hibernateViewportSnapshotRef,
|
|
hibernateScrollbackSnapshotRef,
|
|
hibernateContextSnapshotRef,
|
|
hibernateContextViewportSnapshotRef,
|
|
hibernateContextScrollbackSnapshotRef,
|
|
hibernateAlternateScreenRef,
|
|
hasRuntimeRef,
|
|
onHibernate: hibernateRuntime,
|
|
onSoftHideWake: wakeSoftHiddenRuntime,
|
|
onWake: wakeFromHibernateRuntime,
|
|
});
|
|
|
|
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, prepareRestoredReconnect, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
|
|
|
return (
|
|
<>
|
|
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
|
|
<ScriptExecutionOverlay
|
|
run={activeScriptRun}
|
|
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
|
|
onResume={() => { void resumeScriptRun(activeScriptRun.runId); }}
|
|
onStop={() => { void stopScriptRun(activeScriptRun.runId); }}
|
|
onDismiss={dismissScriptOverlay}
|
|
/>
|
|
) : null, selectionOverlayPosition, sessionDisplayName, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />
|
|
<ScriptSaveRecordingDialog
|
|
open={saveRecordingOpen}
|
|
code={recordedCode}
|
|
packages={snippetPackages}
|
|
defaultName={`recorded-${new Date().toISOString().slice(0, 10)}`}
|
|
onClose={() => setSaveRecordingOpen(false)}
|
|
onSave={({ name, packagePath, code, editAfterSave }) => {
|
|
window.dispatchEvent(new CustomEvent('netcatty:scripts:save-recorded', {
|
|
detail: { name, packagePath, code, editAfterSave },
|
|
}));
|
|
setSaveRecordingOpen(false);
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const Terminal = memo(TerminalComponent, terminalPropsAreEqual);
|
|
Terminal.displayName = "Terminal";
|
|
|
|
export default Terminal;
|