Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
4406 lines
181 KiB
TypeScript
4406 lines
181 KiB
TypeScript
import { resolveHostOs } from '../domain/host';
|
|
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, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug } 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 {
|
|
scrollTerminalToBottomAfterInputIfEnabled,
|
|
shouldEnableNativeUserInputAutoScroll,
|
|
} 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, resolveInheritedCwdIntent } from "../domain/sessionRestore";
|
|
import {
|
|
buildTerminalContextReadResult,
|
|
buildTerminalContextSnapshotText,
|
|
normalizeTerminalContextRange,
|
|
resolveTerminalContextLineWindow,
|
|
type TerminalContextReader,
|
|
} from "../domain/terminalContextRead";
|
|
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
|
|
import { shouldCollectServerStats } from "../domain/systemManager/systemTarget";
|
|
import { resolveHostSshConnectionTimeouts } from "../domain/sshConnectionTimeouts";
|
|
import { CONNECTION_PROGRESS_START } from "./terminal/connectionProgress";
|
|
import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
|
|
import { resolveHostAuth, resolveHostAutofillPassword } from "../domain/sshAuth";
|
|
import { resolveEffectiveTerminalProtocol } from "../domain/terminalProtocol";
|
|
import { isPluginHostProtocol } from "../domain/pluginConnection";
|
|
import { clearTerminalBootEpoch, setTerminalBootEpoch } from "../domain/terminalBootEpoch";
|
|
import {
|
|
appendTerminalPromptSecurityTail,
|
|
isConfirmedTerminalShellPrompt,
|
|
isUntrustedTerminalInputPrompt,
|
|
} from "../domain/terminalPromptSecurity";
|
|
import { listPasswordPromptFillCandidates } from "../domain/passwordPromptAssist";
|
|
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
|
import {
|
|
TERMINAL_AUTO_RECONNECT_DELAY_MS,
|
|
canAttemptTerminalAutoReconnect,
|
|
shouldAutoReconnectAfterExit,
|
|
shouldContinueAutoReconnectAfterFailure,
|
|
} from "../application/state/terminalAutoReconnect";
|
|
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";
|
|
import { useAppearanceChromeStore } from "../application/state/appearanceChromeStore";
|
|
import {
|
|
shouldPublishPluginTerminalSessionMountLifecycle,
|
|
usePluginTerminalSessionLifecycle,
|
|
} from "../application/state/usePluginTerminalSessionLifecycle";
|
|
import { usePluginTerminalProviders } from "../application/state/usePluginTerminalProviders";
|
|
import type { PluginTerminalDecorationRule } from "../domain/pluginTerminalProviders";
|
|
import { terminalReconnectRegistry } from "../application/state/terminalReconnectRegistry";
|
|
import { resolveSftpReuseSourceSessionId } from "../application/state/terminalConnectionReuse";
|
|
// 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 { 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 {
|
|
SCRIPT_RECORDING_LIMIT_EVENT,
|
|
useScriptRecorder,
|
|
} from "@/application/state/useScriptRecorder.ts";
|
|
import { getScriptRecordingSnapshot, setScriptRecordingState } from "@/application/state/scriptRecordingStore.ts";
|
|
import {
|
|
runAutomationScript,
|
|
runConnectScriptsSequential,
|
|
selectScriptOverlayRun,
|
|
subscribeScriptRuns,
|
|
pauseScriptRun,
|
|
resumeScriptRun,
|
|
stopScriptRun,
|
|
} from "@/application/state/scriptAutomationCoordinator.ts";
|
|
import {
|
|
hasUnresolvedConnectScriptBindings,
|
|
resolveConnectScriptsForHost,
|
|
shouldMarkConnectAutomationConsumed,
|
|
shouldUseFreshSshConnectionForAutomation,
|
|
} from "@/domain/hostConnectScripts.ts";
|
|
import { isVaultInitialized } from "@/application/state/vaultInitStore.ts";
|
|
import { useVaultSnapshotField } from "@/application/state/vaultSnapshotStore.ts";
|
|
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
|
|
import { handleTerminalOscNotification } from "@/application/state/oscDesktopNotifications.ts";
|
|
import { OscNotificationStreamScanner } from "@/domain/terminalOscNotifications.ts";
|
|
import { ScriptExecutionOverlay } from "./terminal/ScriptExecutionOverlay";
|
|
import { isScriptSnippet } from "@/domain/snippetScript.ts";
|
|
import { snippetCanRunInTerminal } from "@/domain/snippetTargets.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 { getTerminalSelectionForClipboard } from "./terminal/normalizeTerminalSelection";
|
|
import { getHistoryPreviewSelectionFromRoot } from "./terminal/runtime/terminalHistoryScrollOverride";
|
|
import { createTerminalOutputHistoryPreview } from "./terminal/runtime/terminalOutputHistory";
|
|
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
|
import {
|
|
createTerminalSessionStarters,
|
|
type PendingAuth,
|
|
type TerminalSessionDataMeta,
|
|
} from "./terminal/runtime/createTerminalSessionStarters";
|
|
import {
|
|
createXTermRuntime,
|
|
resetKittyKeyboardModeStateForSession,
|
|
type XTermRuntime,
|
|
} from "./terminal/runtime/createXTermRuntime";
|
|
import { clearKittyKeyboardBroadcastSession } from "./terminal/runtime/kittyKeyboardBroadcast";
|
|
import { registerTerminalSensitiveInputReader } from "./terminal/runtime/terminalSensitiveInputRegistry";
|
|
import { registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
|
|
import { isIdleShellReadyForCommandInjection } from "../domain/terminalCommandInjectionReady";
|
|
import { detectPrompt } from "./terminal/autocomplete/promptDetector";
|
|
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
|
|
import { terminalAltKeyOptions } from "./terminal/runtime/altKeyOptions";
|
|
import {
|
|
createPromptLineBreakState,
|
|
markTerminalCommandCompletionPending,
|
|
type PromptLineBreakState,
|
|
} from "./terminal/runtime/promptLineBreak";
|
|
import {
|
|
prepareSudoAutofillInput,
|
|
type PasswordPromptPickerState,
|
|
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 { getRememberedYmodemSendDefaultPath, rememberYmodemSendFilePath } from "../application/state/ymodemFileMemory";
|
|
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,
|
|
invalidateTerminalCwdAfterCommand,
|
|
resolvePreferredTerminalCwd,
|
|
type TerminalCwdChangeMeta,
|
|
} from "./terminal/sftpCwd";
|
|
import { useTerminalEffects } from "./terminal/useTerminalEffects";
|
|
import { useTerminalHibernateEffect } from "./terminal/useTerminalHibernateEffect";
|
|
import { readActiveTerminalBufferTextRange } from "./terminal/terminalContextBuffer";
|
|
import {
|
|
applyAuthoritativeHibernateSnapshot,
|
|
appendHibernatePendingBuffer,
|
|
isTerminalAlternateScreenActive,
|
|
readTerminalHibernateContext,
|
|
resolveTerminalSnapshotCapture,
|
|
serializeTerminalForHibernate,
|
|
} from "./terminal/terminalHibernateRuntime";
|
|
import {
|
|
ackTerminalSessionFlow,
|
|
clearTerminalSessionFlowAck,
|
|
flushTerminalSessionFlowAck,
|
|
} from "./terminal/runtime/terminalFlowAckBuffer";
|
|
import {
|
|
releaseTerminalFlowBeforeHibernate,
|
|
} from "./terminal/runtime/terminalSessionAttachment";
|
|
import { resetTerminalLineTimestamps } from "./terminal/runtime/terminalLineTimestamps";
|
|
import {
|
|
flushPendingTerminalWritesBeforeHibernate,
|
|
hasPendingTerminalWrites,
|
|
runWithTerminalOutputPausedAfterWritesSettle,
|
|
writeLocalTerminalDataInOrder,
|
|
} from "./terminal/runtime/terminalUnfocusedRepaint";
|
|
import {
|
|
canHibernateTerminalRuntimeSession,
|
|
canHibernateTerminalRuntimeStatus,
|
|
isTerminalFileTransferActive,
|
|
resolveHibernateKeepRendererCount,
|
|
resolveHibernatePreferWasmSerialize,
|
|
resolveHibernateSkipAltScreen,
|
|
resolveTerminalHibernateDelayMs,
|
|
resolveTerminalHibernateEnabledForProtocol,
|
|
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 {
|
|
cancelConnectAutomationBatch,
|
|
createConnectAutomationBatch,
|
|
trackConnectAutomationStop,
|
|
type ConnectAutomationBatch,
|
|
} from "./terminal/connectAutomationBatch";
|
|
import {
|
|
getInitialTerminalStatus,
|
|
resolveTerminalVaultInitialized,
|
|
shouldResetConnectAutomationOnReconnect,
|
|
shouldSuppressHostStartupCommandOnReconnect,
|
|
shouldStartTerminalBackend,
|
|
} from "./terminal/restoredSessionGate";
|
|
import {
|
|
alignTerminalViewportScroll,
|
|
createSynchronizedOutputFitScheduler,
|
|
AUTO_RUN_SNIPPET_LINE_DELAY_MS,
|
|
forceSyncRenderAfterResize,
|
|
MAX_CONNECTION_LOG_DATA_CHARS,
|
|
shouldDelayAutoRunSnippetInput,
|
|
shouldHideConnectingDialogForConnectionReuse,
|
|
shouldShowTerminalDisconnectedNotice,
|
|
shouldShowTerminalConnectionDialog,
|
|
type TerminalProps,
|
|
} from "./terminal/terminalHelpers";
|
|
import { terminalPropsAreEqual } from "./terminal/terminalMemo";
|
|
|
|
const HIBERNATE_RETRY_AFTER_DRAIN_MS = 250;
|
|
const EMPTY_CHAIN_HOSTS: Host[] = [];
|
|
const TerminalComponent: React.FC<TerminalProps> = ({
|
|
host,
|
|
keys,
|
|
identities,
|
|
snippets,
|
|
snippetPackages = [],
|
|
compactToolbar = false,
|
|
onDeleteSnippets,
|
|
lineTimestampsAvailable = true,
|
|
chainHosts = EMPTY_CHAIN_HOSTS,
|
|
appearanceTheme,
|
|
knownHosts = [],
|
|
isVisible,
|
|
paneLayoutKey,
|
|
inWorkspace,
|
|
isResizing,
|
|
isFocusMode,
|
|
isPaneMagnified,
|
|
isFocused,
|
|
isFocusedPane,
|
|
fontFamilyId,
|
|
fontSize,
|
|
terminalTheme,
|
|
followAppTerminalTheme = false,
|
|
terminalSettings,
|
|
sessionId,
|
|
workspaceId,
|
|
restoreState,
|
|
vaultInitializedOverride,
|
|
pendingInitialCwd,
|
|
shellType,
|
|
lastCwd,
|
|
restoreTerminalCwd = false,
|
|
startupCommand,
|
|
noAutoRun,
|
|
multiLineRunMode,
|
|
pendingScriptId,
|
|
pendingScript,
|
|
reuseConnectionFromSessionId,
|
|
requireFreshConnection = false,
|
|
attachExistingSession = false,
|
|
attachAuthorization,
|
|
onAttachClosePreparationChange,
|
|
serialConfig,
|
|
hotkeyScheme = "disabled",
|
|
disableTerminalFontZoom = false,
|
|
keyBindings = [],
|
|
onHotkeyAction,
|
|
onTerminalFontSizeChange,
|
|
onStatusChange,
|
|
onSessionExit,
|
|
onTerminalDataCapture,
|
|
onOsDetected,
|
|
onCloseSession,
|
|
onUpdateHost,
|
|
onAddKnownHost,
|
|
onExpandToFocus,
|
|
onTogglePaneMagnification,
|
|
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,
|
|
sudoAutofillCandidates,
|
|
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 hostConnectionTimeouts = resolveHostSshConnectionTimeouts(host);
|
|
const CONNECTION_TIMEOUT = hostConnectionTimeouts.tcpConnectTimeoutSeconds * 1000;
|
|
const effectiveTerminalProtocol = resolveEffectiveTerminalProtocol(host);
|
|
const isPluginConnection = isPluginHostProtocol(effectiveTerminalProtocol);
|
|
const { t } = useI18n();
|
|
const openExternalErrorTextRef = useRef({
|
|
body: t("settings.application.openExternal.failedBody"),
|
|
title: t("settings.application.openExternal.failedTitle"),
|
|
});
|
|
openExternalErrorTextRef.current = {
|
|
body: t("settings.application.openExternal.failedBody"),
|
|
title: t("settings.application.openExternal.failedTitle"),
|
|
};
|
|
const onOpenExternalError = useCallback(() => {
|
|
const message = openExternalErrorTextRef.current;
|
|
toast.error(message.body, message.title);
|
|
}, []);
|
|
// Reactive vault-ready flag so restored panes re-arm boot after hydration
|
|
// (module isVaultInitialized() alone is not a React dependency).
|
|
const sharedVaultInitialized = useVaultSnapshotField("isVaultInitialized");
|
|
const vaultInitialized = resolveTerminalVaultInitialized(
|
|
sharedVaultInitialized,
|
|
vaultInitializedOverride,
|
|
);
|
|
const connectScriptsConsumedRef = useRef(false);
|
|
const connectScriptsCompletedIdsRef = useRef(new Set<string>());
|
|
const connectScriptsInFlightRef = useRef(false);
|
|
const connectScriptsBatchRef = useRef<ConnectAutomationBatch | null>(null);
|
|
const pendingScriptRunIdRef = useRef<string | null>(null);
|
|
const pendingScriptHandledRef = useRef<Snippet | null>(null);
|
|
const pendingScriptRef = useRef(pendingScript);
|
|
const pendingScriptIdRef = useRef(pendingScriptId);
|
|
pendingScriptRef.current = pendingScript;
|
|
pendingScriptIdRef.current = pendingScriptId;
|
|
const isPendingScriptAlreadyHandled = useCallback((snippet: Snippet) => {
|
|
if (snippet.id) {
|
|
return pendingScriptRunIdRef.current === snippet.id;
|
|
}
|
|
return pendingScriptHandledRef.current === snippet;
|
|
}, []);
|
|
// Mosh marks status=connected during the SSH handshake so interactive
|
|
// prompts remain reachable. Connect/pending scripts must wait until
|
|
// mosh-client is ready (#2199). closeSession clears preload ready
|
|
// listeners for this session id — resubscribe synchronously before each
|
|
// startMosh (not only in a useEffect, which can lose a race with reconnect).
|
|
const [moshShellReady, setMoshShellReady] = useState(() => effectiveTerminalProtocol !== 'mosh');
|
|
const disposeMoshReadyRef = useRef<(() => void) | 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 terminalTitleRef = useRef<string | undefined>(undefined);
|
|
const pluginTerminalLifecycleRef = useRef<ReturnType<typeof usePluginTerminalSessionLifecycle> | null>(null);
|
|
useEffect(() => registerTerminalSensitiveInputReader(
|
|
sessionId,
|
|
() => passwordPromptActiveRef.current,
|
|
), [sessionId]);
|
|
const sensitivePromptOutputTailRef = useRef("");
|
|
const [activeScriptRun, setActiveScriptRun] = useState<import('@/types/global/netcatty-bridge-script.d.ts').ScriptRun | undefined>(undefined);
|
|
const dismissedScriptRunIdsRef = useRef(new Set<string>());
|
|
|
|
useEffect(() => {
|
|
return subscribeScriptRuns((runs) => {
|
|
setActiveScriptRun(selectScriptOverlayRun(runs, sessionId, dismissedScriptRunIdsRef.current));
|
|
});
|
|
}, [sessionId]);
|
|
|
|
const dismissScriptOverlay = useCallback(() => {
|
|
if (activeScriptRun) {
|
|
dismissedScriptRunIdsRef.current.add(activeScriptRun.runId);
|
|
}
|
|
setActiveScriptRun(undefined);
|
|
}, [activeScriptRun]);
|
|
const scriptSessionName = sessionDisplayName || host.label;
|
|
const outputTriggers = useOutputTriggers({
|
|
sessionId,
|
|
host,
|
|
snippets,
|
|
onRunScript: (snippet, sid) => runAutomationScript({
|
|
snippet,
|
|
sessionId: sid,
|
|
sessionMeta: {
|
|
connected: true,
|
|
name: scriptSessionName,
|
|
hostname: host.hostname,
|
|
username: host.username,
|
|
},
|
|
}).then(() => undefined).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 hibernatePendingCapDisabledRef = useRef(false);
|
|
const hibernateAlternateScreenRef = useRef(false);
|
|
const hibernateRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const fullHibernateRuntimeRef = useRef<(() => Promise<boolean>) | null>(null);
|
|
const wakeSoftHiddenRuntimeRef = useRef<(() => void) | null>(null);
|
|
const resumeRendererAfterCancelledHibernateUpgradeRef = useRef<(() => void) | null>(null);
|
|
const wakeInProgressRef = useRef(false);
|
|
const wakePromiseRef = useRef<Promise<boolean> | null>(null);
|
|
const sessionRef = useRef<string | null>(null);
|
|
const sessionCleanupPromiseRef = useRef<Promise<void> | null>(null);
|
|
/** Epoch owned by an in-flight disconnect/teardown close (before bump). */
|
|
const pendingCloseBootEpochRef = useRef<number | undefined>(undefined);
|
|
const trackSessionCleanup = (promise: Promise<unknown>) => {
|
|
const settled = Promise.resolve(promise).then(
|
|
() => undefined,
|
|
() => undefined,
|
|
);
|
|
const previous = sessionCleanupPromiseRef.current;
|
|
const next = previous
|
|
? Promise.all([previous, settled]).then(() => undefined)
|
|
: settled;
|
|
sessionCleanupPromiseRef.current = next;
|
|
void next.finally(() => {
|
|
if (sessionCleanupPromiseRef.current === next) {
|
|
sessionCleanupPromiseRef.current = null;
|
|
}
|
|
});
|
|
};
|
|
const isBootActiveRef = useRef(false);
|
|
const bootEpochRef = useRef(0);
|
|
const publishBootEpoch = () => {
|
|
setTerminalBootEpoch(sessionId, bootEpochRef.current);
|
|
};
|
|
const bumpBootEpoch = () => {
|
|
bootEpochRef.current += 1;
|
|
publishBootEpoch();
|
|
};
|
|
/** Invalidate the current boot and remember its epoch for the matching closeSession. */
|
|
const invalidateBootEpochForClose = () => {
|
|
const closingEpoch = bootEpochRef.current;
|
|
bumpBootEpoch();
|
|
pendingCloseBootEpochRef.current = closingEpoch;
|
|
return closingEpoch;
|
|
};
|
|
const resolveCloseBootEpoch = () => {
|
|
if (pendingCloseBootEpochRef.current !== undefined) {
|
|
const epoch = pendingCloseBootEpochRef.current;
|
|
pendingCloseBootEpochRef.current = undefined;
|
|
return epoch;
|
|
}
|
|
return bootEpochRef.current;
|
|
};
|
|
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 reconnectPreparationTokenRef = useRef<symbol | null>(null);
|
|
const autoReconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const autoReconnectLoopActiveRef = useRef(false);
|
|
const [manualReconnectActive, setManualReconnectActive] = useState(false);
|
|
const autoReconnectAttemptRef = useRef(0);
|
|
const startReconnectRef = useRef<((mode: "manual" | "auto") => void) | null>(null);
|
|
const wakeHibernatedRuntimeForReconnectRef = useRef<(() => Promise<boolean>) | null>(null);
|
|
/** Connected wake for multi-tab snippet fan-out (reattaches session listeners). */
|
|
const wakeHibernatedRuntimeForConnectedRef = useRef<(() => Promise<boolean>) | null>(null);
|
|
const reconnectWakeInFlightRef = useRef(false);
|
|
// When a hibernated reconnect wake is invalidated: 'dispose' for unmount/
|
|
// session teardown (orphan runtime must go), 'keep' for Disconnect so the
|
|
// pane retains an xterm instance and can reconnect without being remounted.
|
|
const reconnectWakeInvalidateModeRef = useRef<"dispose" | "keep">("dispose");
|
|
const reconnectWakeTokenRef = useRef<symbol | null>(null);
|
|
const manualReconnectRequestRef = useRef<() => void>(() => {});
|
|
// Once a pane reconnects, every later SSH attempt in it must dial a fresh
|
|
// connection: reusing a live/idle pooled transport multiplexes onto the old
|
|
// login, so remote supplementary-group changes (e.g. `usermod -aG`) are not
|
|
// picked up until the app fully quits (#3293). See createTerminalSessionStarters.
|
|
const requireFreshConnectionOnReconnectRef = useRef(false);
|
|
const terminalDataCapturedRef = useRef(false);
|
|
const connectionLogBufferRef = useRef(createConnectionLogBuffer(MAX_CONNECTION_LOG_DATA_CHARS));
|
|
const terminalOutputHistoryRef = useRef(createTerminalOutputHistoryPreview());
|
|
const terminalLogSanitizerRef = useRef(createReplaySafeTerminalLogSanitizer());
|
|
const commandLogRewriterRef = useRef(createProgrammaticCommandLogRewriter());
|
|
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
|
|
const onSessionExitRef = useRef(onSessionExit);
|
|
const commandBufferRef = useRef<string>("");
|
|
useEffect(() => registerTerminalCommandInjectionReadyReader(sessionId, () => {
|
|
const term = termRef.current;
|
|
if (!term) return false;
|
|
const prompt = detectPrompt(term);
|
|
return isIdleShellReadyForCommandInjection({
|
|
sensitiveInputActive: passwordPromptActiveRef.current,
|
|
hasLiveTerminal: true,
|
|
alternateScreenActive: isTerminalAlternateScreenActive(term),
|
|
isAtPrompt: prompt.isAtPrompt,
|
|
userInputLength: prompt.userInput.length,
|
|
pendingTypedInputLength: commandBufferRef.current.length,
|
|
});
|
|
}), [sessionId]);
|
|
const promptLineBreakStateRef = useRef<PromptLineBreakState>(createPromptLineBreakState());
|
|
const [hasMouseTracking, setHasMouseTracking] = useState(false);
|
|
const mouseTrackingRef = useRef(false);
|
|
const serialLineBufferRef = useRef<string>("");
|
|
const telnetLocalEchoRef = useRef(false);
|
|
const pluginTerminalSessionExitRef = useRef<(exitCode?: number) => void>(() => {});
|
|
|
|
useLayoutEffect(() => {
|
|
onSessionExitRef.current = onSessionExit;
|
|
}, [onSessionExit]);
|
|
|
|
useEffect(() => () => {
|
|
reconnectWakeInvalidateModeRef.current = "dispose";
|
|
reconnectWakeTokenRef.current = null;
|
|
}, [sessionId]);
|
|
|
|
const terminalSettingsRef = useRef(terminalSettings);
|
|
terminalSettingsRef.current = terminalSettings;
|
|
const hostRef = useRef(host);
|
|
hostRef.current = host;
|
|
// The protocol capability is connection-scoped. A settings change applies
|
|
// to newly mounted terminals so an already-negotiated remote never sees the
|
|
// parser and encoder disagree or disappear during hibernation/reconnect.
|
|
const kittyKeyboardProtocolEnabledForSessionRef = useRef(
|
|
terminalSettings?.kittyKeyboardProtocolEnabled === true,
|
|
);
|
|
const kittyKeyboardProtocolEnabledForSession =
|
|
kittyKeyboardProtocolEnabledForSessionRef.current;
|
|
const isSearchOpenRef = useRef(false);
|
|
const hibernateFileTransferActiveRef = useRef(false);
|
|
const handleUpdateHostFromTerminal = useCallback((hostUpdate: TerminalHostUpdate) => {
|
|
onUpdateHost?.(hostUpdate as Host);
|
|
}, [onUpdateHost]);
|
|
onTerminalDataCaptureRef.current = onTerminalDataCapture;
|
|
const isVisibleRef = useRef(isVisible);
|
|
isVisibleRef.current = isVisible;
|
|
const isFocusedRef = useRef(!!isFocused);
|
|
isFocusedRef.current = !!isFocused;
|
|
const oscNotificationScannerRef = useRef(new OscNotificationStreamScanner());
|
|
const hibernateEnabled =
|
|
resolveTerminalHibernateEnabledForProtocol(terminalSettings, effectiveTerminalProtocol) &&
|
|
!kittyKeyboardProtocolEnabledForSession &&
|
|
!isBroadcastEnabled;
|
|
const hibernateEnabledRef = useRef(hibernateEnabled);
|
|
hibernateEnabledRef.current = hibernateEnabled;
|
|
const isRendererActive = isVisible || !hibernateEnabled;
|
|
const isRendererActiveRef = useRef(isRendererActive);
|
|
isRendererActiveRef.current = isRendererActive;
|
|
const pendingOutputScrollRef = useRef(false);
|
|
const lastFittedSizeRef = useRef<{ width: number; height: number } | null>(null);
|
|
const fontWeightFixupDoneRef = useRef(false);
|
|
|
|
const captureTerminalLogData = useCallback((data: string) => {
|
|
// Keep the history tracker's viewport size current so cursor-row and
|
|
// cursor-column moves are clamped exactly like the terminal clamps them.
|
|
const captureTerm = termRef.current;
|
|
if (captureTerm) {
|
|
terminalOutputHistoryRef.current.setViewportRows(captureTerm.rows);
|
|
terminalOutputHistoryRef.current.setViewportCols(captureTerm.cols);
|
|
// The tracker's wrap decisions must measure cell widths the way this
|
|
// terminal's Unicode provider does, or the preview diverges from what
|
|
// xterm actually rendered (e.g. `15-graphemes` emoji widths).
|
|
terminalOutputHistoryRef.current.setWidthTerminal(captureTerm);
|
|
}
|
|
const readableCommandData = commandLogRewriterRef.current.append(data);
|
|
// The alternate-screen preview reads the raw display stream (before the
|
|
// replay sanitizer, which drops alternate-screen output on purpose) but
|
|
// after the command rewriter, so masked commands stay masked (#2516).
|
|
terminalOutputHistoryRef.current.append(readableCommandData);
|
|
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);
|
|
let term = termRef.current;
|
|
let liveContextReady = !term;
|
|
for (let attempt = 0; term && attempt < 2; attempt += 1) {
|
|
const targetTerm = term;
|
|
const flushed = await flushPendingTerminalWritesBeforeHibernate(targetTerm);
|
|
term = termRef.current;
|
|
if (term !== targetTerm) continue;
|
|
if (!flushed) {
|
|
return { ok: false, error: "Terminal output is still draining; retry the context read." };
|
|
}
|
|
liveContextReady = true;
|
|
break;
|
|
}
|
|
if (term && !liveContextReady) {
|
|
return { ok: false, error: "Terminal changed while reading its context; retry the request." };
|
|
}
|
|
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 retainedText = hibernateContextSnapshotRef.current;
|
|
const fullText = retainedText
|
|
? [retainedText, hibernatePendingBufferRef.current].filter(Boolean).join("\n")
|
|
: snapshot.fullText;
|
|
const viewportEndLine = hibernatePendingBufferRef.current && fullText
|
|
? fullText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n").length - 1
|
|
: snapshot.viewportEndLine;
|
|
|
|
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,
|
|
});
|
|
}, [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) => {
|
|
const term = termRef.current;
|
|
if (!term) return;
|
|
writeLocalTerminalDataInOrder(term, data, captureTerminalLogData);
|
|
}, [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 {
|
|
chooseManualSessionLogPath,
|
|
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 hasEverConnectedRef = useRef(status === "connected");
|
|
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(CONNECTION_PROGRESS_START);
|
|
const [isDisconnectedDialogDismissed, setIsDisconnectedDialogDismissed] = useState(false);
|
|
const [connectionReuseFellBack, setConnectionReuseFellBack] = useState(false);
|
|
const [connectionReuseAttemptSourceId, setConnectionReuseAttemptSourceId] = useState(
|
|
reuseConnectionFromSessionId,
|
|
);
|
|
|
|
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);
|
|
// Prefer parent-supplied candidates (TerminalLayer); otherwise derive from
|
|
// host/keys/identities so standalone popups (TerminalPopupPage) still work.
|
|
const resolvedSudoAutofillCandidates = useMemo(
|
|
() => isPluginConnection
|
|
? []
|
|
: sudoAutofillCandidates
|
|
?? listPasswordPromptFillCandidates({ host, keys, identities }),
|
|
[sudoAutofillCandidates, host, keys, identities, isPluginConnection],
|
|
);
|
|
const resolvedSudoAutofillPassword = useMemo(
|
|
() => isPluginConnection
|
|
? undefined
|
|
: sudoAutofillPassword
|
|
?? resolveHostAutofillPassword({ host, keys, identities }),
|
|
[sudoAutofillPassword, host, keys, identities, isPluginConnection],
|
|
);
|
|
const sudoAutofillPasswordRef = useRef(resolvedSudoAutofillPassword);
|
|
sudoAutofillPasswordRef.current = resolvedSudoAutofillPassword;
|
|
const resolvedLoginUsername = useMemo(
|
|
() => resolveHostAuth({ host, keys, identities }).username,
|
|
[host, keys, identities],
|
|
);
|
|
const sudoAutofillCandidatesRef = useRef(resolvedSudoAutofillCandidates);
|
|
sudoAutofillCandidatesRef.current = resolvedSudoAutofillCandidates;
|
|
const [passwordPickerState, setPasswordPickerState] = useState<PasswordPromptPickerState | null>(null);
|
|
const passwordPickerRef = useRef<
|
|
((active: boolean, state: PasswordPromptPickerState | null) => boolean) | undefined
|
|
>(undefined);
|
|
passwordPickerRef.current = (active, state) => {
|
|
setPasswordPickerState(active && state ? state : null);
|
|
return true;
|
|
};
|
|
|
|
const [chainProgress, setChainProgress] = useState<{
|
|
currentHop: number;
|
|
totalHops: number;
|
|
currentHostLabel: string;
|
|
connectionPhase: string;
|
|
} | null>(null);
|
|
const [isConnectionAwaitingUserInput, setIsConnectionAwaitingUserInput] = useState(false);
|
|
const [isConnectionPastTcpDial, setIsConnectionPastTcpDial] = useState(false);
|
|
const [reconnectNoticeMessage, setReconnectNoticeMessage] = useState<string | null>(null);
|
|
|
|
// 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;
|
|
isSearchOpenRef.current = isSearchOpen;
|
|
|
|
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) {
|
|
const sensitive = passwordPromptActiveRef.current;
|
|
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, { sensitive });
|
|
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, { sensitive });
|
|
for (const ch of text) {
|
|
if (ch === "\r") {
|
|
writeLocalTerminalData("\r\n");
|
|
} else if (ch.charCodeAt(0) >= 32) {
|
|
writeLocalTerminalData(ch);
|
|
}
|
|
}
|
|
} else {
|
|
terminalBackend.writeToSession(id, textToWrite, { sensitive });
|
|
}
|
|
|
|
// Broadcast to other sessions if broadcast mode is enabled
|
|
if (!sensitive && isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
|
|
onBroadcastInputRef.current(text, sessionId);
|
|
}
|
|
|
|
// ESC-prefixed writes (Esc+. yank-last-arg) are shell editor commands.
|
|
// Walking printable bytes would append "." and leave history/completions
|
|
// tracking a stale line.
|
|
if (text.startsWith("\x1b")) {
|
|
return;
|
|
}
|
|
|
|
// 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;
|
|
const sensitive = passwordPromptActiveRef.current;
|
|
if (recorderRef.current.isRecording) {
|
|
void recorderRef.current.recordEnter({
|
|
sensitive,
|
|
});
|
|
}
|
|
passwordPromptActiveRef.current = false;
|
|
recordTerminalCommandExecution(rawCommand, {
|
|
host,
|
|
sessionId,
|
|
onCommandExecuted,
|
|
onCommandSubmitted: cwdAwareOnCommandSubmitted,
|
|
onTrustedCommandSubmitted: pluginAwareOnCommandSubmitted,
|
|
commandBufferRef,
|
|
promptLineBreakStateRef,
|
|
}, termRef.current, { sensitive, allowHostStyleGreaterThanPrompt: isNetworkDevice });
|
|
} 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 resolvedAutocompleteOs = host.protocol === "local"
|
|
? detectLocalOs(navigator.userAgent || navigator.platform)
|
|
: resolveHostOs(host);
|
|
const autocompleteHostOs = resolvedAutocompleteOs === "windows" || resolvedAutocompleteOs === "macos"
|
|
? resolvedAutocompleteOs : "linux";
|
|
const autocompleteSettings = resolveTerminalAutocompleteSettings({
|
|
protocol: effectiveTerminalProtocol,
|
|
terminalSettings,
|
|
systemUnknown: resolvedAutocompleteOs === 'unknown',
|
|
isNetworkDevice: host.deviceType === 'network'
|
|
|| classifyDistroId(host.distro) === 'network-device',
|
|
});
|
|
|
|
const resolveSftpInitialPath = useCallback(async (options?: {
|
|
preferFreshBackend?: boolean;
|
|
allowRendererFallback?: boolean;
|
|
requireActiveShellCwd?: boolean;
|
|
}): Promise<string | undefined> => {
|
|
const cwd = await resolvePreferredTerminalCwd({
|
|
rendererCwd: terminalCwdTracker.getRendererCwd(),
|
|
rendererCwdSource: terminalCwdTracker.getRendererCwdSource(),
|
|
sessionId: sessionRef.current,
|
|
getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options),
|
|
preferFreshBackend: options?.preferFreshBackend,
|
|
allowRendererFallback: options?.allowRendererFallback,
|
|
requireActiveShellCwd: options?.requireActiveShellCwd,
|
|
});
|
|
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 &&
|
|
!isPluginConnection &&
|
|
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 = shouldCollectServerStats(host, undefined, null);
|
|
const isSystemSidebarEligible =
|
|
!!onOpenSystem &&
|
|
isSupportedOs &&
|
|
!isLocalConnection &&
|
|
!isSerialConnection &&
|
|
!isPluginConnection &&
|
|
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(resolvedSudoAutofillPassword);
|
|
}, [resolvedSudoAutofillPassword]);
|
|
useEffect(() => {
|
|
sudoAutofillRef.current?.updateCandidates(resolvedSudoAutofillCandidates);
|
|
}, [resolvedSudoAutofillCandidates]);
|
|
useEffect(() => {
|
|
const mode = terminalSettings?.passwordPromptAssist ?? "hint";
|
|
sudoAutofillRef.current?.updateMode(mode);
|
|
}, [terminalSettings?.passwordPromptAssist]);
|
|
// Drop a stale picker if the session disconnects/reconnects — exit teardown
|
|
// nulls sudoAutofillRef without calling onPicker(false).
|
|
useEffect(() => {
|
|
if (status === "disconnected" || status === "connecting") {
|
|
setPasswordPickerState(null);
|
|
}
|
|
}, [status]);
|
|
const handlePasswordPickerSelect = useCallback((id: string) => {
|
|
sudoAutofillRef.current?.confirmFill(id);
|
|
}, []);
|
|
const passwordPickerTitle = t("terminal.passwordPicker.title");
|
|
const passwordPickerEmptyText = t("terminal.passwordPicker.empty");
|
|
const sudoHintText = t("terminal.sudoHint.pressEnter");
|
|
const sessionStartersRef = useRef<ReturnType<typeof createTerminalSessionStarters> | null>(null);
|
|
const reuseConnectionSourceRef = useRef(reuseConnectionFromSessionId);
|
|
const reuseConnectionSourceAttemptedRef = useRef(false);
|
|
const previousReuseConnectionSourcePropRef = useRef(reuseConnectionFromSessionId);
|
|
if (previousReuseConnectionSourcePropRef.current !== reuseConnectionFromSessionId) {
|
|
previousReuseConnectionSourcePropRef.current = reuseConnectionFromSessionId;
|
|
reuseConnectionSourceRef.current = reuseConnectionFromSessionId;
|
|
reuseConnectionSourceAttemptedRef.current = false;
|
|
}
|
|
const auth = useTerminalAuthState({
|
|
host,
|
|
pendingAuthRef,
|
|
termRef,
|
|
onUpdateHost: handleUpdateHostFromTerminal,
|
|
onStartSession: (term) => {
|
|
const starters = sessionStartersRef.current;
|
|
if (!starters) return;
|
|
if (effectiveTerminalProtocol.startsWith('plugin:')) {
|
|
starters.startPluginConnection(term);
|
|
return;
|
|
}
|
|
if (effectiveTerminalProtocol === 'mosh') {
|
|
starters.startMosh(term);
|
|
return;
|
|
}
|
|
if (effectiveTerminalProtocol === 'et') {
|
|
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(result.sentToTerminal
|
|
? t("terminal.osc7Setup.sent")
|
|
: 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 { accentMode, customAccent } = useAppearanceChromeStore();
|
|
|
|
const baseEffectiveTheme = useMemo(() => {
|
|
// Always re-apply appearanceChromeStore accent. appearanceTheme from the
|
|
// layer may still carry a previously baked custom accent while TerminalLayer
|
|
// memo ignores accent churn — re-resolve the catalog theme by id first.
|
|
const resolveBase = (): typeof terminalTheme => {
|
|
if (appearanceTheme) {
|
|
const clean = getBuiltinTerminalThemeById(appearanceTheme.id)
|
|
|| customThemes.find((t) => t.id === appearanceTheme.id);
|
|
if (clean) return clean;
|
|
}
|
|
if (followAppTerminalTheme) return terminalTheme;
|
|
const themeId = resolveHostTerminalThemeId(
|
|
{ theme: host.theme, themeOverride: host.themeOverride } as Pick<Host, 'theme' | 'themeOverride'>,
|
|
terminalTheme.id,
|
|
);
|
|
if (themeId) {
|
|
const hostTheme = getBuiltinTerminalThemeById(themeId)
|
|
|| customThemes.find((t) => t.id === themeId);
|
|
if (hostTheme) return hostTheme;
|
|
}
|
|
return terminalTheme;
|
|
};
|
|
return applyCustomAccentToTerminalTheme(resolveBase(), accentMode, customAccent);
|
|
}, [accentMode, appearanceTheme, customAccent, customThemes, followAppTerminalTheme, host.theme, host.themeOverride, terminalTheme]);
|
|
|
|
const resolvedChainHosts =
|
|
chainHosts;
|
|
|
|
const clearAutoReconnect = useCallback((options?: { stopLoop?: boolean; clearNotice?: boolean }) => {
|
|
if (autoReconnectTimerRef.current) {
|
|
clearTimeout(autoReconnectTimerRef.current);
|
|
autoReconnectTimerRef.current = null;
|
|
}
|
|
if (options?.stopLoop !== false) {
|
|
autoReconnectLoopActiveRef.current = false;
|
|
autoReconnectAttemptRef.current = 0;
|
|
}
|
|
if (options?.clearNotice !== false) setReconnectNoticeMessage(null);
|
|
}, []);
|
|
|
|
const updateStatus = useCallback((next: TerminalSession["status"]) => {
|
|
statusRef.current = next;
|
|
setStatus(next);
|
|
hasConnectedRef.current = next === "connected";
|
|
if (next !== "connecting") {
|
|
setManualReconnectActive(false);
|
|
terminalReconnectRegistry.setActive(sessionId, false);
|
|
}
|
|
if (next === "connected") {
|
|
hasEverConnectedRef.current = true;
|
|
clearAutoReconnect();
|
|
}
|
|
onStatusChange?.(sessionId, next);
|
|
}, [clearAutoReconnect, onStatusChange, sessionId]);
|
|
const updateStatusRef = useRef(updateStatus);
|
|
updateStatusRef.current = updateStatus;
|
|
|
|
const scheduleAutoReconnect = useCallback((trigger?: { evt?: Parameters<typeof shouldAutoReconnectAfterExit>[0]["evt"] }) => {
|
|
// Observe popups display an existing backend; the hidden owner remains the
|
|
// sole authority for reconnecting or replacing that session.
|
|
if (attachExistingSession) return false;
|
|
const shouldSchedule = trigger?.evt
|
|
? shouldAutoReconnectAfterExit({
|
|
evt: trigger.evt,
|
|
host,
|
|
terminalSettings,
|
|
hasEverConnected: hasEverConnectedRef.current,
|
|
})
|
|
: shouldContinueAutoReconnectAfterFailure({
|
|
host,
|
|
terminalSettings,
|
|
loopActive: autoReconnectLoopActiveRef.current,
|
|
});
|
|
|
|
if (!shouldSchedule || !canAttemptTerminalAutoReconnect({
|
|
hasTerminalRuntime: Boolean(termRef.current),
|
|
isHibernated: hibernatedRef.current,
|
|
})) {
|
|
return false;
|
|
}
|
|
|
|
autoReconnectLoopActiveRef.current = true;
|
|
if (autoReconnectTimerRef.current) {
|
|
return true;
|
|
}
|
|
|
|
autoReconnectAttemptRef.current += 1;
|
|
const attempt = autoReconnectAttemptRef.current;
|
|
const seconds = Math.round(TERMINAL_AUTO_RECONNECT_DELAY_MS / 1000);
|
|
const scheduledMessage = t("terminal.progress.autoReconnectScheduled", { seconds, attempt });
|
|
setReconnectNoticeMessage(scheduledMessage);
|
|
|
|
setError(null);
|
|
setShowLogs(true);
|
|
setIsDisconnectedDialogDismissed(false);
|
|
setProgressLogs((prev) => [...prev, scheduledMessage]);
|
|
updateStatus("connecting");
|
|
|
|
autoReconnectTimerRef.current = setTimeout(() => {
|
|
autoReconnectTimerRef.current = null;
|
|
startReconnectRef.current?.("auto");
|
|
}, TERMINAL_AUTO_RECONNECT_DELAY_MS);
|
|
|
|
return true;
|
|
}, [attachExistingSession, host, t, terminalSettings, updateStatus]);
|
|
|
|
const prepareRestoredReconnect = useCallback(() => {
|
|
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect(
|
|
restoreState === "restored-disconnected" ? "restored" : "manual",
|
|
);
|
|
if (restoreState !== "restored-disconnected") {
|
|
restoreCwdIntentRef.current = null;
|
|
return;
|
|
}
|
|
|
|
restoreCwdIntentRef.current = resolveRestoreCwdIntent({
|
|
enabled: restoreTerminalCwd,
|
|
session: {
|
|
status: "disconnected",
|
|
restoreState,
|
|
protocol: host.protocol,
|
|
shellType,
|
|
lastCwd,
|
|
requireFreshConnection,
|
|
moshEnabled: host.moshEnabled,
|
|
etEnabled: host.etEnabled,
|
|
},
|
|
isNetworkDevice,
|
|
});
|
|
}, [
|
|
host.etEnabled,
|
|
host.moshEnabled,
|
|
host.protocol,
|
|
isNetworkDevice,
|
|
lastCwd,
|
|
requireFreshConnection,
|
|
restoreState,
|
|
restoreTerminalCwd,
|
|
shellType,
|
|
]);
|
|
|
|
// Set only once the inherited `cd` is actually written to the shell (from the
|
|
// restore-cwd consumption callback), NOT when the intent is merely prepared —
|
|
// otherwise a first connect that fails before consumption would block the
|
|
// intent from being re-armed on retry, landing the clone in the login dir.
|
|
const initialCwdConsumedRef = useRef(false);
|
|
const prepareInitialCwdIntent = useCallback(() => {
|
|
if (initialCwdConsumedRef.current) return;
|
|
if (!pendingInitialCwd) return;
|
|
const intent = resolveInheritedCwdIntent({
|
|
session: {
|
|
protocol: host.protocol,
|
|
shellType,
|
|
moshEnabled: host.moshEnabled,
|
|
etEnabled: host.etEnabled,
|
|
cwd: pendingInitialCwd,
|
|
},
|
|
isNetworkDevice,
|
|
});
|
|
if (!intent) return;
|
|
restoreCwdIntentRef.current = intent;
|
|
}, [pendingInitialCwd, host.protocol, host.moshEnabled, host.etEnabled, shellType, isNetworkDevice]);
|
|
|
|
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 attachHomeWebContentsIdRef = useRef<number | null | undefined>(undefined);
|
|
|
|
useEffect(() => {
|
|
const bridge = netcattyBridge.get();
|
|
if (!bridge?.onTerminalOutputDrainRequest || !bridge?.respondTerminalOutputDrain) return undefined;
|
|
return bridge.onTerminalOutputDrainRequest(sessionId, async (payload) => {
|
|
const term = termRef.current;
|
|
if (term) {
|
|
const flushed = await flushPendingTerminalWritesBeforeHibernate(term);
|
|
if (!flushed) {
|
|
logger.warn("Terminal output drain did not settle before the deadline", { sessionId });
|
|
return;
|
|
}
|
|
}
|
|
flushTerminalSessionFlowAck(sessionId);
|
|
bridge.respondTerminalOutputDrain?.(payload.requestId);
|
|
});
|
|
}, [sessionId]);
|
|
|
|
// Home renderer for AI observe popups: serialize scrollback on demand, and
|
|
// accept reverse snapshots when the observe popup restores the route.
|
|
useEffect(() => {
|
|
if (attachExistingSession) return undefined;
|
|
const bridge = netcattyBridge.get();
|
|
const unsubs: Array<() => void> = [];
|
|
if (bridge?.onTerminalSessionSnapshotRequest && bridge?.respondTerminalSessionSnapshot) {
|
|
unsubs.push(bridge.onTerminalSessionSnapshotRequest(async (payload) => {
|
|
if (!payload || payload.sessionId !== sessionId) return;
|
|
let snapshot = "";
|
|
try {
|
|
if (serializeAddonRef.current) {
|
|
if (termRef.current) {
|
|
const flushed = await flushPendingTerminalWritesBeforeHibernate(termRef.current);
|
|
if (!flushed) {
|
|
logger.warn("Terminal snapshot drain did not settle before the deadline", { sessionId });
|
|
return;
|
|
}
|
|
}
|
|
await xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization();
|
|
snapshot = serializeAddonRef.current.serialize() || "";
|
|
} else if (hibernatedRef.current || softHiddenRef.current) {
|
|
// Hibernate path: live xterm is torn down; use retained snapshot.
|
|
snapshot = [
|
|
hibernateSnapshotRef.current || "",
|
|
hibernatePendingBufferRef.current || "",
|
|
].join("");
|
|
}
|
|
} catch (err) {
|
|
logger.warn("Failed to serialize terminal snapshot for attach popup", err);
|
|
}
|
|
bridge.respondTerminalSessionSnapshot?.(
|
|
payload.requestId,
|
|
snapshot,
|
|
xtermRuntimeRef.current?.getKittyKeyboardModeState(),
|
|
xtermRuntimeRef.current?.getKittyKeyboardProtocolEnabled()
|
|
?? kittyKeyboardProtocolEnabledForSession,
|
|
passwordPromptActiveRef.current,
|
|
knownCwdRef.current ?? null,
|
|
terminalTitleRef.current ?? null,
|
|
);
|
|
}));
|
|
}
|
|
if (bridge?.onTerminalSessionApplySnapshot) {
|
|
unsubs.push(bridge.onTerminalSessionApplySnapshot(async (payload) => {
|
|
if (
|
|
!payload
|
|
|| payload.sessionId !== sessionId
|
|
|| typeof payload.snapshot !== "string"
|
|
|| typeof payload.contextSnapshot !== "string"
|
|
|| typeof payload.contextViewportSnapshot !== "string"
|
|
|| typeof payload.contextScrollbackSnapshot !== "string"
|
|
|| typeof payload.alternateScreen !== "boolean"
|
|
) return false;
|
|
const term = termRef.current;
|
|
if (term) {
|
|
const flushed = await flushPendingTerminalWritesBeforeHibernate(term);
|
|
if (!flushed || termRef.current !== term) {
|
|
throw new Error("Terminal output did not settle before applying the snapshot");
|
|
}
|
|
}
|
|
if (typeof payload.kittyKeyboardProtocolEnabled === "boolean") {
|
|
xtermRuntimeRef.current?.setKittyKeyboardProtocolEnabled(
|
|
payload.kittyKeyboardProtocolEnabled,
|
|
);
|
|
}
|
|
if (typeof payload.passwordPromptActive === "boolean") {
|
|
passwordPromptActiveRef.current = payload.passwordPromptActive;
|
|
}
|
|
if (payload.cwd !== undefined) {
|
|
const cwd = terminalCwdTracker.setRendererCwd(payload.cwd, "snapshot");
|
|
knownCwdRef.current = cwd;
|
|
pluginTerminalLifecycleRef.current?.onCwdChanged(cwd ?? null);
|
|
onTerminalCwdChange?.(sessionId, cwd ?? null, { source: "snapshot" });
|
|
}
|
|
if (payload.title !== undefined) {
|
|
const title = payload.title || null;
|
|
terminalTitleRef.current = title ?? undefined;
|
|
pluginTerminalLifecycleRef.current?.onTitleChanged(title);
|
|
onTerminalTitleChange?.(sessionId, title);
|
|
}
|
|
if (payload.kittyKeyboardModeState) {
|
|
xtermRuntimeRef.current?.restoreKittyKeyboardModeState(
|
|
payload.kittyKeyboardModeState,
|
|
);
|
|
}
|
|
if (term) {
|
|
term.reset();
|
|
// The buffer was just wiped; drop ledger stamps anchored to the old
|
|
// rows so the reconnect gutter cannot paint stale timestamps.
|
|
resetTerminalLineTimestamps(term);
|
|
if (payload.snapshot) {
|
|
await new Promise<void>((resolve) => term.write(payload.snapshot, resolve));
|
|
}
|
|
xtermRuntimeRef.current?.cursorLineHighlighter.refresh({ force: true });
|
|
try { term.scrollToBottom?.(); } catch { /* ignore */ }
|
|
} else if (hibernatedRef.current || softHiddenRef.current) {
|
|
applyAuthoritativeHibernateSnapshot({
|
|
snapshot: hibernateSnapshotRef,
|
|
viewportSnapshot: hibernateViewportSnapshotRef,
|
|
scrollbackSnapshot: hibernateScrollbackSnapshotRef,
|
|
contextSnapshot: hibernateContextSnapshotRef,
|
|
contextViewportSnapshot: hibernateContextViewportSnapshotRef,
|
|
contextScrollbackSnapshot: hibernateContextScrollbackSnapshotRef,
|
|
pendingBuffer: hibernatePendingBufferRef,
|
|
alternateScreen: hibernateAlternateScreenRef,
|
|
}, payload.snapshot, {
|
|
contextSnapshot: payload.contextSnapshot,
|
|
contextViewportSnapshot: payload.contextViewportSnapshot,
|
|
contextScrollbackSnapshot: payload.contextScrollbackSnapshot,
|
|
alternateScreen: payload.alternateScreen,
|
|
});
|
|
}
|
|
return true;
|
|
}));
|
|
}
|
|
return () => {
|
|
for (const unsub of unsubs) unsub();
|
|
};
|
|
}, [
|
|
attachExistingSession,
|
|
kittyKeyboardProtocolEnabledForSession,
|
|
onTerminalCwdChange,
|
|
onTerminalTitleChange,
|
|
sessionId,
|
|
terminalCwdTracker,
|
|
]);
|
|
|
|
const cleanupSession = async (options?: { retainOwnership?: boolean }) => {
|
|
const closingSessionId = sessionRef.current;
|
|
xtermRuntimeRef.current?.flushKittyKeyboardReleases();
|
|
sessionRef.current = null;
|
|
const disposeSessionListeners = () => {
|
|
disposeDataRef.current?.();
|
|
disposeDataRef.current = null;
|
|
disposeExitRef.current?.();
|
|
disposeExitRef.current = null;
|
|
disposeTelnetEchoModeRef.current?.();
|
|
disposeTelnetEchoModeRef.current = null;
|
|
telnetLocalEchoRef.current = false;
|
|
};
|
|
|
|
if (!attachExistingSession) {
|
|
disposeSessionListeners();
|
|
}
|
|
|
|
const pendingCleanup = sessionCleanupPromiseRef.current;
|
|
if (pendingCleanup) {
|
|
await pendingCleanup;
|
|
}
|
|
|
|
if (!closingSessionId) {
|
|
disposeSessionListeners();
|
|
// Still notify main so in-flight SSH passphrase prompts for this UI
|
|
// sessionId are aborted even before a backend session was attached.
|
|
try {
|
|
await terminalBackend.closeSession(sessionId, {
|
|
bootEpoch: resolveCloseBootEpoch(),
|
|
...(options?.retainOwnership === true ? { retainOwnership: true } : {}),
|
|
});
|
|
} catch (err) {
|
|
logger.warn("Failed to cancel pending session boot on disconnect", err);
|
|
}
|
|
const postDisposeCleanup = sessionCleanupPromiseRef.current;
|
|
if (postDisposeCleanup) {
|
|
await postDisposeCleanup;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Observe/attach popups must not kill the backend session — only release
|
|
// the display route back to the home renderer.
|
|
if (attachExistingSession) {
|
|
const homeId = attachHomeWebContentsIdRef.current;
|
|
attachHomeWebContentsIdRef.current = undefined;
|
|
const outputPauseLease = await terminalBackend.acquireSessionFlowPauseLease(closingSessionId);
|
|
try {
|
|
// Stop the source before detaching the popup listener. This lets any
|
|
// already-delivered writes settle into xterm before its final snapshot.
|
|
const paused = await outputPauseLease.waitForPause();
|
|
if (!paused?.success && paused?.error === "Output drain unavailable") {
|
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
} else if (!paused?.success) {
|
|
throw new Error(paused?.error || "Failed to drain terminal output");
|
|
}
|
|
const snapshotTerm = termRef.current;
|
|
if (snapshotTerm) {
|
|
const flushed = await flushPendingTerminalWritesBeforeHibernate(snapshotTerm);
|
|
if (!flushed) {
|
|
throw new Error("Terminal output did not settle before closing the attached display");
|
|
}
|
|
}
|
|
// Push popup display state home first so reopen is not stale.
|
|
let finalContext = {
|
|
contextSnapshot: "",
|
|
contextViewportSnapshot: "",
|
|
contextScrollbackSnapshot: "",
|
|
alternateScreen: snapshotTerm ? isTerminalAlternateScreenActive(snapshotTerm) : false,
|
|
};
|
|
try {
|
|
if (snapshotTerm) finalContext = readTerminalHibernateContext(snapshotTerm);
|
|
} catch (err) {
|
|
logger.warn("Failed to read terminal context for attach popup", err);
|
|
}
|
|
let serializedSnapshot: unknown;
|
|
try {
|
|
await xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization();
|
|
serializedSnapshot = serializeAddonRef.current?.serialize?.();
|
|
} catch (err) {
|
|
logger.warn("Failed to serialize terminal snapshot for attach popup", err);
|
|
}
|
|
const capture = resolveTerminalSnapshotCapture(serializedSnapshot, finalContext);
|
|
const snap = capture.snapshot;
|
|
finalContext = capture.context;
|
|
const applied = await terminalBackend.applySessionSnapshot?.(
|
|
closingSessionId,
|
|
snap,
|
|
{
|
|
...finalContext,
|
|
kittyKeyboardModeState: xtermRuntimeRef.current?.getKittyKeyboardModeState(),
|
|
kittyKeyboardProtocolEnabled:
|
|
xtermRuntimeRef.current?.getKittyKeyboardProtocolEnabled(),
|
|
passwordPromptActive: passwordPromptActiveRef.current,
|
|
cwd: knownCwdRef.current ?? null,
|
|
title: terminalTitleRef.current ?? null,
|
|
},
|
|
attachAuthorization || "",
|
|
);
|
|
if (applied && !applied.success) throw new Error(applied.error || "Failed to apply terminal snapshot");
|
|
// Hand the route home while output is still paused, then detach the
|
|
// popup listener and resume so subsequent bytes land on the home route.
|
|
const restored = await terminalBackend.restoreSessionOutput?.(
|
|
closingSessionId,
|
|
homeId ?? null,
|
|
attachAuthorization || "",
|
|
);
|
|
if (restored && !restored.success) throw new Error(restored.error || "Failed to restore terminal output");
|
|
disposeSessionListeners();
|
|
const activeTerm = termRef.current;
|
|
if (activeTerm) {
|
|
releaseTerminalFlowBeforeHibernate(terminalBackend, activeTerm, closingSessionId, {
|
|
resumeBackend: true,
|
|
});
|
|
} else {
|
|
flushTerminalSessionFlowAck(closingSessionId);
|
|
clearTerminalSessionFlowAck(closingSessionId);
|
|
terminalBackend.setSessionFlowPaused?.(closingSessionId, false);
|
|
}
|
|
outputPauseLease.release();
|
|
} catch (err) {
|
|
outputPauseLease.release({ keepPaused: true });
|
|
logger.warn("Failed to restore terminal output after attach popup close", err);
|
|
disposeSessionListeners();
|
|
throw err;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const cleanupPromise = (async () => {
|
|
const activeTerm = termRef.current;
|
|
if (activeTerm) {
|
|
releaseTerminalFlowBeforeHibernate(terminalBackend, activeTerm, closingSessionId, {
|
|
resumeBackend: false,
|
|
});
|
|
} else {
|
|
flushTerminalSessionFlowAck(closingSessionId);
|
|
clearTerminalSessionFlowAck(closingSessionId);
|
|
}
|
|
try {
|
|
await terminalBackend.closeSession(closingSessionId, {
|
|
bootEpoch: resolveCloseBootEpoch(),
|
|
...(options?.retainOwnership === true ? { retainOwnership: true } : {}),
|
|
});
|
|
} catch (err) {
|
|
logger.warn("Failed to close SSH session", err);
|
|
}
|
|
})();
|
|
|
|
sessionCleanupPromiseRef.current = cleanupPromise;
|
|
try {
|
|
await cleanupPromise;
|
|
} finally {
|
|
if (sessionCleanupPromiseRef.current === cleanupPromise) {
|
|
sessionCleanupPromiseRef.current = null;
|
|
}
|
|
}
|
|
};
|
|
|
|
const cleanupSessionRef = useRef(cleanupSession);
|
|
cleanupSessionRef.current = cleanupSession;
|
|
useEffect(() => {
|
|
if (!attachExistingSession || !onAttachClosePreparationChange) return undefined;
|
|
const prepare = () => cleanupSessionRef.current();
|
|
onAttachClosePreparationChange(prepare);
|
|
return () => onAttachClosePreparationChange(null);
|
|
}, [attachExistingSession, onAttachClosePreparationChange]);
|
|
|
|
const disposeRuntimeOnly = () => {
|
|
xtermRuntimeRef.current?.dispose();
|
|
xtermRuntimeRef.current = null;
|
|
termRef.current = null;
|
|
fitAddonRef.current = null;
|
|
serializeAddonRef.current = null;
|
|
searchAddonRef.current = null;
|
|
hasRuntimeRef.current = false;
|
|
};
|
|
|
|
const clearHibernateRuntimeState = useCallback(() => {
|
|
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);
|
|
}, [sessionId]);
|
|
|
|
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);
|
|
if (attachExistingSession) {
|
|
const homeId = attachHomeWebContentsIdRef.current;
|
|
attachHomeWebContentsIdRef.current = undefined;
|
|
terminalBackend.setSessionFlowPaused?.(closingSessionId, true);
|
|
void terminalBackend.restoreSessionOutput?.(
|
|
closingSessionId,
|
|
homeId ?? null,
|
|
attachAuthorization || "",
|
|
).then((result) => {
|
|
if (!result?.success) throw new Error(result?.error || "Failed to restore terminal output");
|
|
flushTerminalSessionFlowAck(closingSessionId);
|
|
clearTerminalSessionFlowAck(closingSessionId);
|
|
terminalBackend.setSessionFlowPaused?.(closingSessionId, false);
|
|
}).catch((err) => {
|
|
logger.warn("Failed to restore terminal output after attach popup hibernate close", err);
|
|
});
|
|
} else {
|
|
try {
|
|
const closeResult = terminalBackend.closeSession(closingSessionId);
|
|
void Promise.resolve(closeResult).catch((err) => {
|
|
logger.warn("Failed to close hibernated session", err);
|
|
});
|
|
} catch (err) {
|
|
logger.warn("Failed to close hibernated session", err);
|
|
}
|
|
}
|
|
}
|
|
sessionRef.current = null;
|
|
clearHibernateRuntimeState();
|
|
}, [attachAuthorization, attachExistingSession, clearHibernateRuntimeState, handleTerminalDataCaptureOnce, sessionId, terminalBackend]);
|
|
|
|
const observeTerminalInputPrompt = useCallback((
|
|
chunk: string,
|
|
meta?: TerminalSessionDataMeta,
|
|
) => {
|
|
sensitivePromptOutputTailRef.current = appendTerminalPromptSecurityTail(
|
|
sensitivePromptOutputTailRef.current,
|
|
chunk,
|
|
);
|
|
const promptSecurityOptions = { allowHostStyleGreaterThan: isNetworkDevice };
|
|
if (typeof meta?.pluginPipelineSensitiveInput === "boolean") {
|
|
passwordPromptActiveRef.current = meta.pluginPipelineSensitiveInput;
|
|
if (meta.pluginPipelineSensitiveInput) {
|
|
autocompleteCloseRef.current?.();
|
|
} else {
|
|
sensitivePromptOutputTailRef.current = "";
|
|
}
|
|
return;
|
|
} else if (isUntrustedTerminalInputPrompt(
|
|
sensitivePromptOutputTailRef.current,
|
|
promptSecurityOptions,
|
|
)) {
|
|
passwordPromptActiveRef.current = true;
|
|
autocompleteCloseRef.current?.();
|
|
} else if (isConfirmedTerminalShellPrompt(
|
|
sensitivePromptOutputTailRef.current,
|
|
promptSecurityOptions,
|
|
)) {
|
|
passwordPromptActiveRef.current = false;
|
|
}
|
|
}, [isNetworkDevice]);
|
|
|
|
const beginHibernatedSessionListeners = useCallback((backendId: string) => {
|
|
disposeDataRef.current?.();
|
|
flushTerminalSessionFlowAck(backendId);
|
|
terminalBackend.setSessionFlowPaused?.(backendId, false);
|
|
hibernatePendingBufferRef.current = "";
|
|
oscNotificationScannerRef.current = new OscNotificationStreamScanner();
|
|
disposeDataRef.current = terminalBackend.onSessionData(
|
|
backendId,
|
|
(chunk, meta) => {
|
|
observeTerminalInputPrompt(chunk, meta);
|
|
const scanned = oscNotificationScannerRef.current.consume(chunk);
|
|
for (const notification of scanned.notifications) {
|
|
handleTerminalOscNotification({
|
|
notification,
|
|
mode: terminalSettingsRef.current?.oscNotifications,
|
|
sessionFocused: isFocusedRef.current,
|
|
sessionId,
|
|
fallbackTitle: host.label || host.hostname || "Netcatty",
|
|
onSessionActivity: () => onTerminalBell?.(sessionId),
|
|
});
|
|
}
|
|
hibernatePendingBufferRef.current = hibernatePendingCapDisabledRef.current
|
|
? hibernatePendingBufferRef.current + scanned.remainder
|
|
: appendHibernatePendingBuffer(
|
|
hibernatePendingBufferRef.current,
|
|
scanned.remainder,
|
|
);
|
|
const pluginPipelineIngressBytes = Number.isFinite(meta?.pluginPipelineIngressBytes)
|
|
? Math.max(0, Number(meta.pluginPipelineIngressBytes))
|
|
: chunk.length;
|
|
ackTerminalSessionFlow(terminalBackend, backendId, pluginPipelineIngressBytes);
|
|
},
|
|
{ replayBacklog: true },
|
|
);
|
|
|
|
disposeExitRef.current?.();
|
|
disposeExitRef.current = terminalBackend.onSessionExit(backendId, (evt) => {
|
|
disposeTelnetEchoModeRef.current?.();
|
|
disposeTelnetEchoModeRef.current = null;
|
|
telnetLocalEchoRef.current = false;
|
|
updateStatusRef.current("disconnected");
|
|
pluginTerminalSessionExitRef.current(evt.exitCode);
|
|
if (evt.error) {
|
|
setError(evt.error);
|
|
}
|
|
const exitMessage = `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
|
|
hibernatePendingBufferRef.current = hibernatePendingCapDisabledRef.current
|
|
? hibernatePendingBufferRef.current + exitMessage
|
|
: appendHibernatePendingBuffer(
|
|
hibernatePendingBufferRef.current,
|
|
exitMessage,
|
|
);
|
|
onSessionExitRef.current?.(sessionId, evt);
|
|
scheduleAutoReconnect({ evt });
|
|
});
|
|
}, [host.hostname, host.label, observeTerminalInputPrompt, onTerminalBell, scheduleAutoReconnect, sessionId, terminalBackend]);
|
|
|
|
const clearHibernateRetry = useCallback(() => {
|
|
if (hibernateRetryTimerRef.current === null) return;
|
|
clearTimeout(hibernateRetryTimerRef.current);
|
|
hibernateRetryTimerRef.current = null;
|
|
}, []);
|
|
|
|
const scheduleHibernateRetry = useCallback(() => {
|
|
if (hibernateRetryTimerRef.current !== null) return;
|
|
hibernateRetryTimerRef.current = setTimeout(() => {
|
|
hibernateRetryTimerRef.current = null;
|
|
if (
|
|
isVisibleRef.current
|
|
|| hibernatedRef.current
|
|
|| softHiddenRef.current
|
|
|| !hasRuntimeRef.current
|
|
|| !canHibernateTerminalRuntimeStatus(statusRef.current)
|
|
|| isSearchOpenRef.current
|
|
|| hibernateFileTransferActiveRef.current
|
|
|| !hibernateEnabledRef.current
|
|
) {
|
|
return;
|
|
}
|
|
void fullHibernateRuntimeRef.current?.();
|
|
}, HIBERNATE_RETRY_AFTER_DRAIN_MS);
|
|
}, []);
|
|
|
|
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 shouldSkipHibernateForActiveAlternateScreen = useCallback((term: XTerm): boolean => {
|
|
if (statusRef.current !== "connected") return false;
|
|
if (
|
|
!isTerminalAlternateScreenActive(term)
|
|
|| !resolveHibernateSkipAltScreen(terminalSettings)
|
|
) {
|
|
return false;
|
|
}
|
|
logger.info("[Terminal] Skipping hibernate: alternate screen active", { sessionId });
|
|
return true;
|
|
}, [sessionId, terminalSettings]);
|
|
|
|
// Hibernate rebuilds a tab from a serialized *text* snapshot. Inline images
|
|
// (Kitty graphics / SIXEL / iTerm IIP) live in the ImageAddon's own storage and
|
|
// are not part of that snapshot, so a session holding images degrades to
|
|
// soft-hide (WebGL suspended, xterm and its image layers kept alive) instead of
|
|
// a full renderer release. The check reads the live image cache, so it stops
|
|
// blocking once the cache is emptied by a terminal reset or FIFO eviction.
|
|
const runtimeHasInlineImages = useCallback(
|
|
() => xtermRuntimeRef.current?.hasInlineImages() === true,
|
|
[],
|
|
);
|
|
|
|
const fullHibernateRuntime = useCallback(async (): Promise<boolean> => {
|
|
if (
|
|
hibernatedRef.current
|
|
|| (softHiddenRef.current && statusRef.current !== "disconnected")
|
|
|| !termRef.current
|
|
|| !serializeAddonRef.current
|
|
) return false;
|
|
clearHibernateRetry();
|
|
if (reconnectPreparationTokenRef.current !== null) return false;
|
|
const hibernateStatus = statusRef.current;
|
|
const backendId = sessionRef.current;
|
|
if (!canHibernateTerminalRuntimeSession(hibernateStatus, backendId)) return false;
|
|
const term = termRef.current;
|
|
const serializeAddon = serializeAddonRef.current;
|
|
const canFinishHibernate = () => (
|
|
!isVisibleRef.current
|
|
&& !hibernatedRef.current
|
|
&& (!softHiddenRef.current || hibernateStatus === "disconnected")
|
|
&& hasRuntimeRef.current
|
|
&& canHibernateTerminalRuntimeSession(hibernateStatus, backendId)
|
|
&& !isSearchOpenRef.current
|
|
&& !hibernateFileTransferActiveRef.current
|
|
&& !runtimeHasInlineImages()
|
|
&& hibernateEnabledRef.current
|
|
&& termRef.current === term
|
|
&& statusRef.current === hibernateStatus
|
|
&& sessionRef.current === backendId
|
|
&& serializeAddonRef.current === serializeAddon
|
|
&& reconnectPreparationTokenRef.current === null
|
|
);
|
|
|
|
if (!canFinishHibernate()) return false;
|
|
|
|
terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
|
softHiddenRef.current = false;
|
|
const flushedBeforeHibernate = await flushPendingTerminalWritesBeforeHibernate(term);
|
|
if (!flushedBeforeHibernate) {
|
|
logger.info("[Terminal] Skipping hibernate: terminal output is still draining", { sessionId });
|
|
scheduleHibernateRetry();
|
|
return false;
|
|
}
|
|
if (!canFinishHibernate()) return false;
|
|
if (shouldSkipHibernateForActiveAlternateScreen(term)) {
|
|
return false;
|
|
}
|
|
|
|
const snapshot = await serializeTerminalForHibernate(
|
|
term,
|
|
serializeAddon,
|
|
{
|
|
preferWasm: resolveHibernatePreferWasmSerialize(terminalSettingsRef.current),
|
|
prepare: () => xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization()
|
|
?? Promise.resolve(),
|
|
},
|
|
);
|
|
|
|
if (!canFinishHibernate()) return false;
|
|
|
|
if (snapshot.alternateScreen && snapshot.snapshot.length === 0) {
|
|
logger.info("[Terminal] Skipping hibernate: alternate screen snapshot unavailable", { sessionId });
|
|
return false;
|
|
}
|
|
|
|
applyHibernateSnapshot(snapshot);
|
|
isBootActiveRef.current = false;
|
|
const connectedBackendId = hibernateStatus === "connected" ? backendId : null;
|
|
if (connectedBackendId) {
|
|
releaseTerminalFlowBeforeHibernate(terminalBackend, term, connectedBackendId);
|
|
}
|
|
disposeDataRef.current?.();
|
|
disposeDataRef.current = null;
|
|
disposeExitRef.current?.();
|
|
disposeExitRef.current = null;
|
|
disposeRuntimeOnly();
|
|
if (connectedBackendId) {
|
|
beginHibernatedSessionListeners(connectedBackendId);
|
|
}
|
|
hibernatedRef.current = true;
|
|
// Hibernation rebuilds the autofill controller on wake; drop any open
|
|
// picker so it cannot stay visible against a non-pending controller.
|
|
setPasswordPickerState(null);
|
|
logger.info("[Terminal] Hibernated runtime", {
|
|
sessionId,
|
|
snapshotChars: hibernateSnapshotRef.current.length,
|
|
viewportChars: hibernateViewportSnapshotRef.current.length,
|
|
scrollbackChars: hibernateScrollbackSnapshotRef.current.length,
|
|
alternateScreen: snapshot.alternateScreen,
|
|
});
|
|
return true;
|
|
}, [
|
|
applyHibernateSnapshot,
|
|
beginHibernatedSessionListeners,
|
|
clearHibernateRetry,
|
|
scheduleHibernateRetry,
|
|
runtimeHasInlineImages,
|
|
sessionId,
|
|
shouldSkipHibernateForActiveAlternateScreen,
|
|
terminalBackend,
|
|
]);
|
|
fullHibernateRuntimeRef.current = fullHibernateRuntime;
|
|
|
|
const upgradeSoftHiddenRuntimeToHibernate = useCallback(() => {
|
|
if (!wakeSoftHiddenRuntimeRef.current) return;
|
|
wakeSoftHiddenRuntimeRef.current?.();
|
|
void fullHibernateRuntime().then(
|
|
(completed) => {
|
|
if (!completed) resumeRendererAfterCancelledHibernateUpgradeRef.current?.();
|
|
},
|
|
(error) => {
|
|
logger.error("[Terminal] Failed to upgrade soft-hidden runtime to hibernate", { sessionId, error });
|
|
resumeRendererAfterCancelledHibernateUpgradeRef.current?.();
|
|
},
|
|
);
|
|
}, [fullHibernateRuntime, sessionId]);
|
|
|
|
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 || !termRef.current) return;
|
|
|
|
if (shouldSkipHibernateForActiveAlternateScreen(termRef.current)) {
|
|
return;
|
|
}
|
|
|
|
// Images cannot be restored from the text snapshot: keep the renderer alive
|
|
// and only suspend WebGL, and never evict another tab on this session's behalf.
|
|
if (runtimeHasInlineImages()) {
|
|
hideRuntimeOnly();
|
|
return;
|
|
}
|
|
|
|
if (softHiddenRef.current) {
|
|
if (statusRef.current === "disconnected") {
|
|
upgradeSoftHiddenRuntimeToHibernate();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const keepCount = statusRef.current === "connected"
|
|
? resolveHibernateKeepRendererCount(terminalSettings)
|
|
: 0;
|
|
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,
|
|
runtimeHasInlineImages,
|
|
sessionId,
|
|
shouldSkipHibernateForActiveAlternateScreen,
|
|
terminalSettings,
|
|
upgradeSoftHiddenRuntimeToHibernate,
|
|
]);
|
|
|
|
const terminalRuntimeRefs = useMemo<TerminalRuntimeRefs>(() => ({
|
|
xtermRuntimeRef,
|
|
termRef,
|
|
fitAddonRef,
|
|
serializeAddonRef,
|
|
prepareKeywordHighlightSerialization: () => xtermRuntimeRef.current
|
|
?.keywordHighlighter.prepareForSerialization() ?? Promise.resolve(),
|
|
searchAddonRef,
|
|
hasRuntimeRef,
|
|
}), []);
|
|
|
|
const xTermRuntimeContextRef = useRef<Omit<CreateXTermRuntimeContext, "container" | "initiallyVisible"> | null>(null);
|
|
const pluginDecorationRulesRef = useRef<readonly PluginTerminalDecorationRule[]>(Object.freeze([]));
|
|
const pluginDecorationRefreshRef = useRef<(reason: string) => void>(() => {});
|
|
|
|
useEffect(() => () => {
|
|
clearKittyKeyboardBroadcastSession(sessionId);
|
|
clearTerminalBootEpoch(sessionId);
|
|
}, [sessionId]);
|
|
|
|
const teardown = () => {
|
|
// Capture the live epoch before invalidating so closeSession still matches
|
|
// the registered backend session / pending passphrase boot.
|
|
invalidateBootEpochForClose();
|
|
clearTerminalBootEpoch(sessionId);
|
|
isBootActiveRef.current = false;
|
|
retryTokenRef.current = null;
|
|
reconnectPreparationTokenRef.current = null;
|
|
restoreCwdIntentRef.current = null;
|
|
suppressHostStartupCommandRef.current = false;
|
|
clearHibernateRetry();
|
|
clearAutoReconnect();
|
|
void cleanupSession();
|
|
synchronizedFitSchedulerRef.current?.dispose();
|
|
disposeRuntimeOnly();
|
|
};
|
|
|
|
const pluginTerminalLifecycle = usePluginTerminalSessionLifecycle({
|
|
sessionId,
|
|
hostId: host.id,
|
|
workspaceId,
|
|
protocol: effectiveTerminalProtocol,
|
|
status,
|
|
shellType,
|
|
initialCwd: knownCwdRef.current ?? lastCwd,
|
|
ownsBackendLifecycle: shouldPublishPluginTerminalSessionMountLifecycle(attachExistingSession),
|
|
});
|
|
pluginTerminalLifecycleRef.current = pluginTerminalLifecycle;
|
|
pluginTerminalSessionExitRef.current = pluginTerminalLifecycle.onSessionExited;
|
|
const getPluginTerminalSnapshotState = useCallback((): Partial<NetcattyTerminalSessionSnapshot> => {
|
|
const term = termRef.current;
|
|
return {
|
|
status: statusRef.current,
|
|
...(knownCwdRef.current ? { cwd: knownCwdRef.current } : {}),
|
|
...(term ? { cols: term.cols, rows: term.rows } : {}),
|
|
alternateScreen: term?.buffer.active.type === 'alternate',
|
|
};
|
|
}, []);
|
|
const pluginTerminalProviders = usePluginTerminalProviders({
|
|
sessionId,
|
|
hostId: host.id,
|
|
workspaceId,
|
|
protocol: effectiveTerminalProtocol,
|
|
status,
|
|
shellType,
|
|
baseTheme: baseEffectiveTheme,
|
|
getSnapshotState: getPluginTerminalSnapshotState,
|
|
});
|
|
const {
|
|
decorationRules: pluginDecorationRules,
|
|
hasProvider: isPluginTerminalProviderAvailable,
|
|
providerRevision: pluginTerminalProviderRevision,
|
|
refreshProviderOutputs,
|
|
request: requestPluginTerminalProviders,
|
|
resolvedTheme: effectiveTheme,
|
|
} = pluginTerminalProviders;
|
|
pluginDecorationRefreshRef.current = (reason: string) => { void refreshProviderOutputs(reason); };
|
|
const pluginAwareOnCommandSubmitted = useCallback((
|
|
command: string,
|
|
) => {
|
|
markTerminalCommandCompletionPending(promptLineBreakStateRef);
|
|
pluginTerminalLifecycle.onCommandSubmitted();
|
|
void xtermRuntimeRef.current?.pluginProviderHost?.commandSubmitted(command);
|
|
}, [pluginTerminalLifecycle]);
|
|
const cwdAwareOnCommandSubmitted = useCallback((
|
|
...args: Parameters<NonNullable<typeof onCommandSubmitted>>
|
|
) => {
|
|
invalidateTerminalCwdAfterCommand(
|
|
terminalCwdTracker,
|
|
sessionId,
|
|
() => { knownCwdRef.current = undefined; },
|
|
onTerminalCwdChange,
|
|
);
|
|
onCommandSubmitted?.(...args);
|
|
}, [onCommandSubmitted, onTerminalCwdChange, sessionId, terminalCwdTracker]);
|
|
const pluginAwareOnCommandCompleted = useCallback(() => {
|
|
pluginTerminalLifecycle.onCommandCompleted();
|
|
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
|
|
}, [pluginTerminalLifecycle]);
|
|
const pluginAwareOnTerminalCwdChange = useCallback((
|
|
changedSessionId: string,
|
|
cwd: string | null,
|
|
meta?: TerminalCwdChangeMeta,
|
|
) => {
|
|
pluginTerminalLifecycle.onCwdChanged(cwd);
|
|
onTerminalCwdChange?.(changedSessionId, cwd, meta);
|
|
}, [onTerminalCwdChange, pluginTerminalLifecycle]);
|
|
const pluginAwareOnRuntimeCwdChange = useCallback((
|
|
cwd: string,
|
|
meta?: TerminalCwdChangeMeta,
|
|
) => {
|
|
const normalizedCwd = terminalCwdTracker.setRendererCwd(
|
|
cwd,
|
|
meta?.source ?? "backend",
|
|
);
|
|
knownCwdRef.current = normalizedCwd;
|
|
pluginAwareOnTerminalCwdChange(sessionId, normalizedCwd ?? null, meta);
|
|
void refreshProviderOutputs('cwd-changed');
|
|
}, [pluginAwareOnTerminalCwdChange, refreshProviderOutputs, sessionId, terminalCwdTracker]);
|
|
const pluginAwareOnTerminalTitleChange = useCallback((changedSessionId: string, title: string | null) => {
|
|
terminalTitleRef.current = title || undefined;
|
|
pluginTerminalLifecycle.onTitleChanged(title);
|
|
onTerminalTitleChange?.(changedSessionId, title);
|
|
}, [onTerminalTitleChange, pluginTerminalLifecycle]);
|
|
|
|
const sessionStarters = createTerminalSessionStarters({
|
|
host,
|
|
hostRef,
|
|
keys,
|
|
identities,
|
|
knownHosts,
|
|
resolvedChainHosts,
|
|
sessionId,
|
|
reuseConnectionFromSessionIdRef: reuseConnectionSourceRef,
|
|
requireFreshConnection,
|
|
requireFreshConnectionOnReconnectRef,
|
|
reuseConnectionSourceAttemptedRef,
|
|
setConnectionReuseAttemptSourceId,
|
|
shouldUseFreshSshConnection: () => {
|
|
const currentPendingScript = pendingScriptRef.current;
|
|
const currentPendingScriptId = pendingScriptIdRef.current;
|
|
const hasUnhandledPendingScript = currentPendingScript && isScriptSnippet(currentPendingScript)
|
|
? !isPendingScriptAlreadyHandled(currentPendingScript)
|
|
: Boolean(
|
|
currentPendingScriptId
|
|
&& pendingScriptRunIdRef.current !== currentPendingScriptId,
|
|
);
|
|
return shouldUseFreshSshConnectionForAutomation({
|
|
host: hostRef.current,
|
|
snippets: snippetsRef.current,
|
|
vaultInitialized: isVaultInitialized(),
|
|
hasPendingScript: hasUnhandledPendingScript,
|
|
connectAutomationConsumed: connectScriptsConsumedRef.current,
|
|
});
|
|
},
|
|
onConnectAutomationSnapshotCommitted: () => {
|
|
connectScriptsConsumedRef.current = true;
|
|
},
|
|
isNetworkDevice,
|
|
startupCommand,
|
|
noAutoRun,
|
|
multiLineRunMode,
|
|
shellType,
|
|
suppressHostStartupCommandRef,
|
|
terminalSettings,
|
|
terminalSettingsRef,
|
|
terminalBackend,
|
|
serialConfig,
|
|
telnetLocalEchoRef,
|
|
isPaneVisibleRef: isVisibleRef,
|
|
isVisibleRef: isRendererActiveRef,
|
|
isBootActiveRef,
|
|
bootEpochRef,
|
|
pendingOutputScrollRef,
|
|
sessionRef,
|
|
hasConnectedRef,
|
|
hasRunStartupCommandRef,
|
|
restoreCwdIntentRef,
|
|
disposeDataRef,
|
|
disposeExitRef,
|
|
trackSessionCleanup,
|
|
disposeTelnetEchoModeRef,
|
|
fitAddonRef,
|
|
serializeAddonRef,
|
|
pendingAuthRef,
|
|
promptLineBreakStateRef,
|
|
sudoAutofillRef,
|
|
onSudoHint: (active: boolean) => sudoHintRef.current?.(active) ?? false,
|
|
onPasswordPromptPicker: (active, state) => passwordPickerRef.current?.(active, state) ?? false,
|
|
sudoAutofillCandidates: resolvedSudoAutofillCandidates,
|
|
sudoAutofillCandidatesRef,
|
|
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 isPlugin = isPluginHostProtocol(host.protocol);
|
|
const isSSH = !isLocal && !isSerial && !isTelnet && !isMosh && !isEt && !isPlugin;
|
|
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;
|
|
// The inherited `cd` was actually sent — now mark it consumed so retries
|
|
// don't re-arm it (see prepareInitialCwdIntent).
|
|
initialCwdConsumedRef.current = true;
|
|
// Report the new cwd to app state so `pendingInitialCwd` is cleared even
|
|
// for shells that never emit OSC 7 (where the post-connect fallback probe
|
|
// is skipped because knownCwdRef is now set). Without this the transient
|
|
// seed would survive and re-inject a stale dir on a later remount.
|
|
if (pendingInitialCwd) {
|
|
onTerminalCwdChange?.(sessionId, cwd);
|
|
}
|
|
},
|
|
onSessionExit: (closedSessionId, evt) => {
|
|
clearTerminalCwd();
|
|
pluginTerminalLifecycle.onSessionExited(evt.exitCode);
|
|
onSessionExitRef.current?.(closedSessionId, evt);
|
|
scheduleAutoReconnect({ evt });
|
|
},
|
|
onTerminalDataCapture: handleTerminalDataCaptureOnce,
|
|
onTerminalOutput: (chunk: string, meta?: TerminalSessionDataMeta) => {
|
|
observeTerminalInputPrompt(chunk, meta);
|
|
appendOutputTriggerOutputRef.current(chunk, meta);
|
|
if (onTerminalOutput) {
|
|
onTerminalOutput(sessionId, chunk);
|
|
}
|
|
},
|
|
onTerminalLogData: captureTerminalLogData,
|
|
onProgrammaticCommandLogRewrite: queueProgrammaticCommandLogRewrite,
|
|
onOsDetected,
|
|
onCommandExecuted,
|
|
onCommandSubmitted: cwdAwareOnCommandSubmitted,
|
|
onCommandCompleted: pluginAwareOnCommandCompleted,
|
|
sessionLog,
|
|
sshDebugLogEnabled,
|
|
sudoAutofillPassword: resolvedSudoAutofillPassword,
|
|
sudoAutofillPasswordRef,
|
|
});
|
|
sessionStartersRef.current = sessionStarters;
|
|
|
|
useEffect(() => {
|
|
if (status === 'disconnected' && !autoReconnectLoopActiveRef.current) {
|
|
connectScriptsConsumedRef.current = false;
|
|
connectScriptsCompletedIdsRef.current = new Set();
|
|
}
|
|
if (status === 'disconnected' && effectiveTerminalProtocol === 'mosh') {
|
|
setMoshShellReady(false);
|
|
}
|
|
}, [effectiveTerminalProtocol, status]);
|
|
|
|
// Synchronously (re)register the mosh ready listener. Must run before
|
|
// startMosh on retry: cleanupSession/closeSession wipes preload listeners,
|
|
// and a useEffect-only resubscribe can race a fast passwordless handshake.
|
|
const prepareMoshReadySubscription = useCallback(() => {
|
|
disposeMoshReadyRef.current?.();
|
|
disposeMoshReadyRef.current = null;
|
|
if (effectiveTerminalProtocol !== 'mosh') {
|
|
setMoshShellReady(true);
|
|
return;
|
|
}
|
|
if (!terminalBackend.onMoshSessionReady) {
|
|
// Older bridges without the ready event must not block scripts forever.
|
|
setMoshShellReady(true);
|
|
return;
|
|
}
|
|
setMoshShellReady(false);
|
|
disposeMoshReadyRef.current = terminalBackend.onMoshSessionReady(sessionId, (evt) => {
|
|
const currentEpoch = bootEpochRef.current;
|
|
if (
|
|
Number.isFinite(currentEpoch)
|
|
&& Number.isFinite(evt?.bootEpoch)
|
|
&& evt.bootEpoch !== currentEpoch
|
|
) {
|
|
return;
|
|
}
|
|
setMoshShellReady(true);
|
|
}) ?? null;
|
|
}, [effectiveTerminalProtocol, sessionId, terminalBackend]);
|
|
|
|
useEffect(() => {
|
|
prepareMoshReadySubscription();
|
|
return () => {
|
|
disposeMoshReadyRef.current?.();
|
|
disposeMoshReadyRef.current = null;
|
|
};
|
|
}, [prepareMoshReadySubscription]);
|
|
|
|
useEffect(() => {
|
|
if (status !== "disconnected") return;
|
|
scheduleAutoReconnect();
|
|
}, [scheduleAutoReconnect, status]);
|
|
|
|
useEffect(() => {
|
|
if (!autoReconnectLoopActiveRef.current) return;
|
|
if (shouldContinueAutoReconnectAfterFailure({ host, terminalSettings, loopActive: true })) return;
|
|
const hadPendingReconnect = autoReconnectTimerRef.current !== null;
|
|
clearAutoReconnect();
|
|
if (hadPendingReconnect) {
|
|
updateStatus("disconnected");
|
|
}
|
|
}, [clearAutoReconnect, host, terminalSettings, updateStatus]);
|
|
|
|
useEffect(() => {
|
|
pendingScriptRunIdRef.current = null;
|
|
pendingScriptHandledRef.current = null;
|
|
}, [pendingScript?.id, pendingScriptId]);
|
|
|
|
useEffect(() => {
|
|
if (status !== 'connected') return;
|
|
if (effectiveTerminalProtocol === 'mosh' && !moshShellReady) 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 resolvedConnectScripts = resolveConnectScriptsForHost(host, snippets);
|
|
// An empty hydrated vault is a final decision for this connection. Lock it
|
|
// before the output-settling delay so a sync arriving during that window
|
|
// cannot inject a new connect script into an already-reused SSH session.
|
|
if (
|
|
!connectScriptsConsumedRef.current
|
|
&& resolvedConnectScripts.length === 0
|
|
&& shouldMarkConnectAutomationConsumed({
|
|
allConnectScriptsDone: true,
|
|
vaultInitialized: isVaultInitialized(),
|
|
hasUnresolvedBindings: hasUnresolvedConnectScriptBindings(host, snippets),
|
|
})
|
|
) {
|
|
connectScriptsConsumedRef.current = true;
|
|
}
|
|
|
|
const shouldEvaluateConnect = !connectScriptsConsumedRef.current;
|
|
const hasPendingWork = Boolean(pendingOne);
|
|
if (!shouldEvaluateConnect && !hasPendingWork) return;
|
|
if (connectScriptsInFlightRef.current) return;
|
|
|
|
const batch = createConnectAutomationBatch();
|
|
connectScriptsBatchRef.current = batch;
|
|
connectScriptsInFlightRef.current = true;
|
|
const batchStillActive = () => (
|
|
!batch.controller.signal.aborted && connectScriptsBatchRef.current === batch
|
|
);
|
|
|
|
// Defer until xterm has rendered login output and the main-process output tap
|
|
// has populated SessionOutputBuffer (avoids waitForPrompt racing an empty buffer).
|
|
let timerFired = false;
|
|
const timer = window.setTimeout(() => {
|
|
timerFired = true;
|
|
if (!batchStillActive()) return;
|
|
const runPending = Boolean(pendingOne);
|
|
const connectQueueNow = connectScriptsConsumedRef.current
|
|
? []
|
|
: resolvedConnectScripts.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 allConnectScriptsDone = resolvedConnectScripts.length === 0
|
|
|| resolvedConnectScripts.every(
|
|
(item) => item.id && connectScriptsCompletedIdsRef.current.has(item.id),
|
|
);
|
|
|
|
if (scriptsToRun.length === 0) {
|
|
if (!connectScriptsConsumedRef.current && shouldMarkConnectAutomationConsumed({
|
|
allConnectScriptsDone,
|
|
vaultInitialized: isVaultInitialized(),
|
|
hasUnresolvedBindings: hasUnresolvedConnectScriptBindings(host, snippets),
|
|
})) {
|
|
connectScriptsConsumedRef.current = true;
|
|
}
|
|
if (connectScriptsBatchRef.current === batch) {
|
|
connectScriptsBatchRef.current = null;
|
|
connectScriptsInFlightRef.current = false;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const pendingScriptToMark = runPending ? pendingOne : undefined;
|
|
const connectIdsInBatch = new Set(
|
|
connectQueueNow.map((item) => item.id).filter((id): id is string => Boolean(id)),
|
|
);
|
|
|
|
void runConnectScriptsSequential({
|
|
scripts: scriptsToRun,
|
|
sessionId,
|
|
signal: batch.controller.signal,
|
|
onCancelableRunChange: (stopCurrentRun) => (
|
|
trackConnectAutomationStop(batch, stopCurrentRun)
|
|
),
|
|
sessionMeta: {
|
|
connected: true,
|
|
name: scriptSessionName,
|
|
hostname: host.hostname,
|
|
username: host.username,
|
|
},
|
|
onScriptComplete: (snippet) => {
|
|
if (!batchStillActive()) return;
|
|
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(() => {
|
|
if (!batchStillActive()) return;
|
|
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) => {
|
|
if (!batchStillActive()) return;
|
|
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,
|
|
signal: batch.controller.signal,
|
|
onCancelableRunChange: (stopCurrentRun) => (
|
|
trackConnectAutomationStop(batch, stopCurrentRun)
|
|
),
|
|
sessionMeta: {
|
|
connected: true,
|
|
name: scriptSessionName,
|
|
hostname: host.hostname,
|
|
username: host.username,
|
|
},
|
|
onScriptComplete: (snippet) => {
|
|
if (!batchStillActive()) return;
|
|
if (snippet.id) {
|
|
pendingScriptRunIdRef.current = snippet.id;
|
|
} else {
|
|
pendingScriptHandledRef.current = snippet;
|
|
}
|
|
},
|
|
});
|
|
} catch (pendingErr) {
|
|
if (!batchStillActive()) return;
|
|
const pendingMessage = pendingErr instanceof Error ? pendingErr.message : String(pendingErr);
|
|
toast.error(pendingMessage.includes('Observer mode') ? t('scripts.observer.blocked') : pendingMessage);
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (
|
|
!batch.controller.signal.aborted
|
|
&& connectScriptsBatchRef.current === batch
|
|
) {
|
|
connectScriptsBatchRef.current = null;
|
|
connectScriptsInFlightRef.current = false;
|
|
}
|
|
});
|
|
}, 400);
|
|
|
|
return () => {
|
|
window.clearTimeout(timer);
|
|
if (!timerFired && connectScriptsBatchRef.current === batch) {
|
|
batch.controller.abort();
|
|
connectScriptsBatchRef.current = null;
|
|
connectScriptsInFlightRef.current = false;
|
|
} else if (statusRef.current !== "connected") {
|
|
void cancelConnectAutomationBatch(batch).catch(() => {});
|
|
}
|
|
};
|
|
}, [effectiveTerminalProtocol, host, isPendingScriptAlreadyHandled, moshShellReady, pendingScript, pendingScriptId, scriptSessionName, sessionId, snippets, status, t]);
|
|
|
|
useEffect(() => {
|
|
return registerScreenSnapshotProvider(sessionId, () => {
|
|
const term = termRef.current;
|
|
if (!term?.buffer?.active) {
|
|
// Hibernated terminals: prefer last captured viewport for script sync.
|
|
const hibernatedViewport =
|
|
hibernateViewportSnapshotRef.current
|
|
|| hibernateSnapshotRef.current
|
|
|| hibernatePendingBufferRef.current;
|
|
if (hibernatedViewport) {
|
|
const lines = hibernatedViewport.split("\n");
|
|
return {
|
|
rows: Math.max(lines.length, 1),
|
|
cols: 80,
|
|
currentRow: Math.max(lines.length - 1, 0),
|
|
lines,
|
|
source: "hibernate-viewport",
|
|
};
|
|
}
|
|
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);
|
|
});
|
|
};
|
|
const limitHandler = (event: Event) => {
|
|
const detail = (event as CustomEvent<{ sessionId?: string; code?: string }>).detail;
|
|
if (detail?.sessionId !== sessionId) return;
|
|
setRecordedCode(detail.code ?? '');
|
|
setSaveRecordingOpen(true);
|
|
};
|
|
window.addEventListener('netcatty:script:recording:start', startHandler);
|
|
window.addEventListener('netcatty:script:recording:stop', stopHandler);
|
|
window.addEventListener(SCRIPT_RECORDING_LIMIT_EVENT, limitHandler);
|
|
return () => {
|
|
window.removeEventListener('netcatty:script:recording:start', startHandler);
|
|
window.removeEventListener('netcatty:script:recording:stop', stopHandler);
|
|
window.removeEventListener(SCRIPT_RECORDING_LIMIT_EVENT, limitHandler);
|
|
};
|
|
}, [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 synchronizedFitSchedulerRef = useRef<ReturnType<typeof createSynchronizedOutputFitScheduler> | null>(null);
|
|
if (!synchronizedFitSchedulerRef.current) {
|
|
synchronizedFitSchedulerRef.current = createSynchronizedOutputFitScheduler();
|
|
}
|
|
useEffect(() => () => synchronizedFitSchedulerRef.current?.dispose(), []);
|
|
|
|
type SafeFitOptions = { force?: boolean; requireVisible?: boolean; immediate?: boolean; allowHidden?: boolean };
|
|
const pendingWriteSafeFitRef = useRef<{
|
|
term: XTerm;
|
|
options: SafeFitOptions;
|
|
} | null>(null);
|
|
|
|
const safeFit = (options?: SafeFitOptions) => {
|
|
const fitAddon = fitAddonRef.current;
|
|
if (!fitAddon) return;
|
|
if (!isRendererActiveRef.current && !options?.allowHidden) {
|
|
lastFittedSizeRef.current = null;
|
|
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;
|
|
|
|
if (hasPendingTerminalWrites(term)) {
|
|
let pending = pendingWriteSafeFitRef.current;
|
|
if (pending?.term === term) {
|
|
pending.options = {
|
|
force: pending.options.force || options?.force,
|
|
requireVisible: pending.options.requireVisible || options?.requireVisible,
|
|
immediate: pending.options.immediate || options?.immediate,
|
|
allowHidden: pending.options.allowHidden || options?.allowHidden,
|
|
};
|
|
return;
|
|
}
|
|
|
|
pending = { term, options: { ...options } };
|
|
pendingWriteSafeFitRef.current = pending;
|
|
const fitRequest = pending;
|
|
const fitSessionId = sessionRef.current;
|
|
void (async () => {
|
|
let ranFit = false;
|
|
const settled = await runWithTerminalOutputPausedAfterWritesSettle(
|
|
term,
|
|
fitSessionId,
|
|
terminalBackend,
|
|
() => {
|
|
if (
|
|
pendingWriteSafeFitRef.current !== fitRequest ||
|
|
termRef.current !== term ||
|
|
sessionRef.current !== fitSessionId
|
|
) return;
|
|
pendingWriteSafeFitRef.current = null;
|
|
ranFit = true;
|
|
safeFit({ ...fitRequest.options, immediate: true });
|
|
},
|
|
() => sessionRef.current === fitSessionId && termRef.current === term,
|
|
);
|
|
if (ranFit) return;
|
|
if (pendingWriteSafeFitRef.current !== fitRequest || termRef.current !== term) return;
|
|
|
|
// A reconnect can reuse the same xterm while replacing the backend
|
|
// session. Retry against the new source instead of resizing it
|
|
// under the old session's pause.
|
|
if (sessionRef.current !== fitSessionId) {
|
|
pendingWriteSafeFitRef.current = null;
|
|
setTimeout(() => safeFit(fitRequest.options), 0);
|
|
return;
|
|
}
|
|
|
|
if (!settled) {
|
|
setTimeout(() => {
|
|
if (pendingWriteSafeFitRef.current !== fitRequest || termRef.current !== term) return;
|
|
pendingWriteSafeFitRef.current = null;
|
|
safeFit(fitRequest.options);
|
|
}, 50);
|
|
}
|
|
})();
|
|
return;
|
|
}
|
|
|
|
// Keep the frozen buffer intact until synchronized output finishes.
|
|
// Re-enter safeFit to use the latest size, buffer and reading position.
|
|
if (synchronizedFitSchedulerRef.current?.defer(term, () => {
|
|
if (termRef.current === term) {
|
|
safeFitRef.current({ ...options, force: true, immediate: true });
|
|
}
|
|
})) 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);
|
|
} else {
|
|
// Pixel-only layout changes (opening the SFTP side panel, compose
|
|
// bar, etc.) shrink the container without changing cols/rows, so
|
|
// term.onResize — which clears the WebGL atlas — never fires.
|
|
// Stale atlas glyphs then paint as black squares (#2013).
|
|
xtermRuntimeRef.current?.clearTextureAtlas();
|
|
forceSyncRenderAfterResize(term);
|
|
}
|
|
|
|
// Reflow changes the buffer row before xterm updates its scroll range.
|
|
// Align first so the relative restore below does not apply that delta twice.
|
|
alignTerminalViewportScroll(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" &&
|
|
!options?.immediate
|
|
) {
|
|
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 normalizeTextOnCopyRef = useRef(terminalSettings?.normalizeTextOnCopy ?? true);
|
|
normalizeTextOnCopyRef.current = terminalSettings?.normalizeTextOnCopy ?? true;
|
|
const autoUploadClipboardImageOnPasteRef = useRef(terminalSettings?.autoUploadClipboardImageOnPaste ?? false);
|
|
autoUploadClipboardImageOnPasteRef.current = terminalSettings?.autoUploadClipboardImageOnPaste ?? false;
|
|
|
|
const scrollToBottomAfterProgrammaticInput = useCallback((data: string) => {
|
|
if (!termRef.current) return;
|
|
scrollTerminalToBottomAfterInputIfEnabled(
|
|
termRef.current,
|
|
terminalSettingsRef.current,
|
|
data,
|
|
);
|
|
}, []);
|
|
|
|
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 (
|
|
!passwordPromptActiveRef.current
|
|
&& sessionRef.current
|
|
&& isBroadcastEnabledRef.current
|
|
&& onBroadcastInputRef.current
|
|
) {
|
|
onBroadcastInputRef.current(data, sessionId);
|
|
return true;
|
|
}
|
|
return false;
|
|
}, [sessionId]);
|
|
|
|
const executeSnippetCommand = useCallback(async (
|
|
command: string,
|
|
noAutoRun?: boolean,
|
|
options?: {
|
|
broadcast?: boolean;
|
|
multiLineRunMode?: Snippet["multiLineRunMode"];
|
|
/** When false, skip term.focus() so multi-tab fan-out does not steal focus. */
|
|
focus?: boolean;
|
|
},
|
|
): Promise<boolean> => {
|
|
// Hidden-tab hibernation clears termRef. Wake via the *connected* path so
|
|
// reattachSession restores output listeners before we write the snippet.
|
|
// (The reconnect helper skips reattach and leaves the tab detached.)
|
|
if ((!termRef.current || !sessionRef.current) && hibernatedRef.current) {
|
|
const wake = wakeHibernatedRuntimeForConnectedRef.current;
|
|
if (wake) {
|
|
try {
|
|
await wake();
|
|
} catch {
|
|
// Fall through; we still report failure if term is unavailable.
|
|
}
|
|
}
|
|
}
|
|
|
|
const term = termRef.current;
|
|
const id = sessionRef.current;
|
|
if (!term || !id) return false;
|
|
|
|
// Multi-tab fan-out uses focus:false + broadcast:false. After wake, buffered
|
|
// output may have activated a password prompt — refuse to inject the snippet.
|
|
if (
|
|
options?.focus === false
|
|
&& options?.broadcast === false
|
|
&& passwordPromptActiveRef.current
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
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.
|
|
const sensitive = passwordPromptActiveRef.current;
|
|
if (!sensitive && options?.broadcast !== false && isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
|
|
onBroadcastInputRef.current(data, sessionId, {
|
|
noAutoRun,
|
|
...(lineDelayMs ? { lineDelayMs } : {}),
|
|
});
|
|
}
|
|
|
|
data = prepareProgrammaticSudoInput(data);
|
|
terminalBackend.writeToSession(id, data, {
|
|
automated: true,
|
|
sensitive,
|
|
...(lineDelayMs ? { lineDelayMs } : {}),
|
|
});
|
|
scrollToBottomAfterProgrammaticInput(data);
|
|
if (options?.focus !== false) {
|
|
term.focus();
|
|
}
|
|
return true;
|
|
}, [prepareProgrammaticSudoInput, scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId]);
|
|
|
|
const executeSnippet = useCallback(async (snippet: Snippet) => {
|
|
if (isScriptSnippet(snippet)) {
|
|
if (!snippetCanRunInTerminal(snippet, host)) {
|
|
toast.error(t('scripts.targets.currentHostMismatch'));
|
|
return;
|
|
}
|
|
try {
|
|
await runAutomationScript({
|
|
snippet,
|
|
sessionId,
|
|
sessionMeta: {
|
|
connected: true,
|
|
name: scriptSessionName,
|
|
hostname: host.hostname,
|
|
username: host.username,
|
|
},
|
|
});
|
|
} 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, host, scriptSessionName, 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,
|
|
scrollOnPasteRef,
|
|
clearWipesScrollbackRef,
|
|
normalizeTextOnCopyRef,
|
|
isBroadcastEnabledRef,
|
|
onBroadcastInputRef,
|
|
passwordPromptActiveRef,
|
|
isLocalConnection,
|
|
supportsRemoteImagePaste,
|
|
autoUploadClipboardImageOnPasteRef,
|
|
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 term = termRef.current;
|
|
if (!term) return;
|
|
const selection = getHistoryPreviewSelectionFromRoot(term.element?.parentElement)
|
|
|| getTerminalSelectionForClipboard(
|
|
term,
|
|
terminalSettings?.normalizeTextOnCopy ?? true,
|
|
);
|
|
if (!selection.trim()) return;
|
|
onAddSelectionToAI?.(sessionId, selection);
|
|
}, [onAddSelectionToAI, sessionId, terminalSettings?.normalizeTextOnCopy]);
|
|
|
|
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,
|
|
resolveSftpReuseSourceSessionId(host, 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 defaultPath = getRememberedYmodemSendDefaultPath();
|
|
const filePath = await selectFile(
|
|
t("terminal.ymodem.selectFile"),
|
|
defaultPath,
|
|
[{ name: t("terminal.ymodem.allFiles"), extensions: ["*"] }],
|
|
);
|
|
if (!filePath) return;
|
|
rememberYmodemSendFilePath(filePath);
|
|
|
|
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);
|
|
}
|
|
clearAutoReconnect();
|
|
retryTokenRef.current = null;
|
|
reconnectPreparationTokenRef.current = null;
|
|
restoreCwdIntentRef.current = null;
|
|
// Cancel must invalidate the boot the same way Disconnect does. Unmount is
|
|
// no longer the only path that flips boot-active: under StrictMode the pane
|
|
// can survive this click, so a late startSSH/startMosh attach could revive
|
|
// the aborted attempt. Bump the epoch here and let closeSession target the
|
|
// pre-bump epoch.
|
|
invalidateBootEpochForClose();
|
|
isBootActiveRef.current = false;
|
|
setIsCancelling(true);
|
|
auth.setNeedsAuth(false);
|
|
auth.setAuthRetryMessage(null);
|
|
setNeedsHostKeyVerification(false);
|
|
setPendingHostKeyInfo(null);
|
|
setPendingHostKeyRequestId(null);
|
|
setError("Connection cancelled");
|
|
setProgressLogs((prev) => [...prev, "Cancelled by user."]);
|
|
void cleanupSession();
|
|
updateStatus("disconnected");
|
|
setChainProgress(null);
|
|
setTimeout(() => setIsCancelling(false), 600);
|
|
onCloseSession?.(sessionId);
|
|
};
|
|
|
|
/** Tear down the live transport but keep the tab/pane for a later reconnect. */
|
|
const handleDisconnect = () => {
|
|
if (attachExistingSession) return;
|
|
if (statusRef.current === "disconnected") return;
|
|
if (pendingHostKeyRequestId) {
|
|
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
|
|
}
|
|
clearAutoReconnect({ stopLoop: true });
|
|
retryTokenRef.current = null;
|
|
reconnectPreparationTokenRef.current = null;
|
|
restoreCwdIntentRef.current = null;
|
|
// A hibernated reconnect may still be waking the runtime; invalidate that
|
|
// continuation so it cannot re-arm boot after this disconnect. Keep any
|
|
// runtime already created — wakeHibernatedRuntime clears hibernatedRef
|
|
// before returning, and disposing here would leave no term and no
|
|
// hibernation marker, permanently blocking later Reconnect.
|
|
reconnectWakeInvalidateModeRef.current = "keep";
|
|
reconnectWakeTokenRef.current = null;
|
|
reconnectWakeInFlightRef.current = false;
|
|
// Cancel closes the tab (effect cleanup flips boot-active). Disconnect keeps
|
|
// the pane mounted, so mark boot inactive here or a late startSSH/startMosh
|
|
// attach can still bring the session back after the user asked to stop.
|
|
// Bump the epoch so a later reconnect cannot revive this aborted attempt,
|
|
// but closeSession must still target the pre-bump epoch.
|
|
invalidateBootEpochForClose();
|
|
isBootActiveRef.current = false;
|
|
setIsCancelling(true);
|
|
auth.setNeedsAuth(false);
|
|
auth.setAuthRetryMessage(null);
|
|
setNeedsHostKeyVerification(false);
|
|
setPendingHostKeyInfo(null);
|
|
setPendingHostKeyRequestId(null);
|
|
setError(null);
|
|
setProgressLogs((prev) => [...prev, "Disconnected by user."]);
|
|
void cleanupSession({ retainOwnership: true });
|
|
updateStatus("disconnected");
|
|
setChainProgress(null);
|
|
setIsDisconnectedDialogDismissed(false);
|
|
window.dispatchEvent(new CustomEvent("netcatty:terminal-session-disconnected", {
|
|
detail: { sessionId },
|
|
}));
|
|
setTimeout(() => setIsCancelling(false), 600);
|
|
};
|
|
|
|
const handleDismissDisconnectedDialog = () => {
|
|
setIsDisconnectedDialogDismissed(true);
|
|
queueMicrotask(() => termRef.current?.focus());
|
|
};
|
|
|
|
const handleCloseDisconnectedSession = () => {
|
|
clearAutoReconnect();
|
|
retryTokenRef.current = null;
|
|
reconnectPreparationTokenRef.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 startReconnect = async (mode: "manual" | "auto" = "manual") => {
|
|
if (attachExistingSession) return;
|
|
if (
|
|
reconnectPreparationTokenRef.current !== null
|
|
|| reconnectWakeInFlightRef.current
|
|
) return;
|
|
setManualReconnectActive(mode === "manual");
|
|
if (mode === "manual") terminalReconnectRegistry.setActive(sessionId, true);
|
|
const reconnectAttemptMessage = mode === "auto"
|
|
? t("terminal.progress.autoReconnectAttempt", { attempt: autoReconnectAttemptRef.current })
|
|
: t("terminal.progress.reconnecting");
|
|
setReconnectNoticeMessage(reconnectAttemptMessage);
|
|
const restoreDisconnectedAfterReconnectFailure = () => {
|
|
if (mode === "manual") setReconnectNoticeMessage(null);
|
|
updateStatus("disconnected");
|
|
};
|
|
if (!termRef.current && hibernatedRef.current) {
|
|
if (reconnectWakeInFlightRef.current) return;
|
|
const wakeForReconnect = wakeHibernatedRuntimeForReconnectRef.current;
|
|
if (!wakeForReconnect) {
|
|
restoreDisconnectedAfterReconnectFailure();
|
|
return;
|
|
}
|
|
reconnectWakeInFlightRef.current = true;
|
|
const wakeToken = Symbol();
|
|
reconnectWakeInvalidateModeRef.current = "dispose";
|
|
reconnectWakeTokenRef.current = wakeToken;
|
|
if (mode === "auto") updateStatus("connecting");
|
|
void wakeForReconnect().then((woke) => {
|
|
if (reconnectWakeTokenRef.current !== wakeToken) {
|
|
reconnectWakeInFlightRef.current = false;
|
|
if (reconnectWakeInvalidateModeRef.current === "dispose") {
|
|
disposeRuntimeOnly();
|
|
}
|
|
return;
|
|
}
|
|
reconnectWakeTokenRef.current = null;
|
|
reconnectWakeInFlightRef.current = false;
|
|
if (woke) {
|
|
startReconnectRef.current?.(mode);
|
|
return;
|
|
}
|
|
restoreDisconnectedAfterReconnectFailure();
|
|
}).catch(() => {
|
|
if (reconnectWakeTokenRef.current !== wakeToken) {
|
|
reconnectWakeInFlightRef.current = false;
|
|
if (reconnectWakeInvalidateModeRef.current === "dispose") {
|
|
disposeRuntimeOnly();
|
|
}
|
|
return;
|
|
}
|
|
reconnectWakeTokenRef.current = null;
|
|
reconnectWakeInFlightRef.current = false;
|
|
restoreDisconnectedAfterReconnectFailure();
|
|
});
|
|
return;
|
|
}
|
|
if (!termRef.current) {
|
|
restoreDisconnectedAfterReconnectFailure();
|
|
return;
|
|
}
|
|
// Claim the retry before awaiting either script cancellation or backend
|
|
// cleanup. A second reconnect/cancel invalidates this continuation.
|
|
const retryToken = Symbol("retry");
|
|
retryTokenRef.current = retryToken;
|
|
reconnectPreparationTokenRef.current = retryToken;
|
|
const retryTokenStillCurrent = () => retryTokenRef.current === retryToken;
|
|
const finishReconnectPreparation = () => {
|
|
if (reconnectPreparationTokenRef.current === retryToken) {
|
|
reconnectPreparationTokenRef.current = null;
|
|
}
|
|
};
|
|
|
|
const connectAutomationBatch = connectScriptsBatchRef.current;
|
|
if (connectAutomationBatch) {
|
|
try {
|
|
await cancelConnectAutomationBatch(connectAutomationBatch);
|
|
} catch (error) {
|
|
finishReconnectPreparation();
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
toast.error(message);
|
|
if (mode === "auto" && retryTokenStillCurrent()) {
|
|
// Return to disconnected so the existing auto-reconnect loop can
|
|
// schedule another attempt, including another exact stop request.
|
|
updateStatus("disconnected");
|
|
} else if (mode === "manual" && retryTokenStillCurrent()) {
|
|
restoreDisconnectedAfterReconnectFailure();
|
|
}
|
|
return;
|
|
}
|
|
if (!retryTokenStillCurrent()) {
|
|
finishReconnectPreparation();
|
|
return;
|
|
}
|
|
if (connectScriptsBatchRef.current === connectAutomationBatch) {
|
|
connectScriptsBatchRef.current = null;
|
|
connectScriptsInFlightRef.current = false;
|
|
}
|
|
}
|
|
|
|
if (mode === "manual") {
|
|
clearAutoReconnect({ clearNotice: false });
|
|
// Manual reconnect skips the disconnected status transition that normally
|
|
// clears these refs, so onConnect scripts would otherwise stay consumed.
|
|
if (shouldResetConnectAutomationOnReconnect(
|
|
restoreState === "restored-disconnected" ? "restored" : "manual",
|
|
)) {
|
|
// The prior batch is now stopped, so its callbacks cannot mutate these
|
|
// newly allocated guards for the fresh manual connection.
|
|
connectScriptsConsumedRef.current = false;
|
|
connectScriptsCompletedIdsRef.current = new Set();
|
|
connectScriptsInFlightRef.current = false;
|
|
}
|
|
prepareRestoredReconnect();
|
|
// A clone's first connection can fail (auth/host-key/transport) before the
|
|
// inherited `cd` is consumed. prepareRestoredReconnect() just cleared the
|
|
// intent for non-restored sessions, so re-arm it here; the callback no-ops
|
|
// once the cwd was consumed or when there is no pending inherited cwd.
|
|
prepareInitialCwdIntent();
|
|
} else {
|
|
// An automatic reconnect replaces the transport but remains the same user
|
|
// session. Stop old automation above, then preserve the existing policy of
|
|
// not starting onConnect scripts again on the replacement transport.
|
|
connectScriptsConsumedRef.current = true;
|
|
restoreCwdIntentRef.current = null;
|
|
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect("automatic");
|
|
}
|
|
|
|
try {
|
|
await cleanupSession({ retainOwnership: true });
|
|
} catch (error) {
|
|
finishReconnectPreparation();
|
|
if (mode === "manual" && retryTokenStillCurrent()) {
|
|
restoreDisconnectedAfterReconnectFailure();
|
|
}
|
|
throw error;
|
|
}
|
|
if (!retryTokenStillCurrent()) {
|
|
finishReconnectPreparation();
|
|
return;
|
|
}
|
|
const term = termRef.current;
|
|
if (!term) {
|
|
finishReconnectPreparation();
|
|
if (mode === "manual" && retryTokenStillCurrent()) {
|
|
restoreDisconnectedAfterReconnectFailure();
|
|
}
|
|
return;
|
|
}
|
|
// closeSession wiped preload ready listeners; re-arm before startMosh so a
|
|
// fast handshake cannot emit netcatty:mosh:ready into an empty map.
|
|
prepareMoshReadySubscription();
|
|
// Keep the same retry token through the queued writes. If the user cancels /
|
|
// closes / unmounts / kicks off another retry while the chained writes are
|
|
// queued, the token is invalidated and callbacks abort before opening a
|
|
// ghost backend session with no owning UI.
|
|
const retryStillActive = () => retryTokenStillCurrent() && termRef.current === term;
|
|
|
|
bootEpochRef.current += 1;
|
|
// This start is a reconnect: force the bridge to dial a fresh connection
|
|
// instead of borrowing a parked/live transport, so the server performs a
|
|
// new login and picks up remote supplementary-group changes (#3293).
|
|
requireFreshConnectionOnReconnectRef.current = true;
|
|
publishBootEpoch();
|
|
isBootActiveRef.current = true;
|
|
auth.resetForRetry();
|
|
terminalDataCapturedRef.current = false;
|
|
if (mode === "manual") {
|
|
hasRunStartupCommandRef.current = false;
|
|
}
|
|
setIsDisconnectedDialogDismissed(false);
|
|
setConnectionReuseFellBack(false);
|
|
setConnectionReuseAttemptSourceId(undefined);
|
|
updateStatus("connecting");
|
|
setError(null);
|
|
setProgressLogs((prev) => (
|
|
[...prev, reconnectAttemptMessage]
|
|
));
|
|
setShowLogs(true);
|
|
|
|
const startNewSession = () => {
|
|
if (!retryStillActive()) {
|
|
finishReconnectPreparation();
|
|
return;
|
|
}
|
|
finishReconnectPreparation();
|
|
xtermRuntimeRef.current?.resetKittyConnectionInputState();
|
|
resetKittyKeyboardModeStateForSession(terminalSettingsRef);
|
|
if (effectiveTerminalProtocol.startsWith("plugin:")) {
|
|
sessionStarters.startPluginConnection(term);
|
|
} else if (host.protocol === "serial") {
|
|
sessionStarters.startSerial(term);
|
|
} else if (effectiveTerminalProtocol === 'local') {
|
|
sessionStarters.startLocal(term);
|
|
} else if (host.protocol === "telnet") {
|
|
sessionStarters.startTelnet(term);
|
|
} else if (effectiveTerminalProtocol === 'mosh') {
|
|
// Defensive: xterm.write may fire after another cleanup raced us.
|
|
prepareMoshReadySubscription();
|
|
sessionStarters.startMosh(term);
|
|
} else if (effectiveTerminalProtocol === 'et') {
|
|
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()) {
|
|
finishReconnectPreparation();
|
|
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,
|
|
);
|
|
});
|
|
};
|
|
startReconnectRef.current = startReconnect;
|
|
|
|
const handleRetry = () => {
|
|
startReconnect("manual");
|
|
};
|
|
manualReconnectRequestRef.current = handleRetry;
|
|
useEffect(() => {
|
|
if (attachExistingSession) return undefined;
|
|
return terminalReconnectRegistry.register(
|
|
sessionId,
|
|
() => manualReconnectRequestRef.current(),
|
|
);
|
|
}, [attachExistingSession, sessionId]);
|
|
|
|
const isReconnectActive = autoReconnectLoopActiveRef.current || manualReconnectActive;
|
|
const shouldShowConnectionDialog = shouldShowTerminalConnectionDialog({
|
|
status,
|
|
isLocalConnection,
|
|
isSerialConnection,
|
|
isDisconnectedDialogDismissed,
|
|
disconnectedNoticeMode: terminalSettings.disconnectedNoticeMode,
|
|
hasEverConnected: hasEverConnectedRef.current,
|
|
restoreState,
|
|
isReconnectActive,
|
|
requiresUserInput: auth.needsAuth || needsHostKeyVerification || isConnectionAwaitingUserInput,
|
|
hideConnectingDialogForConnectionReuse: shouldHideConnectingDialogForConnectionReuse({
|
|
reuseConnectionFromSessionId: connectionReuseAttemptSourceId,
|
|
host,
|
|
connectionReuseFellBack,
|
|
}),
|
|
});
|
|
const showDisconnectedTerminalNotice = shouldShowTerminalDisconnectedNotice({
|
|
status,
|
|
disconnectedNoticeMode: terminalSettings.disconnectedNoticeMode,
|
|
hasEverConnected: hasEverConnectedRef.current,
|
|
restoreState,
|
|
isReconnectActive,
|
|
requiresUserInput: auth.needsAuth || needsHostKeyVerification || isConnectionAwaitingUserInput,
|
|
});
|
|
|
|
const {
|
|
handleDragEnter,
|
|
handleDragLeave,
|
|
handleDragOver,
|
|
handleDrop,
|
|
isDraggingOver,
|
|
} = useTerminalDragDrop({
|
|
host,
|
|
resolvedLoginUsername,
|
|
resolvedSudoPassword: resolvedSudoAutofillPassword,
|
|
isLocalConnection,
|
|
isNetworkDevice,
|
|
onOpenSftp,
|
|
resolveSftpInitialPath,
|
|
scrollToBottomAfterProgrammaticInput,
|
|
sessionId,
|
|
sessionRef,
|
|
status,
|
|
t,
|
|
terminalBackend,
|
|
isSensitiveInput: () => passwordPromptActiveRef.current,
|
|
termRef,
|
|
});
|
|
|
|
useTerminalFilePaste({
|
|
isLocalConnection,
|
|
status,
|
|
termRef,
|
|
sessionRef,
|
|
terminalBackend,
|
|
isSensitiveInput: () => passwordPromptActiveRef.current,
|
|
scrollOnPasteRef,
|
|
onPasteData: broadcastUserPasteData,
|
|
scrollToBottomAfterProgrammaticInput,
|
|
containerRef,
|
|
autoUploadClipboardImage:
|
|
supportsRemoteImagePaste && terminalSettings?.autoUploadClipboardImageOnPaste === true,
|
|
getRemoteCwd: () => resolveSftpInitialPath({ preferFreshBackend: true }),
|
|
onClipboardImageUploadResult: handleClipboardImageUploadResult,
|
|
});
|
|
|
|
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?.stopped) {
|
|
setIsSessionLogging(false);
|
|
}
|
|
if (!stopResult?.success) {
|
|
toast.error(stopResult?.error || "Failed to stop session log");
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Choose the destination first, then re-sample terminal state. The save
|
|
// dialog can stay open long enough for the user to enter/leave vim, so
|
|
// alternate-screen / initial-line must be captured at stream activation.
|
|
const chooseResult = await chooseManualSessionLogPath({
|
|
sessionId: currentSessionId,
|
|
sessionName: host.label || host.hostname || currentSessionId,
|
|
preferredDirectory: sessionLog?.directory,
|
|
format: sessionLog?.format,
|
|
});
|
|
if (!chooseResult?.success) {
|
|
toast.error(chooseResult?.error || "Failed to start session log");
|
|
return;
|
|
}
|
|
if (chooseResult.canceled || !chooseResult.selectionToken) return;
|
|
|
|
const startResult = await startManualSessionLog({
|
|
sessionId: currentSessionId,
|
|
sessionName: host.label || host.hostname || currentSessionId,
|
|
selectionToken: chooseResult.selectionToken,
|
|
format: sessionLog?.format,
|
|
timestampsEnabled: sessionLog?.timestampsEnabled,
|
|
initialLine: termRef.current ? getSessionLogInitialLine(termRef.current) : "",
|
|
alternateScreenActive: termRef.current
|
|
? isTerminalAlternateScreenActive(termRef.current)
|
|
: false,
|
|
});
|
|
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");
|
|
}
|
|
}, [
|
|
chooseManualSessionLogPath,
|
|
getManualSessionLogStatus,
|
|
host.hostname,
|
|
host.label,
|
|
sessionId,
|
|
sessionLog?.directory,
|
|
sessionLog?.format,
|
|
sessionLog?.timestampsEnabled,
|
|
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; restorePaneLayout?: boolean }) => (
|
|
<TerminalToolbar
|
|
sessionId={sessionId}
|
|
workspaceId={workspaceId}
|
|
status={status}
|
|
host={host}
|
|
compactToolbar={compactToolbar}
|
|
snippets={snippets}
|
|
snippetPackages={snippetPackages}
|
|
onSnippetClick={(snippet) => { void executeSnippet(snippet); }}
|
|
onDeleteSnippets={onDeleteSnippets}
|
|
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={() => {
|
|
if (opts?.restorePaneLayout) {
|
|
onTogglePaneMagnification?.();
|
|
return;
|
|
}
|
|
onCloseSession?.(sessionId);
|
|
}}
|
|
closeLabel={t(opts?.restorePaneLayout
|
|
? 'terminal.paneMagnification.restore'
|
|
: 'terminal.toolbar.closeSession')}
|
|
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,
|
|
onDeleteSnippets,
|
|
onOpenScripts,
|
|
onOpenHistory,
|
|
onOpenTheme,
|
|
onToggleComposeBar,
|
|
onTogglePaneMagnification,
|
|
setIsComposeBarOpen,
|
|
handleUpdateHostFromTerminal,
|
|
sessionId,
|
|
snippetPackages,
|
|
snippets,
|
|
status,
|
|
terminalEncoding,
|
|
t,
|
|
recorder,
|
|
workspaceId,
|
|
]);
|
|
|
|
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,
|
|
kittyKeyboardProtocolEnabled: kittyKeyboardProtocolEnabledForSession,
|
|
terminalBackend,
|
|
sessionRef,
|
|
hotkeySchemeRef,
|
|
disableTerminalFontZoomRef,
|
|
keyBindingsRef,
|
|
onHotkeyActionRef,
|
|
onTerminalFontSizeChange,
|
|
onOpenExternalError,
|
|
isBroadcastEnabledRef,
|
|
onBroadcastInputRef,
|
|
snippetsRef,
|
|
onSnippetShortkeyRef,
|
|
sessionId,
|
|
statusRef,
|
|
onCommandExecuted,
|
|
onCommandSubmitted: cwdAwareOnCommandSubmitted,
|
|
onTrustedCommandSubmitted: pluginAwareOnCommandSubmitted,
|
|
onCommandCompleted: pluginAwareOnCommandCompleted,
|
|
requestPluginTerminalProviders,
|
|
pluginProviderVisible: isVisible,
|
|
isPluginTerminalProviderAvailable,
|
|
onResize: pluginTerminalLifecycle.onResized,
|
|
onAlternateScreenChange: pluginTerminalLifecycle.onAlternateScreenChanged,
|
|
commandBufferRef,
|
|
scriptRecorderRef: recorderRef,
|
|
passwordPromptActiveRef,
|
|
allowHostStyleGreaterThanPrompt: isNetworkDevice,
|
|
onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef,
|
|
promptLineBreakStateRef,
|
|
sudoAutofillRef,
|
|
requestSearchFocus,
|
|
serialLocalEcho: serialConfig?.localEcho,
|
|
serialLineMode: serialConfig?.lineMode,
|
|
serialLineBufferRef,
|
|
telnetLocalEchoRef,
|
|
onTerminalLogData: captureTerminalLogData,
|
|
terminalOutputHistory: terminalOutputHistoryRef.current,
|
|
onCwdChange: (cwd: string) => {
|
|
pluginAwareOnRuntimeCwdChange(cwd, { source: 'osc7' });
|
|
},
|
|
onTitleChange: (title: string | null) => {
|
|
pluginAwareOnTerminalTitleChange(sessionId, title);
|
|
},
|
|
onBell: () => {
|
|
onTerminalBell?.(sessionId);
|
|
},
|
|
onOscNotification: (notification) => {
|
|
handleTerminalOscNotification({
|
|
notification,
|
|
mode: terminalSettingsRef.current?.oscNotifications,
|
|
sessionFocused: isFocusedRef.current,
|
|
sessionId,
|
|
fallbackTitle: host.label || host.hostname || "Netcatty",
|
|
onSessionActivity: () => onTerminalBell?.(sessionId),
|
|
});
|
|
},
|
|
onOsc52ReadRequest: handleOsc52ReadRequest,
|
|
onAutocompleteKeyEvent: (e: KeyboardEvent) => autocompleteKeyEventRef.current?.(e) ?? true,
|
|
onAutocompleteInput: (data: string) => autocompleteInputRef.current?.(data),
|
|
onAutocompleteReposition: () => autocompleteRepositionRef.current?.(),
|
|
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]);
|
|
wakeSoftHiddenRuntimeRef.current = wakeSoftHiddenRuntime;
|
|
|
|
const resumeRendererAfterCancelledHibernateUpgrade = useCallback(() => {
|
|
if (hibernatedRef.current || softHiddenRef.current || !hasRuntimeRef.current) return;
|
|
xtermRuntimeRef.current?.ensureWebglRenderer();
|
|
xtermRuntimeRef.current?.clearTextureAtlas();
|
|
safeFitRef.current({ force: true });
|
|
}, []);
|
|
resumeRendererAfterCancelledHibernateUpgradeRef.current = resumeRendererAfterCancelledHibernateUpgrade;
|
|
|
|
useEffect(() => {
|
|
return terminalHiddenRendererStore.subscribe(() => {
|
|
if (!terminalHiddenRendererStore.consumeEvictionRequest(sessionId)) return;
|
|
if (!softHiddenRef.current || hibernatedRef.current) return;
|
|
// The eviction request is consumed above so the store does not keep retrying,
|
|
// but a session holding inline images stays soft-hidden: full hibernate would
|
|
// drop bitmaps the text snapshot cannot restore.
|
|
if (runtimeHasInlineImages()) return;
|
|
upgradeSoftHiddenRuntimeToHibernate();
|
|
});
|
|
}, [
|
|
runtimeHasInlineImages,
|
|
sessionId,
|
|
upgradeSoftHiddenRuntimeToHibernate,
|
|
]);
|
|
|
|
const wakeFromHibernateRuntime = useCallback((
|
|
getPayload: () => TerminalHibernateWakePayload,
|
|
options: { sessionConnected: boolean },
|
|
): boolean | Promise<boolean> => {
|
|
if (wakeInProgressRef.current) {
|
|
return wakePromiseRef.current ?? false;
|
|
}
|
|
if (hasRuntimeRef.current) {
|
|
logger.warn("[Terminal] Wake skipped", {
|
|
sessionId,
|
|
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;
|
|
setPasswordPickerState(null);
|
|
|
|
const stopHibernateDataListener = () => {
|
|
disposeDataRef.current?.();
|
|
disposeDataRef.current = null;
|
|
};
|
|
|
|
const stopHibernateListeners = (opts?: { keepPaused?: boolean }) => {
|
|
const backendId = sessionRef.current;
|
|
stopHibernateDataListener();
|
|
disposeExitRef.current?.();
|
|
disposeExitRef.current = null;
|
|
if (backendId) {
|
|
flushTerminalSessionFlowAck(backendId);
|
|
clearTerminalSessionFlowAck(backendId);
|
|
if (!opts?.keepPaused) {
|
|
terminalBackend.setSessionFlowPaused?.(backendId, false);
|
|
}
|
|
}
|
|
};
|
|
|
|
const wakePromise = wakeTerminalFromHibernate({
|
|
refs: terminalRuntimeRefs,
|
|
runtimeContext,
|
|
container,
|
|
getPayload,
|
|
prepareWakeFlow: async () => {
|
|
const backendId = sessionRef.current;
|
|
if (!backendId) return true;
|
|
// Always pause when a backend exists, including reconnect wakes
|
|
// (sessionConnected=false) that will not reattach. Stopping the
|
|
// hibernate listener without a pause drops live output into the
|
|
// preload backlog without flow ACKs until cleanupSession.
|
|
if (terminalBackend.setSessionFlowPausedAndWait) {
|
|
const result = await terminalBackend.setSessionFlowPausedAndWait(backendId, true);
|
|
return result?.success === true;
|
|
}
|
|
terminalBackend.setSessionFlowPaused?.(backendId, true);
|
|
return false;
|
|
},
|
|
takePendingBuffer: () => {
|
|
const pending = hibernatePendingBufferRef.current + oscNotificationScannerRef.current.flush();
|
|
hibernatePendingBufferRef.current = "";
|
|
return pending;
|
|
},
|
|
stopHibernateDataListener,
|
|
setHibernatePendingCapDisabled: (disabled) => {
|
|
hibernatePendingCapDisabledRef.current = disabled;
|
|
},
|
|
stopHibernateListeners: () => stopHibernateListeners({ keepPaused: true }),
|
|
restoreAfterFailedWake: (takenPending) => {
|
|
hibernatePendingCapDisabledRef.current = false;
|
|
const pendingStillInRef = hibernatePendingBufferRef.current;
|
|
disposeRuntimeOnly();
|
|
const backendId = sessionRef.current;
|
|
if (!backendId) {
|
|
// No backend to reattach listeners to; still keep taken bytes for a
|
|
// later wake of a disconnected hibernated tab.
|
|
hibernatePendingBufferRef.current = appendHibernatePendingBuffer(
|
|
takenPending || "",
|
|
pendingStillInRef,
|
|
);
|
|
return;
|
|
}
|
|
beginHibernatedSessionListeners(backendId);
|
|
// beginHibernatedSessionListeners clears pending; restore take-and-cleared
|
|
// bytes from this wake plus anything that arrived after the last take.
|
|
const restored = appendHibernatePendingBuffer(
|
|
takenPending || "",
|
|
pendingStillInRef,
|
|
);
|
|
if (restored) {
|
|
hibernatePendingBufferRef.current = appendHibernatePendingBuffer(
|
|
restored,
|
|
hibernatePendingBufferRef.current,
|
|
);
|
|
}
|
|
},
|
|
resumeAfterReattach: () => {
|
|
hibernatePendingCapDisabledRef.current = false;
|
|
const backendId = sessionRef.current;
|
|
if (!backendId) return;
|
|
terminalBackend.setSessionFlowPaused?.(backendId, false);
|
|
},
|
|
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),
|
|
additionalKeywordHighlightRules: pluginDecorationRulesRef.current,
|
|
}).then((ok) => ok).catch((err) => {
|
|
logger.error("[Terminal] Failed to resume from hibernate", err);
|
|
return false;
|
|
}).finally(() => {
|
|
wakeInProgressRef.current = false;
|
|
if (wakePromiseRef.current === wakePromise) {
|
|
wakePromiseRef.current = null;
|
|
}
|
|
});
|
|
wakePromiseRef.current = wakePromise;
|
|
return wakePromise;
|
|
}, [sessionId, terminalBackend, terminalRuntimeRefs, resizeSession, terminalSettings, beginHibernatedSessionListeners]);
|
|
|
|
const wakeHibernatedRuntime = useCallback(async (sessionConnected: boolean): Promise<boolean> => {
|
|
if (!hibernatedRef.current) {
|
|
return Boolean(termRef.current);
|
|
}
|
|
|
|
const getPayload = (): TerminalHibernateWakePayload => ({
|
|
snapshot: hibernateSnapshotRef.current,
|
|
viewportSnapshot: hibernateViewportSnapshotRef.current || hibernateSnapshotRef.current,
|
|
scrollbackSnapshot: hibernateScrollbackSnapshotRef.current,
|
|
pendingBuffer: hibernatePendingBufferRef.current,
|
|
alternateScreen: hibernateAlternateScreenRef.current,
|
|
});
|
|
|
|
logger.info("[Terminal] Waking hibernated runtime", {
|
|
sessionId,
|
|
sessionConnected,
|
|
snapshotChars: hibernateSnapshotRef.current.length,
|
|
viewportChars: hibernateViewportSnapshotRef.current.length,
|
|
scrollbackChars: hibernateScrollbackSnapshotRef.current.length,
|
|
pendingChars: hibernatePendingBufferRef.current.length,
|
|
});
|
|
|
|
const accepted = await Promise.resolve(wakeFromHibernateRuntime(getPayload, { sessionConnected }));
|
|
if (accepted === false || !termRef.current) {
|
|
return false;
|
|
}
|
|
|
|
clearHibernateRuntimeState();
|
|
// Connected multi-tab fan-out may wake still-hidden peers. Re-arm the
|
|
// hibernate timer so the full xterm runtime does not stay mounted forever
|
|
// (visibility/status do not change, so the hibernate effect will not rerun).
|
|
if (sessionConnected && !isVisibleRef.current) {
|
|
scheduleHibernateRetry();
|
|
}
|
|
return true;
|
|
}, [sessionId, wakeFromHibernateRuntime, clearHibernateRuntimeState, scheduleHibernateRetry]);
|
|
|
|
wakeHibernatedRuntimeForReconnectRef.current = () => wakeHibernatedRuntime(false);
|
|
wakeHibernatedRuntimeForConnectedRef.current = () => wakeHibernatedRuntime(true);
|
|
|
|
const hibernateFileTransferActive = isTerminalFileTransferActive({
|
|
zmodemActive: zmodem.active,
|
|
ymodemInProgress,
|
|
isDraggingOver,
|
|
});
|
|
hibernateFileTransferActiveRef.current = hibernateFileTransferActive;
|
|
|
|
useTerminalHibernateEffect({
|
|
sessionId,
|
|
isVisible,
|
|
isVisibleRef,
|
|
getSessionConnectedRef,
|
|
status,
|
|
isSearchOpen,
|
|
hibernateEnabled: hibernateEnabled,
|
|
hibernateDelayMs: resolveTerminalHibernateDelayMs(terminalSettings),
|
|
fileTransferActive: hibernateFileTransferActive,
|
|
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, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, 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, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
|
|
|
return (
|
|
<>
|
|
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, 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}
|
|
compactTopChrome={terminalSettings?.showHostInfoBar === false}
|
|
/>
|
|
) : null, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls: !attachExistingSession && !compactToolbar, showLogs, showSelectionAIAction: Boolean(showSelectionAIAction && onAddSelectionToAI), isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect: handlePasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable: !attachExistingSession, reconnectNoticeMessage, timeLeft, toast, zmodem }} isPaneMagnified={isPaneMagnified} />
|
|
<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;
|