Files
Netcatty/components/TerminalLayer.tsx
陈大猫 4459aa4ef3 Add terminal zoom disable setting
Adds a Shortcuts setting to disable terminal zoom shortcuts.
2026-06-12 14:33:17 +08:00

1245 lines
46 KiB
TypeScript

import { FolderTree, History, MessageSquare, PanelLeft, PanelRight, Palette, X, Zap } from 'lucide-react';
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { activeTabStore } from '../application/state/activeTabStore';
import { canReuseTerminalConnection } from '../application/state/terminalConnectionReuse';
import { resolveTerminalSessionExitIntent, type TerminalSessionExitEvent } from '../application/state/resolveTerminalSessionExitIntent';
import { prewarmAIStateStorageSnapshots } from '../application/state/aiStateSnapshots';
import {
getSessionActivityIdsToClear,
getValidSessionActivityIds,
shouldMarkSessionActivity,
} from '../application/state/sessionActivity';
import { sessionActivityStore } from '../application/state/sessionActivityStore';
import { sessionCapabilitiesStore } from '../application/state/sessionCapabilitiesStore';
import { useTerminalBackend } from '../application/state/useTerminalBackend';
import { collectSessionIds } from '../domain/workspace';
import { cn, normalizeLineEndings } from '../lib/utils';
import { detectLocalOs } from '../lib/localShell';
import { useStoredString } from '../application/state/useStoredString';
import { useStoredNumber } from '../application/state/useStoredNumber';
import {
STORAGE_KEY_SIDE_PANEL_WIDTH,
} from '../infrastructure/config/storageKeys';
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
import type { DropEntry } from '../lib/sftpFileUtils';
import { Host, KnownHost, TerminalSession, Workspace } from '../types';
import { resolveGroupDefaults, applyGroupDefaults } from '../domain/groupConfig';
import { applySessionFontSizeToHost } from '../domain/terminalAppearance';
import { resolveHostAutofillPassword } from '../domain/sshAuth';
import { materializeHostProxyProfile } from '../domain/proxyProfiles';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { useI18n } from '../application/i18n/I18nProvider';
import { SftpSidePanel } from './SftpSidePanel';
import { ScriptsSidePanel } from './ScriptsSidePanel';
import { HistorySidePanel } from './HistorySidePanel';
import { useRemoteHistoryState } from '../application/state/useRemoteHistoryState';
import { resolveSnippetCommand } from './SnippetExecutionProvider';
import type { Snippet } from '../types';
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
import { focusTerminalSessionInput } from './terminal/focusTerminalSession';
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
import { Button } from './ui/button';
import { setupMcpApprovalBridge } from '../infrastructure/ai/shared/approvalGate';
import { resolveScriptsSidePanelShortcutIntent } from '../application/state/resolveSnippetsShortcutIntent';
import { resolveSidePanelToggleIntent } from '../application/state/resolveSidePanelToggleIntent';
import { resolveAiSidePanelToggleIntent } from '../application/state/resolveAiSidePanelToggleIntent';
import { terminalLayerAreEqual } from './terminalLayerMemo';
import { TerminalLayerTabBridge } from './terminalLayer/TerminalLayerTabBridge';
import { resolvePreferredTerminalCwd, scheduleBackendCwdProbeAfterCommand } from './terminal/sftpCwd';
import { classifyDistroId, shouldProbeSessionCwd } from '../domain/host';
import {
AIChatPanelsHost,
AISidePanelStateRoot,
AIStateMaintenanceHost,
AIStateProvider,
ChunkedEscapeFilter,
TerminalPanesHost,
clearTerminalPreviewVars,
clearTopTabsPreviewVars,
filterTabsMap,
hasNotifiableTerminalOutput,
type PendingSftpUpload,
type PendingTerminalSelectionForAI,
type SidePanelTab,
type SnippetExecutor,
type TerminalLayerProps,
} from './terminalLayer/TerminalLayerSupport';
const addMountedSidePanelTabId = (
tabIds: string[],
tabId: string,
): string[] => (tabIds.includes(tabId) ? tabIds : [...tabIds, tabId]);
const removeMountedSidePanelTabId = (
tabIds: string[],
tabId: string,
): string[] => tabIds.filter((id) => id !== tabId);
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
hosts,
customGroups,
groupConfigs,
proxyProfiles,
keys,
identities,
snippets,
snippetPackages,
sessions,
workspaces,
knownHosts = [],
draggingSessionId,
terminalTheme,
followAppTerminalTheme = false,
accentMode = 'theme',
customAccent = '',
terminalSettings,
terminalFontFamilyId,
fontSize = 14,
hotkeyScheme = 'disabled',
disableTerminalFontZoom = false,
keyBindings = [],
onHotkeyAction,
onUpdateTerminalThemeId,
onUpdateTerminalFontFamilyId,
onUpdateTerminalFontSize,
onUpdateTerminalFontWeight,
onUpdateSessionFontSize,
onClearSessionFontSizeOverride,
onCloseSession,
onUpdateSessionStatus,
onUpdateHostDistro,
onUpdateHost,
onAddKnownHost,
onCommandExecuted,
shellHistory = [],
onTerminalDataCapture,
onCreateWorkspaceFromSessions,
onAddSessionToWorkspace,
onRequestAddToWorkspace,
onUpdateSplitSizes,
onSetDraggingSessionId,
onToggleWorkspaceViewMode,
onSetWorkspaceFocusedSession,
onReorderWorkspaceSessions,
onSplitSession,
onConnectToHost,
onCreateLocalTerminal,
isBroadcastEnabled,
onToggleBroadcast,
updateHosts,
updateSnippets,
updateSnippetPackages,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
sftpAutoOpenSidebar,
sftpFollowTerminalCwd,
setSftpFollowTerminalCwd,
editorWordWrap,
setEditorWordWrap,
sessionLogsEnabled,
sessionLogsDir,
sessionLogsFormat,
sessionLogsTimestampsEnabled,
sshDebugLogsEnabled,
showHostTreeSidebar = true,
toggleScriptsSidePanelRef,
toggleSidePanelRef,
}) => {
const { t } = useI18n();
const terminalRendererCwdBySessionRef = useRef<Map<string, string>>(new Map());
const stableRef = useRef<Record<string, unknown>>({});
const activeTabIdRef = useRef(activeTabStore.getActiveTabId());
const activeWorkspaceRef = useRef<Workspace | undefined>(undefined);
const activeSessionRef = useRef<TerminalSession | undefined>(undefined);
const focusedSessionIdRef = useRef<string | undefined>(undefined);
const terminalCwdRevisionRef = useRef(0);
const [terminalCwdRevision, setTerminalCwdRevision] = useState(0);
const cwdProbeCancelersRef = useRef<Map<string, () => void>>(new Map());
const cwdProbeGenerationRef = useRef<Map<string, number>>(new Map());
useEffect(() => {
const runPrewarm = () => prewarmAIStateStorageSnapshots();
if (typeof window.requestIdleCallback === 'function') {
const idleId = window.requestIdleCallback(runPrewarm, { timeout: 2500 });
return () => window.cancelIdleCallback(idleId);
}
const timeoutId = window.setTimeout(runPrewarm, 500);
return () => window.clearTimeout(timeoutId);
}, []);
const handleTerminalCwdChange = useCallback((sessionId: string, cwd: string | null) => {
const currentCwd = terminalRendererCwdBySessionRef.current.get(sessionId) ?? null;
const nextCwd = cwd && cwd.trim().length > 0 ? cwd : null;
if (currentCwd === nextCwd) return;
if (nextCwd) {
terminalRendererCwdBySessionRef.current.set(sessionId, nextCwd);
} else {
terminalRendererCwdBySessionRef.current.delete(sessionId);
}
terminalCwdRevisionRef.current += 1;
setTerminalCwdRevision(terminalCwdRevisionRef.current);
}, []);
// Stable callback references for Terminal components
const handleCloseSession = useCallback((sessionId: string) => {
sessionCapabilitiesStore.delete(sessionId);
onCloseSession(sessionId);
}, [onCloseSession]);
const sftpAutoOpenSidebarRef = useRef(sftpAutoOpenSidebar);
sftpAutoOpenSidebarRef.current = sftpAutoOpenSidebar;
const sftpFollowTerminalCwdRef = useRef(sftpFollowTerminalCwd);
sftpFollowTerminalCwdRef.current = sftpFollowTerminalCwd;
const handleStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
onUpdateSessionStatus(sessionId, status);
// Auto-open SFTP sidebar when a remote host connects (if setting enabled)
if (status === 'connected' && sftpAutoOpenSidebarRef.current) {
const session = sessionsRef.current.find(s => s.id === sessionId);
if (!session) return;
// Only auto-open for SSH/Mosh (SFTP requires SSH); skip local/unset protocol
const proto = session.protocol;
if (proto !== 'ssh' && proto !== 'mosh') return;
const host = hostsRef.current.find(h => h.id === session.hostId);
// Determine the tab ID (workspace or solo session)
const tabId = session.workspaceId || sessionId;
// Only open if the sidebar is not already open for this tab
if (sidePanelOpenTabsRef.current.has(tabId)) return;
const hostWithOverrides: Host = host
? {
...host,
protocol: session.protocol ?? host.protocol,
port: session.port ?? host.port,
moshEnabled: session.moshEnabled ?? host.moshEnabled,
etEnabled: session.etEnabled ?? host.etEnabled,
}
: {
// Quick Connect / temporary session — build minimal host from session data
id: session.hostId || sessionId,
hostname: session.hostname,
username: session.username,
port: session.port ?? 22,
protocol: proto,
label: session.label || session.hostname,
} as Host;
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.set(tabId, 'sftp');
return next;
});
setSftpHostForTab(prev => {
const next = new Map(prev);
next.set(tabId, hostWithOverrides);
return next;
});
}
}, [onUpdateSessionStatus]);
const handleSessionExit = useCallback((sessionId: string, evt: TerminalSessionExitEvent) => {
const intent = resolveTerminalSessionExitIntent(evt);
if (intent.kind === "closeSession") {
onCloseSession(sessionId);
} else {
onUpdateSessionStatus(sessionId, 'disconnected');
}
}, [onCloseSession, onUpdateSessionStatus]);
const handleOsDetected = useCallback((hostId: string, distro: string) => {
onUpdateHostDistro(hostId, distro);
}, [onUpdateHostDistro]);
const handleUpdateHost = useCallback((host: Host) => {
onUpdateHost(host);
}, [onUpdateHost]);
const handleAddKnownHost = useCallback((knownHost: KnownHost) => {
onAddKnownHost?.(knownHost);
}, [onAddKnownHost]);
const handleTerminalDataCapture = useCallback((sessionId: string, data: string) => {
onTerminalDataCapture?.(sessionId, data);
}, [onTerminalDataCapture]);
// Terminal backend for broadcast writes
const terminalBackend = useTerminalBackend();
const snippetExecutorsRef = useRef<Map<string, SnippetExecutor>>(new Map());
const handleSnippetExecutorChange = useCallback((sessionId: string, executor: SnippetExecutor | null) => {
if (executor) {
snippetExecutorsRef.current.set(sessionId, executor);
return;
}
snippetExecutorsRef.current.delete(sessionId);
}, []);
const onSessionData = terminalBackend.onSessionData;
const workspaceById = useMemo(
() => new Map(workspaces.map((workspace) => [workspace.id, workspace])),
[workspaces],
);
// Workspace-level compose bar state
const [isComposeBarOpen, setIsComposeBarOpen] = useState(false);
const sessionsRef = useRef(sessions);
sessionsRef.current = sessions;
const workspacesRef = useRef(workspaces);
workspacesRef.current = workspaces;
const hostsRef = useRef(hosts);
hostsRef.current = hosts;
const onSetWorkspaceFocusedSessionRef = useRef(onSetWorkspaceFocusedSession);
onSetWorkspaceFocusedSessionRef.current = onSetWorkspaceFocusedSession;
// Handle broadcast input - write to all other sessions in the source workspace.
const handleBroadcastInput = useCallback((data: string, sourceSessionId: string) => {
const sourceSession = sessionsRef.current.find((session) => session.id === sourceSessionId);
const workspaceId = sourceSession?.workspaceId;
if (!workspaceId) return;
for (const session of sessionsRef.current) {
if (session.workspaceId === workspaceId && session.id !== sourceSessionId) {
terminalBackend.writeToSession(session.id, data);
}
}
}, [terminalBackend]);
// Side panel state - per-tab tracking of which sub-panel is active
// Maps tab IDs to the active sub-panel type (sftp/scripts/theme), absent = closed
const [sidePanelOpenTabs, setSidePanelOpenTabs] = useState<Map<string, SidePanelTab>>(new Map());
// Keep AI/scripts/theme panels mounted while switching sub-tabs (like SFTP).
const [aiMountedTabIds, setAiMountedTabIds] = useState<string[]>([]);
const [scriptsMountedTabIds, setScriptsMountedTabIds] = useState<string[]>([]);
const [systemMountedTabIds, setSystemMountedTabIds] = useState<string[]>([]);
const [themeMountedTabIds, setThemeMountedTabIds] = useState<string[]>([]);
const [sidePanelWidth, setSidePanelWidth, persistSidePanelWidth] = useStoredNumber(
STORAGE_KEY_SIDE_PANEL_WIDTH, 420, { min: 280, max: 800 },
);
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right'>(
'netcatty_side_panel_position',
'left',
(v): v is 'left' | 'right' => v === 'left' || v === 'right',
);
const sidePanelOpenTabsRef = useRef(sidePanelOpenTabs);
sidePanelOpenTabsRef.current = sidePanelOpenTabs;
// Remember the last sub-panel shown per tab so the toggle shortcut can
// restore it after a close. Overwritten on open, never cleared on close.
const lastSidePanelTabRef = useRef<Map<string, SidePanelTab>>(new Map());
// The host to pass to the SFTP panel - stored when the user opens SFTP
const [sftpHostForTab, setSftpHostForTab] = useState<Map<string, Host>>(new Map());
const [sftpInitialLocationForTab, setSftpInitialLocationForTab] = useState<
Map<string, { hostId: string; path: string }>
>(new Map());
const [sftpPendingUploadsForTab, setSftpPendingUploadsForTab] = useState<
Map<string, PendingSftpUpload>
>(new Map());
const [pendingTerminalSelectionForAI, setPendingTerminalSelectionForAI] =
useState<PendingTerminalSelectionForAI | null>(null);
const sftpHostForTabRef = useRef(sftpHostForTab);
sftpHostForTabRef.current = sftpHostForTab;
const handleToggleWorkspaceComposeBar = useCallback(() => {
setIsComposeBarOpen(prev => !prev);
}, []);
const handleOpenSftp = useCallback((host: Host, initialPath?: string, pendingUploadEntries?: DropEntry[], sourceSessionId?: string) => {
const tabId = activeTabIdRef.current;
if (!tabId) return;
// When SFTP is opened from a non-focused workspace pane (toolbar click
// or drag-drop), switch focus first so the SFTP panel binds to the
// correct host.
if (sourceSessionId) {
const ws = activeWorkspaceRef.current;
if (ws && ws.focusedSessionId !== sourceSessionId) {
onSetWorkspaceFocusedSessionRef.current?.(ws.id, sourceSessionId);
}
}
const currentPanel = sidePanelOpenTabsRef.current.get(tabId);
const isOpen = currentPanel === 'sftp';
const currentHost = sftpHostForTabRef.current.get(tabId);
const shouldKeepOpen = !!pendingUploadEntries?.length;
// Compare full endpoint identity so that session-time overrides
// (different port/protocol for the same host ID) trigger a switch
// instead of toggling the panel closed.
const isSameEndpoint = currentHost
&& currentHost.id === host.id
&& currentHost.hostname === host.hostname
&& currentHost.port === host.port
&& currentHost.protocol === host.protocol
&& currentHost.username === host.username
&& currentHost.sftpSudo === host.sftpSudo;
const isClosing = !shouldKeepOpen && isOpen && isSameEndpoint;
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
if (isClosing) {
next.delete(tabId);
} else {
next.set(tabId, 'sftp');
}
return next;
});
// Store or remove the host for this tab.
// Removing on close unmounts the panel so SFTP sessions are cleaned up.
setSftpHostForTab(prev => {
const next = new Map(prev);
if (isClosing) {
next.delete(tabId);
} else {
next.set(tabId, host);
}
return next;
});
setSftpInitialLocationForTab(prev => {
const next = new Map(prev);
if (initialPath) {
next.set(tabId, { hostId: host.id, path: initialPath });
} else {
next.delete(tabId);
}
return next;
});
setSftpPendingUploadsForTab(prev => {
const next = new Map(prev);
if (isClosing || !pendingUploadEntries?.length) {
// Clear any stale pending upload on close or when opening without new files
next.delete(tabId);
} else {
next.set(tabId, {
requestId: crypto.randomUUID(),
hostId: host.id,
connectionKey: buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username),
targetPath: initialPath,
entries: pendingUploadEntries,
});
}
return next;
});
}, []);
const handlePendingUploadHandled = useCallback((tabId: string, requestId: string) => {
setSftpPendingUploadsForTab(prev => {
const current = prev.get(tabId);
if (!current || current.requestId !== requestId) {
return prev;
}
const next = new Map(prev);
next.delete(tabId);
return next;
});
}, []);
const handleSftpInitialLocationApplied = useCallback((tabId: string, location: { hostId: string; path: string }) => {
setSftpInitialLocationForTab(prev => {
const current = prev.get(tabId);
if (!current || current.hostId !== location.hostId || current.path !== location.path) {
return prev;
}
const next = new Map(prev);
next.delete(tabId);
return next;
});
}, []);
// Pre-compute host lookup map for O(1) access
const hostMap = useMemo(() => {
const map = new Map<string, Host>();
for (const h of hosts) map.set(h.id, h);
return map;
}, [hosts]);
const hostMapRef = useRef(hostMap);
hostMapRef.current = hostMap;
const proxyProfileIdSet = useMemo(
() => new Set(proxyProfiles.map((profile) => profile.id)),
[proxyProfiles],
);
const effectiveHosts = useMemo(
() => hosts.map((host) => {
const groupDefaults = host.group
? resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet })
: {};
return materializeHostProxyProfile(
applyGroupDefaults(host, groupDefaults, { validProxyProfileIds: proxyProfileIdSet }),
proxyProfiles,
);
}),
[groupConfigs, hosts, proxyProfileIdSet, proxyProfiles],
);
// Pre-compute fallback hosts to avoid creating new objects on every render
const sessionHostsMap = useMemo(() => {
const map = new Map<string, Host>();
for (const session of sessions) {
const rawHost = hostMap.get(session.hostId);
if (rawHost) {
// Apply group config defaults so Terminal sees the merged host
const groupDefaults = rawHost.group
? resolveGroupDefaults(rawHost.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet })
: {};
const existingHost = materializeHostProxyProfile(
applyGroupDefaults(rawHost, groupDefaults, { validProxyProfileIds: proxyProfileIdSet }),
proxyProfiles,
);
const protocol = session.protocol ?? existingHost.protocol;
const port = session.port ?? existingHost.port;
const moshEnabled = session.moshEnabled ?? existingHost.moshEnabled;
const etEnabled = session.etEnabled ?? existingHost.etEnabled;
let hostForSession: Host;
if (
protocol === existingHost.protocol &&
port === existingHost.port &&
moshEnabled === existingHost.moshEnabled
&& etEnabled === existingHost.etEnabled
) {
hostForSession = existingHost;
} else {
hostForSession = {
...existingHost,
protocol,
port,
moshEnabled,
etEnabled,
};
}
map.set(session.id, applySessionFontSizeToHost(hostForSession, session));
} else {
// Create stable fallback host object
const fallbackProtocol = session.protocol ?? 'local' as const;
const fallbackHost: Host = {
id: session.hostId,
label: session.hostLabel || 'Local Terminal',
hostname: session.hostname || 'localhost',
username: session.username || 'local',
port: session.port ?? 22,
// Only local terminals adopt the client OS — unsaved serial
// sessions and orphaned remote sessions (whose host was deleted
// while the session lives on) also hit this fallback, and the
// non-local autocomplete path in Terminal.tsx trusts host.os, so
// a Windows-client 'windows' tag here would mis-shape POSIX
// remote/serial autocomplete (#1112 review).
os: fallbackProtocol === 'local'
? detectLocalOs(navigator.userAgent || navigator.platform)
: 'linux',
group: '',
tags: [],
protocol: fallbackProtocol,
moshEnabled: session.moshEnabled,
etEnabled: session.etEnabled,
charset: session.charset,
localShell: session.localShell,
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
};
map.set(session.id, applySessionFontSizeToHost(fallbackHost, session));
}
}
return map;
}, [sessions, hostMap, groupConfigs, proxyProfileIdSet, proxyProfiles]);
const sessionChainHostsMap = useMemo(() => {
const map = new Map<string, Host[]>();
for (const session of sessions) {
const host = sessionHostsMap.get(session.id);
if (!host?.hostChain?.hostIds?.length) continue;
map.set(
session.id,
host.hostChain.hostIds
.map((hostId) => {
const rawChainHost = hostMap.get(hostId);
if (!rawChainHost) return undefined;
const chainGroupDefaults = rawChainHost.group
? resolveGroupDefaults(rawChainHost.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet })
: {};
return materializeHostProxyProfile(
applyGroupDefaults(rawChainHost, chainGroupDefaults, { validProxyProfileIds: proxyProfileIdSet }),
proxyProfiles,
);
})
.filter((value): value is Host => Boolean(value)),
);
}
return map;
}, [sessions, sessionHostsMap, hostMap, groupConfigs, proxyProfileIdSet, proxyProfiles]);
const sessionHostsMapRef = useRef(sessionHostsMap);
sessionHostsMapRef.current = sessionHostsMap;
const handleCommandSubmitted = useCallback((_command: string, _hostId: string, _hostLabel: string, sessionId: string) => {
const tabId = activeTabIdRef.current;
if (!sftpFollowTerminalCwdRef.current || !tabId || sidePanelOpenTabsRef.current.get(tabId) !== 'sftp') return;
const session = sessionsRef.current.find((candidate) => candidate.id === sessionId);
if (!session || !canReuseTerminalConnection(session)) return;
const revisionAtCommand = terminalCwdRevisionRef.current;
const probeGeneration = (cwdProbeGenerationRef.current.get(sessionId) ?? 0) + 1;
cwdProbeGenerationRef.current.set(sessionId, probeGeneration);
cwdProbeCancelersRef.current.get(sessionId)?.();
const cancelProbe = scheduleBackendCwdProbeAfterCommand({
sessionId,
cwdRevisionAtCommand: revisionAtCommand,
getCwdRevision: () => terminalCwdRevisionRef.current,
getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options),
canProbe: async () => {
if (cwdProbeGenerationRef.current.get(sessionId) !== probeGeneration) return false;
const host = sessionHostsMapRef.current.get(sessionId);
if (!host) return false;
const detectedDeviceClass = classifyDistroId(host.distro);
const isNetworkDevice =
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
const info = await terminalBackend.getSessionRemoteInfo?.(sessionId);
return shouldProbeSessionCwd({
isNetworkDevice,
remoteSshVersion: info?.remoteSshVersion,
});
},
onProbedCwd: (cwd) => {
if (cwdProbeGenerationRef.current.get(sessionId) !== probeGeneration) return;
const existing = terminalRendererCwdBySessionRef.current.get(sessionId);
if (existing === cwd) return;
handleTerminalCwdChange(sessionId, cwd);
},
});
cwdProbeCancelersRef.current.set(sessionId, cancelProbe);
}, [handleTerminalCwdChange, terminalBackend]);
const handleCommandExecuted = useCallback((command: string, hostId: string, hostLabel: string, sessionId: string) => {
onCommandExecuted?.(command, hostId, hostLabel, sessionId);
}, [onCommandExecuted]);
useEffect(() => () => {
for (const cancel of cwdProbeCancelersRef.current.values()) {
cancel();
}
cwdProbeCancelersRef.current.clear();
}, []);
const sessionSudoAutofillPasswordsMap = useMemo(() => {
const map = new Map<string, string | undefined>();
for (const session of sessions) {
const rawHost = hostMap.get(session.hostId);
if (rawHost) {
// Resolve through identity references too (host.identityId), not just
// host.password, so a password stored in a Keychain identity is filled
// (issue #1284) — same resolution SSH login uses.
map.set(session.id, resolveHostAutofillPassword({ host: rawHost, keys, identities }));
}
}
return map;
}, [hostMap, sessions, keys, identities]);
const handleTerminalFontSizeChange = useCallback((sessionId: string, nextFontSize: number) => {
const session = sessionsRef.current.find((candidate) => candidate.id === sessionId);
// Workspace panes keep per-session font size so zooming one split does not
// change global defaults or sibling panes (even when they share a host).
if (session?.workspaceId) {
onUpdateSessionFontSize?.(sessionId, nextFontSize);
return;
}
const sessionHost = sessionHostsMapRef.current.get(sessionId);
if (!sessionHost) return;
const rawHost = hostMapRef.current.get(sessionHost.id);
const usesGlobalFontSize = sessionHost.protocol === 'local' || sessionHost.id?.startsWith('local-') || !rawHost;
if (usesGlobalFontSize) {
onUpdateTerminalFontSize?.(nextFontSize);
return;
}
onUpdateHost({ ...rawHost, fontSize: nextFontSize, fontSizeOverride: true });
}, [onUpdateHost, onUpdateSessionFontSize, onUpdateTerminalFontSize]);
const validAIScopeTargetIds = useMemo(() => {
const ids = new Set<string>();
for (const session of sessions) ids.add(session.id);
for (const workspace of workspaces) ids.add(workspace.id);
return ids;
}, [sessions, workspaces]);
const validSessionActivityIds = useMemo(() => {
return getValidSessionActivityIds(sessions);
}, [sessions]);
const activityTrackedSessions = useMemo(
() =>
sessions.filter(
(session) => session.status !== 'disconnected',
),
[sessions],
);
const onSplitSessionRef = useRef(onSplitSession);
onSplitSessionRef.current = onSplitSession;
const splitHorizontalHandlersRef = useRef<Map<string, () => void>>(new Map());
const splitVerticalHandlersRef = useRef<Map<string, () => void>>(new Map());
const onToggleWorkspaceViewModeRef = useRef(onToggleWorkspaceViewMode);
onToggleWorkspaceViewModeRef.current = onToggleWorkspaceViewMode;
const workspaceFocusHandlersRef = useRef<Map<string, () => void>>(new Map());
const onToggleBroadcastRef = useRef(onToggleBroadcast);
onToggleBroadcastRef.current = onToggleBroadcast;
const workspaceBroadcastHandlersRef = useRef<Map<string, () => void>>(new Map());
const mountedSftpTabIds = useMemo(
() => Array.from(sftpHostForTab.keys()),
[sftpHostForTab],
);
const markSidePanelSubTabOpened = useCallback((tabId: string, panel: SidePanelTab) => {
if (panel === 'ai') {
setAiMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
return;
}
if (panel === 'scripts') {
setScriptsMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
return;
}
if (panel === 'theme') {
setThemeMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
return;
}
if (panel === 'system') {
setSystemMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
}
}, []);
const getActiveTerminalSessionId = useCallback((): string | null => {
const activeWorkspace = activeWorkspaceRef.current;
const activeSession = activeSessionRef.current;
if (!activeWorkspace) return activeSession?.id ?? null;
const workspaceSessionIdSet = new Set(collectSessionIds(activeWorkspace.root));
const focusedId = activeWorkspace.focusedSessionId;
if (focusedId && workspaceSessionIdSet.has(focusedId) && sessionsRef.current.some((session) => session.id === focusedId)) {
return focusedId;
}
return sessionsRef.current.find((session) => workspaceSessionIdSet.has(session.id))?.id ?? null;
}, []);
const syncWorkspaceFocusIfNeeded = useCallback((sessionId: string | null) => {
const activeWorkspace = activeWorkspaceRef.current;
if (!activeWorkspace || !sessionId || activeWorkspace.focusedSessionId === sessionId) return;
onSetWorkspaceFocusedSession?.(activeWorkspace.id, sessionId);
}, [onSetWorkspaceFocusedSession]);
// Get the focused terminal's current working directory
const getTerminalCwd = useCallback(async (options?: { preferFreshBackend?: boolean }): Promise<string | null> => {
const sessionId = getActiveTerminalSessionId();
return resolvePreferredTerminalCwd({
rendererCwd: sessionId ? terminalRendererCwdBySessionRef.current.get(sessionId) : undefined,
sessionId,
getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options),
preferFreshBackend: options?.preferFreshBackend,
});
}, [getActiveTerminalSessionId, terminalBackend]);
const refocusTerminalSession = useCallback((sessionId?: string | null) => {
focusTerminalSessionInput(sessionId);
}, []);
const refocusActiveTerminalSession = useCallback(() => {
const sessionId = getActiveTerminalSessionId();
syncWorkspaceFocusIfNeeded(sessionId);
refocusTerminalSession(sessionId);
}, [getActiveTerminalSessionId, refocusTerminalSession, syncWorkspaceFocusIfNeeded]);
// Close the entire side panel for the current tab
const handleCloseSidePanel = useCallback(() => {
const activeTabId = activeTabIdRef.current;
if (!activeTabId) return;
const sessionIdToRefocus = getActiveTerminalSessionId();
syncWorkspaceFocusIfNeeded(sessionIdToRefocus);
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.delete(activeTabId);
return next;
});
// Always clean up SFTP state (it may be mounted in the background
// while scripts/theme tab was active)
setSftpHostForTab(prev => {
const next = new Map(prev);
next.delete(activeTabId);
return next;
});
setSftpPendingUploadsForTab(prev => {
const next = new Map(prev);
next.delete(activeTabId);
return next;
});
setSftpInitialLocationForTab(prev => {
const next = new Map(prev);
next.delete(activeTabId);
return next;
});
setAiMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
setScriptsMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
setThemeMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
setSystemMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
refocusTerminalSession(sessionIdToRefocus);
}, [getActiveTerminalSessionId, refocusTerminalSession, syncWorkspaceFocusIfNeeded]);
// Resolve the SFTP host for a tab: a previously-stored host, otherwise the
// host of the workspace's focused session or the active session. null = none.
const resolveSftpHostForTab = useCallback((tabId: string): Host | null => {
const stored = sftpHostForTabRef.current.get(tabId);
if (stored) return stored;
const currentWorkspace = activeWorkspaceRef.current;
const currentFocusedSessionId = focusedSessionIdRef.current;
const currentActiveSession = activeSessionRef.current;
const currentSessionHosts = sessionHostsMapRef.current;
if (currentWorkspace && currentFocusedSessionId) {
return currentSessionHosts.get(currentFocusedSessionId) ?? null;
}
if (currentActiveSession) {
return currentSessionHosts.get(currentActiveSession.id) ?? null;
}
return null;
}, []);
// Switch side panel to a specific tab (or toggle if already on that tab)
const handleSwitchSidePanelTab = useCallback((tab: SidePanelTab) => {
const tabId = activeTabIdRef.current;
if (!tabId) return;
const currentPanel = sidePanelOpenTabsRef.current.get(tabId);
// If already on this tab, do nothing — user must click X to close
if (currentPanel === tab) return;
// If switching to SFTP and no host is stored yet, resolve it
if (tab === 'sftp' && !sftpHostForTabRef.current.has(tabId)) {
const host = resolveSftpHostForTab(tabId);
if (!host) return;
setSftpHostForTab(prev => {
const next = new Map(prev);
next.set(tabId, host);
return next;
});
}
// Note: When switching away from SFTP, we keep the SFTP host state
// so the SftpSidePanel stays mounted (hidden) and preserves connections.
// SFTP state is only cleaned up when the panel is fully closed.
markSidePanelSubTabOpened(tabId, tab);
startTransition(() => {
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.set(tabId, tab);
return next;
});
});
}, [markSidePanelSubTabOpened, resolveSftpHostForTab]);
// Toggle SFTP from activity bar header
const handleToggleSftpFromBar = useCallback(() => {
handleSwitchSidePanelTab('sftp');
}, [handleSwitchSidePanelTab]);
// Open scripts side panel (called from Terminal toolbar)
const handleOpenScripts = useCallback(() => {
handleSwitchSidePanelTab('scripts');
}, [handleSwitchSidePanelTab]);
const handleToggleScriptsSidePanel = useCallback(() => {
const tabId = activeTabIdRef.current;
if (!tabId) return;
const intent = resolveScriptsSidePanelShortcutIntent(
sidePanelOpenTabsRef.current.get(tabId) ?? null,
);
if (intent.kind === 'closeTerminalSidePanel') {
handleCloseSidePanel();
return;
}
markSidePanelSubTabOpened(tabId, 'scripts');
startTransition(() => {
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.set(tabId, 'scripts');
return next;
});
});
}, [handleCloseSidePanel, markSidePanelSubTabOpened]);
// Toggle the whole side panel (new ⌘/Ctrl+\ shortcut). Close if open; if
// closed, reopen the tab's last sub-panel, defaulting to SFTP (when a host is
// available) or scripts.
const handleToggleSidePanel = useCallback(() => {
const tabId = activeTabIdRef.current;
if (!tabId) return;
const isOpen = sidePanelOpenTabsRef.current.has(tabId);
const sftpAvailable = !!resolveSftpHostForTab(tabId);
const fallbackTab: SidePanelTab = sftpAvailable ? 'sftp' : 'scripts';
const lastTab = lastSidePanelTabRef.current.get(tabId) ?? null;
const intent = resolveSidePanelToggleIntent<SidePanelTab>({ isOpen, lastTab, fallbackTab });
if (intent.kind === 'close') {
handleCloseSidePanel();
return;
}
// If the remembered panel is SFTP but no host is resolvable, use scripts.
const target: SidePanelTab = intent.tab === 'sftp' && !sftpAvailable ? 'scripts' : intent.tab;
handleSwitchSidePanelTab(target);
}, [handleCloseSidePanel, handleSwitchSidePanelTab, resolveSftpHostForTab]);
// Open theme side panel (called from Terminal toolbar)
const handleOpenTheme = useCallback(() => {
handleSwitchSidePanelTab('theme');
}, [handleSwitchSidePanelTab]);
const handleOpenHistory = useCallback(() => {
handleSwitchSidePanelTab('history');
}, [handleSwitchSidePanelTab]);
// Open AI chat side panel (side-panel rail button: a plain switch that is a
// no-op when AI is already the active sub-panel, matching the other rail tabs)
const handleOpenAI = useCallback(() => {
handleSwitchSidePanelTab('ai');
}, [handleSwitchSidePanelTab]);
const handleOpenSystem = useCallback(() => {
handleSwitchSidePanelTab('system');
}, [handleSwitchSidePanelTab]);
const handleAddSelectionToAI = useCallback((sourceSessionId: string, selection: string) => {
const text = selection.trim();
if (!text) return;
const tabId = activeTabIdRef.current;
if (!tabId) return;
const ws = activeWorkspaceRef.current;
if (ws && ws.focusedSessionId !== sourceSessionId) {
onSetWorkspaceFocusedSessionRef.current?.(ws.id, sourceSessionId);
}
setPendingTerminalSelectionForAI({
requestId: crypto.randomUUID(),
tabId,
text,
});
handleSwitchSidePanelTab('ai');
}, [handleSwitchSidePanelTab]);
const handlePendingTerminalSelectionConsumed = useCallback((requestId: string) => {
setPendingTerminalSelectionForAI((current) => (
current?.requestId === requestId ? null : current
));
}, []);
// Toggle the AI chat side panel from the top-bar button: open it (or switch
// to it from another sub-panel), and close the side panel when AI is already
// the open sub-panel. Unlike handleOpenAI (the rail switch), a second click
// here dismisses the panel.
const handleToggleAiFromTopBar = useCallback(() => {
const tabId = activeTabIdRef.current;
if (!tabId) return;
const intent = resolveAiSidePanelToggleIntent(
sidePanelOpenTabsRef.current.get(tabId) ?? null,
);
if (intent.kind === 'closeTerminalSidePanel') {
handleCloseSidePanel();
return;
}
handleSwitchSidePanelTab('ai');
}, [handleCloseSidePanel, handleSwitchSidePanelTab]);
// Execute snippet on the focused terminal session
const handleSnippetClickForFocusedSession = useCallback((command: string, noAutoRun?: boolean) => {
const sessionId = activeWorkspaceRef.current?.focusedSessionId ?? activeSessionRef.current?.id;
if (!sessionId) return;
const executor = snippetExecutorsRef.current.get(sessionId);
if (executor) {
executor(command, noAutoRun);
return;
}
let data = normalizeLineEndings(command);
if (!noAutoRun) data = `${data}\r`;
terminalBackend.writeToSession(sessionId, data);
// Re-focus the terminal so the user can interact immediately
const pane = document.querySelector(`[data-session-id="${sessionId}"]`);
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
textarea?.focus();
}, [terminalBackend]);
const remoteHistory = useRemoteHistoryState();
const handleHistoryPaste = useCallback(
(command: string) => handleSnippetClickForFocusedSession(command, true),
[handleSnippetClickForFocusedSession],
);
const handleHistoryRun = useCallback(
(command: string) => handleSnippetClickForFocusedSession(command, false),
[handleSnippetClickForFocusedSession],
);
const handleSnippetFromPanel = useCallback(async (snippet: Snippet) => {
const command = await resolveSnippetCommand(snippet);
if (command === null) return;
handleSnippetClickForFocusedSession(command, snippet.noAutoRun);
}, [handleSnippetClickForFocusedSession]);
const handleComposeSend = useCallback((text: string) => {
const activeWorkspace = activeWorkspaceRef.current;
if (!activeWorkspace) return;
const payload = text + '\r';
const broadcastEnabled = isBroadcastEnabled?.(activeWorkspace.id);
const focusedSessionId = activeWorkspace.focusedSessionId;
if (broadcastEnabled) {
const allSessionIds = sessionsRef.current
.filter((session) => session.workspaceId === activeWorkspace.id)
.map((session) => session.id);
for (const sid of allSessionIds) {
const executor = snippetExecutorsRef.current.get(sid);
if (executor) {
executor(text, false, { broadcast: false });
} else {
terminalBackend.writeToSession(sid, payload);
}
}
} else {
const workspaceSessions = sessionsRef.current.filter((session) => session.workspaceId === activeWorkspace.id);
const validFocusedId = focusedSessionId && workspaceSessions.some((session) => session.id === focusedSessionId)
? focusedSessionId
: undefined;
const targetId = validFocusedId ?? workspaceSessions[0]?.id;
if (targetId) {
const executor = snippetExecutorsRef.current.get(targetId);
if (executor) {
executor(text, false);
} else {
terminalBackend.writeToSession(targetId, payload);
}
}
}
}, [isBroadcastEnabled, terminalBackend]);
const sessionLogConfig = useMemo(
() =>
sessionLogsEnabled && sessionLogsDir
? { enabled: true as const, directory: sessionLogsDir, format: sessionLogsFormat || 'txt', timestampsEnabled: sessionLogsTimestampsEnabled }
: undefined,
[sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled],
);
stableRef.current = {
accentMode,
activityTrackedSessions,
AIChatPanelsHost,
AISidePanelStateRoot,
AIStateMaintenanceHost,
AIStateProvider,
Array,
Button,
ChunkedEscapeFilter,
clearTerminalPreviewVars,
clearTopTabsPreviewVars,
FolderTree,
History,
HistorySidePanel,
MessageSquare,
Palette,
PanelLeft,
PanelRight,
cn,
collectSessionIds,
customAccent,
customGroups,
draggingSessionId,
editorWordWrap,
effectiveHosts,
filterTabsMap,
followAppTerminalTheme,
fontSize,
getSessionActivityIdsToClear,
getTerminalCwd,
handleAddKnownHost,
handleAddSelectionToAI,
handleBroadcastInput,
handleCloseSession,
handleCloseSidePanel,
handleCommandExecuted,
handleCommandSubmitted,
handleComposeSend,
handleHistoryPaste,
handleHistoryRun,
handleOpenHistory,
handleOpenSftp,
handleOpenScripts,
handleOpenTheme,
handleOpenAI,
handleOpenSystem,
handleOsDetected,
handlePendingTerminalSelectionConsumed,
handlePendingUploadHandled,
handleSessionExit,
handleSftpInitialLocationApplied,
persistSidePanelWidth,
handleSnippetClickForFocusedSession,
handleSnippetFromPanel,
handleSnippetExecutorChange,
handleStatusChange,
handleTerminalCwdChange,
handleTerminalDataCapture,
handleTerminalFontSizeChange,
handleToggleAiFromTopBar,
handleToggleScriptsSidePanel,
handleToggleSidePanel,
handleToggleSftpFromBar,
handleToggleWorkspaceComposeBar,
handleUpdateHost,
hasNotifiableTerminalOutput,
hostMap,
hosts,
hostsRef,
hotkeyScheme,
disableTerminalFontZoom,
identities,
isBroadcastEnabled,
isComposeBarOpen,
keyBindings,
keys,
knownHosts,
lastSidePanelTabRef,
mountedAiTabIds: aiMountedTabIds,
mountedSftpTabIds,
scriptsMountedTabIds,
systemMountedTabIds,
themeMountedTabIds,
onAddSessionToWorkspace,
onConnectToHost,
onCreateLocalTerminal,
onCreateWorkspaceFromSessions,
onHotkeyAction,
onReorderWorkspaceSessions,
onRequestAddToWorkspace,
onSessionData,
onSetDraggingSessionId,
onSetWorkspaceFocusedSession,
onSplitSession,
onSplitSessionRef,
onToggleBroadcastRef,
onToggleWorkspaceViewMode,
onToggleWorkspaceViewModeRef,
onUpdateHost,
onUpdateSplitSizes,
onUpdateTerminalFontFamilyId,
onUpdateTerminalFontSize,
onUpdateTerminalFontWeight,
onUpdateSessionFontSize,
onClearSessionFontSizeOverride,
onUpdateTerminalThemeId,
pendingTerminalSelectionForAI,
refocusActiveTerminalSession,
refocusTerminalSession,
remoteHistory,
shellHistory,
resolveSftpHostForTab,
ScriptsSidePanel,
sessionActivityStore,
sessionChainHostsMap,
sessionHostsMap,
sessionLogConfig,
sessionSudoAutofillPasswordsMap,
sessions,
sessionsRef,
setEditorWordWrap,
setIsComposeBarOpen,
setPendingTerminalSelectionForAI,
setAiMountedTabIds,
setScriptsMountedTabIds,
setSystemMountedTabIds,
setThemeMountedTabIds,
setSidePanelOpenTabs,
setSidePanelWidth,
setSftpFollowTerminalCwd,
setSftpHostForTab,
setSftpInitialLocationForTab,
setSftpPendingUploadsForTab,
setupMcpApprovalBridge,
showHostTreeSidebar,
sidePanelOpenTabs,
sidePanelPosition,
sidePanelWidth,
sftpAutoSync,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
sftpFollowTerminalCwd,
sftpHostForTab,
sftpInitialLocationForTab,
sftpPendingUploadsForTab,
sftpShowHiddenFiles,
SftpSidePanel,
sftpUseCompressedUpload,
shouldMarkSessionActivity,
snippetExecutorsRef,
snippetPackages,
snippets,
splitHorizontalHandlersRef,
splitVerticalHandlersRef,
sshDebugLogsEnabled,
t,
TerminalComposeBar,
TerminalPanesHost,
terminalCwdRevision,
terminalFontFamilyId,
terminalRendererCwdBySessionRef,
terminalSettings,
terminalTheme,
ThemeSidePanel,
toggleScriptsSidePanelRef,
toggleSidePanelRef,
Tooltip,
TooltipContent,
TooltipTrigger,
updateHosts,
updateSnippetPackages,
updateSnippets,
X,
Zap,
validAIScopeTargetIds,
validSessionActivityIds,
workspaceBroadcastHandlersRef,
workspaceById,
workspaceFocusHandlersRef,
workspaces,
workspacesRef,
activeTabIdRef,
activeWorkspaceRef,
activeSessionRef,
focusedSessionIdRef,
setSidePanelPosition,
};
return <TerminalLayerTabBridge stableRef={stableRef} />;
};
export const TerminalLayer = memo(TerminalLayerInner, terminalLayerAreEqual);
TerminalLayer.displayName = 'TerminalLayer';