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
1199 lines
46 KiB
TypeScript
1199 lines
46 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import type React from 'react';
|
|
import type { Host, HostProtocol, TerminalSession } from '../../types';
|
|
import type { PassphraseRequest } from '../../components/PassphraseModal';
|
|
import type { TerminalPopupPayload } from '../../domain/systemManager/types';
|
|
import { getEffectiveHostDistro, classifyDistroId, shouldProbeSessionCwd } from '../../domain/host';
|
|
import { getAvailablePaneMagnificationController } from '../../domain/paneMagnification';
|
|
import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
|
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
|
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
|
import { tabShortcutDigitFromEvent } from '../../domain/models/keyBindings';
|
|
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
|
import { captureInheritedCwd } from '../state/inheritedCwd';
|
|
|
|
type AppContextGetter = () => Record<string, any>;
|
|
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
|
|
const forwardedNativeShortcutEvents = new WeakSet<KeyboardEvent>();
|
|
|
|
export function markForwardedNativeShortcutEvent(event: KeyboardEvent): KeyboardEvent {
|
|
forwardedNativeShortcutEvents.add(event);
|
|
return event;
|
|
}
|
|
|
|
async function deliverKeyboardInteractiveResponse(
|
|
ctx: Record<string, any>,
|
|
requestId: string,
|
|
responses: string[],
|
|
cancelled: boolean,
|
|
) {
|
|
const { netcattyBridge, t, toast } = ctx;
|
|
const bridge = netcattyBridge.get();
|
|
if (!bridge?.respondKeyboardInteractive) {
|
|
toast.error(t('common.unknownError'), t('common.error'));
|
|
return false;
|
|
}
|
|
try {
|
|
const result = await bridge.respondKeyboardInteractive(requestId, responses, cancelled);
|
|
if (!result?.success) {
|
|
toast.error(result?.error || t('common.unknownError'), t('common.error'));
|
|
return false;
|
|
}
|
|
return true;
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : t('common.unknownError'), t('common.error'));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export const getLogHostVisualSnapshot = (host: Host) => {
|
|
const icon = sanitizeHostIconFields(host);
|
|
return {
|
|
hostOs: host.os,
|
|
hostDistro: getEffectiveHostDistro(host) || undefined,
|
|
hostIconMode: icon.iconMode,
|
|
hostIconId: icon.iconId,
|
|
...(icon.iconColorMode ? { hostIconColorMode: icon.iconColorMode } : {}),
|
|
...(icon.iconColor ? { hostIconColor: icon.iconColor } : {}),
|
|
...(icon.iconColorCustom ? { hostIconColorCustom: icon.iconColorCustom } : {}),
|
|
};
|
|
};
|
|
|
|
/**
|
|
* AI silent sessions stay out of the tab bar. Opening them via tray should
|
|
* attach a terminal-popup window to the same live PTY (same sessionId) instead
|
|
* of activating them as a main-window tab (tab-less terminal surface) or
|
|
* spawning a new shell via connection reuse.
|
|
*/
|
|
export function buildAiSilentSessionPopupPayload(session: TerminalSession): TerminalPopupPayload {
|
|
const { hiddenFromTabs: _hiddenFromTabs, ...sourceSession } = session;
|
|
return {
|
|
title: session.hostLabel || 'Terminal',
|
|
parentSessionId: session.id,
|
|
startupCommand: '',
|
|
attachSessionId: session.id,
|
|
sourceSession: {
|
|
...sourceSession,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function handleTrayJumpToSessionImpl(getCtx: AppContextGetter, sessionId: string) {
|
|
const {
|
|
sessions,
|
|
setActiveTabId,
|
|
setWorkspaceFocusedSession,
|
|
netcattyBridge,
|
|
toast,
|
|
t,
|
|
} = getCtx();
|
|
const session = sessions.find((item: TerminalSession) => item.id === sessionId);
|
|
if (!session) return;
|
|
|
|
if (session.hiddenFromTabs) {
|
|
// Leave the main surface if this silent session was already activated
|
|
// (legacy jump path left a terminal with no tab chrome).
|
|
const getActiveTabId = getCtx().getActiveTabId as (() => string) | undefined;
|
|
if (!getActiveTabId || getActiveTabId() === sessionId) {
|
|
setActiveTabId('vault');
|
|
}
|
|
|
|
const bridge = netcattyBridge?.get?.();
|
|
if (!bridge?.openTerminalPopup) {
|
|
toast?.error?.(t?.('tabs.copyTabToNewWindowFailed') ?? 'Failed to open tab in a new window');
|
|
return;
|
|
}
|
|
try {
|
|
const result = await bridge.openTerminalPopup(buildAiSilentSessionPopupPayload(session));
|
|
if (!result?.success) {
|
|
toast?.error?.(
|
|
result?.error
|
|
|| t?.('tabs.copyTabToNewWindowFailed')
|
|
|| 'Failed to open tab in a new window',
|
|
);
|
|
}
|
|
} catch (err) {
|
|
toast?.error?.(
|
|
err instanceof Error
|
|
? err.message
|
|
: (t?.('tabs.copyTabToNewWindowFailed') ?? 'Failed to open tab in a new window'),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Visible sessions still live in the main window; bring it forward now that
|
|
// the tray jump IPC no longer auto-focuses main (silent AI sessions open a
|
|
// popup instead and must not steal main-window focus).
|
|
void netcattyBridge?.get?.()?.openMainWindow?.();
|
|
|
|
if (session.workspaceId) {
|
|
setActiveTabId(session.workspaceId);
|
|
setWorkspaceFocusedSession(session.workspaceId, sessionId);
|
|
return;
|
|
}
|
|
setActiveTabId(sessionId);
|
|
}
|
|
|
|
export function handleTrayTogglePortForwardImpl(getCtx: AppContextGetter, ruleId: string, start: boolean) {
|
|
const { hasRuntimeTunnel, hosts, identities, keys, knownHosts, portForwardingRules, resolveEffectiveHost, startTunnel, stopTunnel, t, terminalSettings, toast } = getCtx();
|
|
{
|
|
const rule = portForwardingRules.find((item) => item.id === ruleId);
|
|
if (!rule) return;
|
|
const host = rule.hostId ? hosts.find((item) => item.id === rule.hostId) : undefined;
|
|
if (!host) {
|
|
toast.error(t("pf.error.hostNotFound"));
|
|
return;
|
|
}
|
|
|
|
if (start) {
|
|
if (!hasRuntimeTunnel(ruleId)) {
|
|
const effectiveHost = resolveEffectiveHost(host);
|
|
void startTunnel(rule, effectiveHost, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
|
|
if (status === "error" && error) toast.error(error);
|
|
}, rule.autoStart, terminalSettings, knownHosts);
|
|
}
|
|
return;
|
|
}
|
|
|
|
void stopTunnel(ruleId).then((result) => {
|
|
if (!result.success && result.error) toast.error(result.error);
|
|
});
|
|
}
|
|
}
|
|
|
|
export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: string) {
|
|
const { addConnectionLog, connectToHost, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast } = getCtx();
|
|
{
|
|
const host = hosts.find((item) => item.id === hostId);
|
|
if (!host) {
|
|
toast.error(t("pf.error.hostNotFound"));
|
|
return;
|
|
}
|
|
|
|
const effectiveHost = resolveEffectiveHost(host);
|
|
|
|
const { username, hostname: localHost } = systemInfoRef.current;
|
|
if (effectiveHost.protocol === 'serial') {
|
|
const portName = host.hostname.split('/').pop() || host.hostname;
|
|
const sessionId = connectToHost(effectiveHost);
|
|
addConnectionLog({
|
|
sessionId,
|
|
hostId: host.id,
|
|
hostLabel: host.label || `Serial: ${portName}`,
|
|
hostname: host.hostname,
|
|
username,
|
|
protocol: 'serial',
|
|
...getLogHostVisualSnapshot(effectiveHost),
|
|
startTime: Date.now(),
|
|
localUsername: username,
|
|
localHostname: localHost,
|
|
saved: false,
|
|
});
|
|
return sessionId;
|
|
}
|
|
|
|
const protocol = resolveEffectiveTerminalProtocol(effectiveHost);
|
|
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
|
|
const sessionId = connectToHost(effectiveHost);
|
|
addConnectionLog({
|
|
sessionId,
|
|
hostId: host.id,
|
|
hostLabel: host.label,
|
|
hostname: host.hostname,
|
|
username: resolvedAuth.username || 'root',
|
|
protocol,
|
|
...getLogHostVisualSnapshot(effectiveHost),
|
|
startTime: Date.now(),
|
|
localUsername: username,
|
|
localHostname: localHost,
|
|
saved: false,
|
|
});
|
|
return sessionId;
|
|
}
|
|
}
|
|
|
|
export function handleTrayPanelConnectRequestImpl(getCtx: AppContextGetter, hostId: string) {
|
|
const { connectNow, isVaultInitialized, queueConnect } = getCtx();
|
|
if (!hostId) return;
|
|
if (!isVaultInitialized) {
|
|
queueConnect(hostId);
|
|
return;
|
|
}
|
|
connectNow(hostId);
|
|
}
|
|
|
|
export function flushQueuedTrayPanelConnectHostsImpl(getCtx: AppContextGetter) {
|
|
const { connectNow, pendingHostIds, setPendingHostIds } = getCtx();
|
|
if (!Array.isArray(pendingHostIds) || pendingHostIds.length === 0) return;
|
|
const hostIds = [...pendingHostIds];
|
|
setPendingHostIds([]);
|
|
hostIds.forEach((hostId: string) => connectNow(hostId));
|
|
}
|
|
|
|
export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEvent) {
|
|
const { HOTKEY_DEBUG, closeTabKeyStr, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding } = getCtx();
|
|
{
|
|
const isMac = hotkeyScheme === 'mac';
|
|
const target = e.target as HTMLElement;
|
|
const isCloseTabHotkey = closeTabKeyStr ? matchesKeyBinding(e, closeTabKeyStr, isMac) : false;
|
|
const dialogHotkeyScope = target.closest?.('[data-hotkey-close-tab="true"]');
|
|
|
|
if (isCloseTabHotkey && dialogHotkeyScope) {
|
|
return;
|
|
}
|
|
|
|
if (isCloseTabHotkey) {
|
|
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
|
|
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
|
|
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
|
|
if (topmostDialogClose) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
topmostDialogClose.click();
|
|
return;
|
|
}
|
|
}
|
|
|
|
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
|
|
const isMonacoElement =
|
|
target instanceof HTMLElement &&
|
|
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
|
|
const isXtermInput =
|
|
target instanceof HTMLElement &&
|
|
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
|
|
|
|
const quickSwitchBinding = keyBindings.find((binding) => binding.action === 'quickSwitch');
|
|
const quickSwitchKeyStr = quickSwitchBinding ? (isMac ? quickSwitchBinding.mac : quickSwitchBinding.pc) : null;
|
|
const isQuickSwitchHotkey = quickSwitchKeyStr ? matchesKeyBinding(e, quickSwitchKeyStr, isMac) : false;
|
|
const paneZoomBinding = keyBindings.find((binding) => binding.action === 'togglePaneZoom');
|
|
const paneZoomKeyStr = paneZoomBinding ? (isMac ? paneZoomBinding.mac : paneZoomBinding.pc) : null;
|
|
const isPaneZoomHotkey = paneZoomKeyStr ? matchesKeyBinding(e, paneZoomKeyStr, isMac) : false;
|
|
|
|
if (
|
|
(isFormElement || isMonacoElement)
|
|
&& !isXtermInput
|
|
&& e.key !== 'Escape'
|
|
&& !isQuickSwitchHotkey
|
|
&& !isPaneZoomHotkey
|
|
&& !forwardedNativeShortcutEvents.has(e)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const isTerminalElement =
|
|
target instanceof HTMLElement &&
|
|
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
|
|
const isTerminalInPath = Boolean(
|
|
e.composedPath?.().some(
|
|
(node) =>
|
|
node instanceof HTMLElement &&
|
|
(node.classList.contains("xterm") ||
|
|
node.classList.contains("xterm-helper-textarea") ||
|
|
node.classList.contains("xterm-screen") ||
|
|
node.classList.contains("xterm-viewport") ||
|
|
node.hasAttribute("data-session-id")),
|
|
),
|
|
);
|
|
|
|
for (const binding of keyBindings) {
|
|
const keyStr = isMac ? binding.mac : binding.pc;
|
|
if (!matchesKeyBinding(e, keyStr, isMac)) continue;
|
|
if (HOTKEY_DEBUG) console.log('[Hotkeys] Matched binding:', binding.action, keyStr);
|
|
if (binding.category === 'sftp') {
|
|
continue;
|
|
}
|
|
if (TERMINAL_PASSTHROUGH_ACTIONS.has(binding.action)) {
|
|
if (isTerminalElement) {
|
|
return;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (HOTKEY_DEBUG) {
|
|
console.log('[Hotkeys] Global handle', {
|
|
action: binding.action,
|
|
key: e.key,
|
|
meta: e.metaKey,
|
|
ctrl: e.ctrlKey,
|
|
alt: e.altKey,
|
|
shift: e.shiftKey,
|
|
targetTag: target?.tagName,
|
|
isTerminalElement,
|
|
isTerminalInPath,
|
|
});
|
|
}
|
|
executeHotkeyAction(binding.action, e);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
export function handleEscapeKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEvent) {
|
|
const {
|
|
isQuickSwitcherOpen,
|
|
setIsQuickSwitcherOpen,
|
|
sftpPaneMagnificationRef,
|
|
terminalPaneMagnificationRef,
|
|
} = getCtx();
|
|
{
|
|
if (e.key !== 'Escape' || e.defaultPrevented) return;
|
|
if (isQuickSwitcherOpen) {
|
|
setIsQuickSwitcherOpen(false);
|
|
return;
|
|
}
|
|
if (getAvailablePaneMagnificationController([
|
|
sftpPaneMagnificationRef?.current,
|
|
terminalPaneMagnificationRef?.current,
|
|
])?.restore()) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function handleKeyboardInteractiveSubmitImpl(
|
|
getCtx: AppContextGetter,
|
|
requestId: string,
|
|
responses: string[],
|
|
savePassword?: string,
|
|
) {
|
|
const ctx = getCtx();
|
|
const {
|
|
hosts,
|
|
hostsRef,
|
|
keyboardInteractiveQueue,
|
|
sessions,
|
|
setKeyboardInteractiveQueue,
|
|
updateHosts,
|
|
} = ctx;
|
|
{
|
|
if (!await deliverKeyboardInteractiveResponse(ctx, requestId, responses, false)) return false;
|
|
const request = keyboardInteractiveQueue.find(r => r.requestId === requestId);
|
|
const session = request?.sessionId
|
|
? sessions.find(s => s.id === request.sessionId)
|
|
: undefined;
|
|
const explicitHostId = typeof request?.hostId === "string" ? request.hostId : undefined;
|
|
const canUseExplicitHost = !!explicitHostId;
|
|
// Only mutate the destination host when the prompting hostname matches —
|
|
// jump-host challenges without an explicit hostId must not rewrite the target host.
|
|
const canUpdateDestinationHost = !!(
|
|
session?.hostId
|
|
&& (!request?.hostname || request.hostname === session.hostname)
|
|
);
|
|
const hostIdToUpdate = canUseExplicitHost
|
|
? explicitHostId
|
|
: canUpdateDestinationHost
|
|
? session.hostId
|
|
: undefined;
|
|
const latestHosts = hostsRef?.current ?? hosts;
|
|
const host = hostIdToUpdate
|
|
? latestHosts.find((h: Host) => h.id === hostIdToUpdate)
|
|
: undefined;
|
|
// Save password to host if requested - never for second-factor / EDR prompts
|
|
// (allowSavePassword === false) so a secondary secret cannot overwrite the
|
|
// host login password (#2150 / Codex review on #2151).
|
|
if (savePassword && host && request?.allowSavePassword !== false) {
|
|
updateHosts(latestHosts.map((h: Host) => h.id === host.id ? {
|
|
...h,
|
|
password: savePassword,
|
|
savePassword: true,
|
|
} : h));
|
|
}
|
|
// Remove from queue by requestId
|
|
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
|
|
return true;
|
|
}
|
|
}
|
|
|
|
export async function handleKeyboardInteractiveCancelImpl(getCtx: AppContextGetter, requestId: string) {
|
|
const ctx = getCtx();
|
|
const { setKeyboardInteractiveQueue } = ctx;
|
|
{
|
|
if (!await deliverKeyboardInteractiveResponse(ctx, requestId, [], true)) return false;
|
|
// Remove from queue by requestId
|
|
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
|
|
return true;
|
|
}
|
|
}
|
|
|
|
export async function handlePassphraseSubmitImpl(getCtx: AppContextGetter, requestId: string, passphrase: string, remember: boolean) {
|
|
const { keysRef, netcattyBridge, passphraseQueue, rememberKeyPassphrase, setPassphraseQueue, updateKeys } = getCtx();
|
|
{
|
|
const bridge = netcattyBridge.get();
|
|
const request = passphraseQueue.find((r: PassphraseRequest) => r.requestId === requestId);
|
|
|
|
// Save passphrase if requested
|
|
if (remember && request?.keyPath) {
|
|
console.log('[App] Saving passphrase for:', request.keyPath);
|
|
try {
|
|
await rememberKeyPassphrase({
|
|
keyPath: request.keyPath,
|
|
passphrase,
|
|
keys: keysRef.current,
|
|
updateKeys,
|
|
setCurrentKeys: (updated) => {
|
|
keysRef.current = updated;
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.warn('[App] Failed to save passphrase:', err);
|
|
}
|
|
}
|
|
|
|
if (bridge?.respondPassphrase) {
|
|
void bridge.respondPassphrase(requestId, passphrase, false);
|
|
}
|
|
|
|
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
|
|
}
|
|
}
|
|
|
|
export function handlePassphraseCancelImpl(getCtx: AppContextGetter, requestId: string) {
|
|
const { netcattyBridge, setPassphraseQueue } = getCtx();
|
|
{
|
|
const bridge = netcattyBridge.get();
|
|
if (bridge?.respondPassphrase) {
|
|
// Cancel = stop the entire passphrase flow
|
|
void bridge.respondPassphrase(requestId, '', true);
|
|
}
|
|
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
|
|
}
|
|
}
|
|
|
|
export function handlePassphraseSkipImpl(getCtx: AppContextGetter, requestId: string) {
|
|
const { netcattyBridge, setPassphraseQueue } = getCtx();
|
|
{
|
|
const bridge = netcattyBridge.get();
|
|
if (bridge?.respondPassphraseSkip) {
|
|
// Skip = skip this key but continue asking for others
|
|
void bridge.respondPassphraseSkip(requestId);
|
|
} else if (bridge?.respondPassphrase) {
|
|
// Fallback for older API
|
|
void bridge.respondPassphrase(requestId, '', false);
|
|
}
|
|
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
|
|
}
|
|
}
|
|
|
|
export function createLocalTerminalWithCurrentShellImpl(getCtx: AppContextGetter) {
|
|
const { classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, terminalSettings } = getCtx();
|
|
{
|
|
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells, terminalSettings.localShellArgs);
|
|
const matchedShell = discoveredShells.find(s => s.id === terminalSettings.localShell);
|
|
return createLocalTerminal({
|
|
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
|
|
shell: resolved?.command,
|
|
shellArgs: resolved?.args,
|
|
shellName: matchedShell?.name,
|
|
shellIcon: matchedShell?.icon,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function captureCtxInheritedCwd(getCtx: AppContextGetter, sessionId: string): Promise<string | undefined> {
|
|
const { sessions, netcattyBridge, hostById, terminalHosts, getSessionRestoreCwd } = getCtx();
|
|
const source = sessions?.find((s: { id: string }) => s.id === sessionId);
|
|
if (!source) return undefined;
|
|
|
|
// Freshest cwd: the live OSC 7 value tracked in terminal state (the only
|
|
// source that reflects `cd`s in a running local terminal — lastCwd is a
|
|
// startup snapshot). Falls through to the SSH probe / lastCwd when absent.
|
|
const liveCwd: string | undefined = getSessionRestoreCwd?.(sessionId);
|
|
|
|
const bridge = netcattyBridge?.get?.();
|
|
// hostById is a Map of SAVED hosts; ephemeral terminal hosts only appear in
|
|
// terminalHosts. Classify the DETECTED distro (host.distro), not the
|
|
// effective/override value, so a cosmetic Linux icon can't re-enable the
|
|
// probe on a network device (matches the terminal cwd-probe gate).
|
|
const host = hostById?.get?.(source.hostId)
|
|
?? terminalHosts?.find?.((h: { id: string }) => h.id === source.hostId);
|
|
const isNetworkDevice = !!host
|
|
&& (host.deviceType === 'network' || classifyDistroId(host.distro) === 'network-device');
|
|
|
|
// Only probe when the app's own cwd-probe gate would: never for network
|
|
// devices (the extra exec channel can close a Huawei VRP-style session), and
|
|
// for not-yet-classified hosts consult the SSH banner (issue #1043).
|
|
let allowSshProbe = false;
|
|
const isConnectedSsh = (source.protocol === "ssh" || source.protocol === undefined)
|
|
&& source.status === "connected";
|
|
if (!liveCwd && isConnectedSsh && !isNetworkDevice) {
|
|
try {
|
|
const info = await bridge?.getSessionRemoteInfo?.(sessionId);
|
|
allowSshProbe = shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: info?.remoteSshVersion });
|
|
} catch {
|
|
allowSshProbe = false;
|
|
}
|
|
}
|
|
|
|
const probe = async (
|
|
id: string,
|
|
options?: {
|
|
allowHomeFallback?: boolean;
|
|
allowLoginShellFallback?: boolean;
|
|
timeoutMs?: number;
|
|
},
|
|
) =>
|
|
(await bridge?.getSessionPwd?.(id, options)) ?? { success: false };
|
|
return captureInheritedCwd(source, probe, { liveCwd, allowSshProbe });
|
|
}
|
|
|
|
export async function splitSessionWithCurrentShellImpl(getCtx: AppContextGetter, sessionId: string, direction: 'horizontal' | 'vertical') {
|
|
const { classifyLocalShellType, discoveredShells, resolveShellSetting, splitSession, terminalSettings } = getCtx();
|
|
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
|
const inheritedCwd = await captureCtxInheritedCwd(getCtx, sessionId);
|
|
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
|
|
return splitSession(sessionId, direction, {
|
|
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, userAgent),
|
|
inheritedCwd,
|
|
});
|
|
}
|
|
|
|
export async function copySessionWithCurrentShellImpl(
|
|
getCtx: AppContextGetter,
|
|
sessionId: string,
|
|
options?: { reuseConnection?: boolean },
|
|
) {
|
|
const { classifyLocalShellType, copySession, discoveredShells, resolveShellSetting, sessions, terminalSettings } = getCtx();
|
|
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
|
// A fresh remote login may stop at a bastion's host-selection prompt. Do
|
|
// not probe the old target or send its directory command to the new login.
|
|
// Local duplicates still open in the source terminal's current directory.
|
|
const inheritCwd = options?.reuseConnection !== false
|
|
|| sessions?.find((session: { id: string }) => session.id === sessionId)?.protocol === "local";
|
|
const inheritedCwd = inheritCwd ? await captureCtxInheritedCwd(getCtx, sessionId) : undefined;
|
|
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
|
|
return copySession(sessionId, {
|
|
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, userAgent),
|
|
inheritedCwd,
|
|
reuseConnection: options?.reuseConnection,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* "Duplicate session": clone the tab but open a brand-new connection instead of
|
|
* multiplexing over the source's established SSH channel (fresh auth; with a
|
|
* bastion/jump host this restarts the bastion login so the user can pick a new
|
|
* target host).
|
|
*/
|
|
export async function duplicateSessionWithCurrentShellImpl(getCtx: AppContextGetter, sessionId: string) {
|
|
return copySessionWithCurrentShellImpl(getCtx, sessionId, { reuseConnection: false });
|
|
}
|
|
|
|
export async function copyWorkspaceWithCurrentShellImpl(getCtx: AppContextGetter, workspaceId: string) {
|
|
const { classifyLocalShellType, collectSessionIds, copyWorkspace, discoveredShells, resolveShellSetting, terminalSettings, workspaces } = getCtx();
|
|
const workspace = workspaces.find((w: { id: string }) => w.id === workspaceId);
|
|
if (!workspace) return;
|
|
|
|
const sessionIds: string[] = collectSessionIds(workspace.root);
|
|
// Resolve each pane's cwd in parallel — SSH panes may await the /proc probe.
|
|
const entries = await Promise.all(
|
|
sessionIds.map(async (id): Promise<readonly [string, string | undefined]> =>
|
|
[id, await captureCtxInheritedCwd(getCtx, id)] as const),
|
|
);
|
|
const perPaneCwd: Record<string, string | undefined> = Object.fromEntries(entries);
|
|
|
|
// Cwd capture is async, so do not invoke the state action with a workspace
|
|
// that was closed or changed while its panes were being inspected.
|
|
if (getCtx().workspaces.find((candidate: { id: string }) => candidate.id === workspaceId) !== workspace) {
|
|
return;
|
|
}
|
|
|
|
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
|
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
|
|
return copyWorkspace(workspaceId, {
|
|
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, userAgent),
|
|
perPaneCwd,
|
|
});
|
|
}
|
|
|
|
export async function copySessionToNewWindowWithCurrentShellImpl(getCtx: AppContextGetter, sessionId: string) {
|
|
const { classifyLocalShellType, discoveredShells, netcattyBridge, resolveShellSetting, sessions, terminalSettings, t, toast } = getCtx();
|
|
{
|
|
const sourceSession = sessions.find((session: { id: string }) => session.id === sessionId);
|
|
if (!sourceSession) return false;
|
|
|
|
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
|
const bridge = netcattyBridge.get();
|
|
if (!bridge?.openSessionInNewWindow) {
|
|
toast?.error?.(t?.('tabs.copyTabToNewWindowFailed') ?? 'Failed to open tab in a new window');
|
|
return false;
|
|
}
|
|
|
|
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
|
|
try {
|
|
const result = await bridge.openSessionInNewWindow({
|
|
title: sourceSession.hostLabel,
|
|
sourceSession,
|
|
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, userAgent),
|
|
});
|
|
const success = result?.success === true;
|
|
if (!success) toast?.error?.(t?.('tabs.copyTabToNewWindowFailed') ?? 'Failed to open tab in a new window');
|
|
return success;
|
|
} catch {
|
|
toast?.error?.(t?.('tabs.copyTabToNewWindowFailed') ?? 'Failed to open tab in a new window');
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function confirmIfBusyLocalTerminalImpl(getCtx: AppContextGetter, sessionIds: string[]) {
|
|
const { netcattyBridge, sessions, t } = getCtx();
|
|
{
|
|
const bridge = netcattyBridge.get();
|
|
const localIds = sessionIds.filter((id) => {
|
|
const s = sessions.find((x) => x.id === id);
|
|
return s?.protocol === 'local';
|
|
});
|
|
const busyCommands: string[] = [];
|
|
for (const id of localIds) {
|
|
const children = (await bridge?.ptyGetChildProcesses?.(id)) ?? [];
|
|
if (children.length > 0) {
|
|
busyCommands.push(children[0].command);
|
|
}
|
|
}
|
|
if (busyCommands.length === 0) return true;
|
|
|
|
const primary = busyCommands[0];
|
|
const extraCount = busyCommands.length - 1;
|
|
const message =
|
|
extraCount > 0
|
|
? t('confirm.closeBusyTerminal.messageWithMore', {
|
|
command: primary,
|
|
count: extraCount,
|
|
})
|
|
: t('confirm.closeBusyTerminal.message', { command: primary });
|
|
|
|
const ok = await bridge?.confirmCloseBusy?.({
|
|
command: primary,
|
|
title: t('confirm.closeBusyTerminal.title'),
|
|
message,
|
|
cancelLabel: t('confirm.closeBusyTerminal.cancel'),
|
|
closeLabel: t('confirm.closeBusyTerminal.close'),
|
|
});
|
|
return ok === true;
|
|
}
|
|
}
|
|
|
|
export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: string[]) {
|
|
const { closeLogView, closeSessions, closeTabsInFlightRef, closeWorkspace, confirmIfBusyLocalTerminal, logViews, sessions, workspaces } = getCtx();
|
|
{
|
|
if (targetIds.length === 0) return true;
|
|
if (closeTabsInFlightRef.current) return false;
|
|
|
|
// Expand workspace ids into their constituent session ids so the busy
|
|
// probe sees every local shell that's about to be killed.
|
|
const sessionIdsToProbe: string[] = [];
|
|
for (const tabId of targetIds) {
|
|
const ws = workspaces.find((w) => w.id === tabId);
|
|
if (ws) {
|
|
for (const s of sessions) {
|
|
if (s.workspaceId === tabId) sessionIdsToProbe.push(s.id);
|
|
}
|
|
} else if (sessions.find((s) => s.id === tabId)) {
|
|
sessionIdsToProbe.push(tabId);
|
|
}
|
|
}
|
|
|
|
closeTabsInFlightRef.current = true;
|
|
try {
|
|
const ok = await confirmIfBusyLocalTerminal(sessionIdsToProbe);
|
|
if (!ok) return false;
|
|
const standaloneSessionIds = targetIds.filter((tabId) => (
|
|
sessions.some((session) => session.id === tabId)
|
|
));
|
|
if (standaloneSessionIds.length > 0) {
|
|
closeSessions(standaloneSessionIds);
|
|
}
|
|
for (const tabId of targetIds) {
|
|
if (workspaces.find((w) => w.id === tabId)) {
|
|
closeWorkspace(tabId);
|
|
} else if (logViews.find((lv) => lv.id === tabId)) {
|
|
closeLogView(tabId);
|
|
}
|
|
}
|
|
return true;
|
|
} finally {
|
|
closeTabsInFlightRef.current = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
|
|
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, canUseGlobalBroadcast, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleGlobalBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
|
|
{
|
|
const shortcutTabs = buildNumberShortcutTabTargets({
|
|
showSftpTab: settings.showSftpTab ?? true,
|
|
shellOnlyTabNumberShortcuts: settings.shellOnlyTabNumberShortcuts ?? false,
|
|
orderedTabs,
|
|
editorTabIds: editorTabs.map((t) => toEditorTabId(t.id)),
|
|
});
|
|
switch (action) {
|
|
case 'switchToTab': {
|
|
// Prefer physical Digit code so Shift+[1...9] works when e.key is "!" etc.
|
|
const num = tabShortcutDigitFromEvent(e);
|
|
if (num !== null && num <= shortcutTabs.length) {
|
|
setActiveTabId(shortcutTabs[num - 1]);
|
|
}
|
|
break;
|
|
}
|
|
case 'nextTab': {
|
|
const currentId = activeTabStore.getActiveTabId();
|
|
const currentIdx = shortcutTabs.indexOf(currentId);
|
|
if (currentIdx !== -1 && shortcutTabs.length > 0) {
|
|
const nextIdx = (currentIdx + 1) % shortcutTabs.length;
|
|
setActiveTabId(shortcutTabs[nextIdx]);
|
|
} else if (shortcutTabs.length > 0) {
|
|
setActiveTabId(shortcutTabs[0]);
|
|
}
|
|
break;
|
|
}
|
|
case 'prevTab': {
|
|
const currentId = activeTabStore.getActiveTabId();
|
|
const currentIdx = shortcutTabs.indexOf(currentId);
|
|
if (currentIdx !== -1 && shortcutTabs.length > 0) {
|
|
const prevIdx = (currentIdx - 1 + shortcutTabs.length) % shortcutTabs.length;
|
|
setActiveTabId(shortcutTabs[prevIdx]);
|
|
} else if (shortcutTabs.length > 0) {
|
|
setActiveTabId(shortcutTabs[shortcutTabs.length - 1]);
|
|
}
|
|
break;
|
|
}
|
|
case 'closeTab': {
|
|
const currentId = activeTabStore.getActiveTabId();
|
|
if (!currentId || currentId === 'vault' || currentId === 'sftp') break;
|
|
if (closeTabInFlightRef.current) break;
|
|
|
|
if (isPluginViewTabId?.(currentId)) {
|
|
closePluginViewTab?.(currentId);
|
|
break;
|
|
}
|
|
|
|
// Editor tabs route through their own dirty-confirm close flow.
|
|
if (isEditorTabId(currentId)) {
|
|
const editorId = fromEditorTabId(currentId);
|
|
if (editorId) handleRequestCloseEditorTabRef.current(editorId);
|
|
break;
|
|
}
|
|
|
|
const session = sessions.find((s) => s.id === currentId) ?? null;
|
|
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
|
|
|
|
const focusIsInsideTerminal = !!document.activeElement?.closest('[data-session-id]');
|
|
|
|
const intent = resolveCloseIntent({
|
|
activeTabId: currentId,
|
|
workspace: workspace ? { id: workspace.id, focusedSessionId: workspace.focusedSessionId } : null,
|
|
sessionForTab: session,
|
|
focusIsInsideTerminal,
|
|
});
|
|
|
|
closeTabInFlightRef.current = true;
|
|
(async () => {
|
|
try {
|
|
switch (intent.kind) {
|
|
case 'closeTerminal':
|
|
case 'closeSingleTab': {
|
|
const ok = await confirmIfBusyLocalTerminal([intent.sessionId]);
|
|
if (ok) closeSession(intent.sessionId);
|
|
return;
|
|
}
|
|
case 'closeWorkspace': {
|
|
const ids = sessions.filter((s) => s.workspaceId === intent.workspaceId).map((s) => s.id);
|
|
const ok = await confirmIfBusyLocalTerminal(ids);
|
|
if (ok) closeWorkspace(intent.workspaceId);
|
|
return;
|
|
}
|
|
case 'noop':
|
|
default:
|
|
return;
|
|
}
|
|
} finally {
|
|
closeTabInFlightRef.current = false;
|
|
}
|
|
})();
|
|
|
|
break;
|
|
}
|
|
case 'closeSession': {
|
|
const currentId = activeTabStore.getActiveTabId();
|
|
if (!currentId || currentId === 'vault' || currentId === 'sftp') break;
|
|
if (closeTabInFlightRef.current) break;
|
|
|
|
const session = sessions.find((s) => s.id === currentId) ?? null;
|
|
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
|
|
|
|
closeTabInFlightRef.current = true;
|
|
(async () => {
|
|
try {
|
|
// If active tab is a workspace, close the focused session (pane)
|
|
if (workspace) {
|
|
// Validate focusedSessionId is still valid — it can become stale
|
|
// if the previously focused session was already closed
|
|
const aliveIds = collectSessionIds(workspace.root);
|
|
const focusedId = aliveIds.includes(workspace.focusedSessionId)
|
|
? workspace.focusedSessionId
|
|
: aliveIds[0];
|
|
if (focusedId) {
|
|
const ok = await confirmIfBusyLocalTerminal([focusedId]);
|
|
if (ok) closeSession(focusedId);
|
|
}
|
|
} else if (session) {
|
|
// Standalone session tab — close the session
|
|
const ok = await confirmIfBusyLocalTerminal([session.id]);
|
|
if (ok) closeSession(session.id);
|
|
}
|
|
} finally {
|
|
closeTabInFlightRef.current = false;
|
|
}
|
|
})();
|
|
break;
|
|
}
|
|
case 'newTab':
|
|
case 'openLocal': {
|
|
const sessionId = createLocalTerminalWithCurrentShell();
|
|
addConnectionLogRef.current({
|
|
sessionId,
|
|
hostId: '',
|
|
hostLabel: 'Local Terminal',
|
|
hostname: 'localhost',
|
|
username: systemInfoRef.current.username,
|
|
protocol: 'local',
|
|
startTime: Date.now(),
|
|
localUsername: systemInfoRef.current.username,
|
|
localHostname: systemInfoRef.current.hostname,
|
|
saved: false,
|
|
});
|
|
break;
|
|
}
|
|
case 'openHosts':
|
|
setActiveTabId('vault');
|
|
break;
|
|
case 'openSftp':
|
|
if (settings.showSftpTab) {
|
|
setActiveTabId('sftp');
|
|
}
|
|
break;
|
|
case 'quickSwitch':
|
|
setIsQuickSwitcherOpen(!isQuickSwitcherOpen);
|
|
break;
|
|
case 'commandPalette':
|
|
setIsQuickSwitcherOpen(true);
|
|
break;
|
|
case 'newWorkspace':
|
|
// Dedicated shortcut to launch the AddToWorkspaceDialog in
|
|
// create mode — same entry as QuickSwitcher's "New Workspace"
|
|
// button, but without having to open QS first.
|
|
setAddToWorkspaceDialog({ mode: 'create' });
|
|
break;
|
|
case 'portForwarding':
|
|
// Navigate to vault and open port forwarding section
|
|
setActiveTabId('vault');
|
|
setNavigateToSection('port');
|
|
break;
|
|
case 'snippets':
|
|
{
|
|
const currentId = activeTabStore.getActiveTabId();
|
|
const intent = resolveSnippetsShortcutIntent({
|
|
activeTabId: currentId,
|
|
sessionForTab: sessions.find((s) => s.id === currentId) ?? null,
|
|
workspaceForTab: workspaces.find((w) => w.id === currentId) ?? null,
|
|
terminalScriptsToggleAvailable: !!toggleScriptsSidePanelRef.current,
|
|
});
|
|
|
|
if (intent.kind === 'toggleTerminalScripts') {
|
|
toggleScriptsSidePanelRef.current();
|
|
break;
|
|
}
|
|
|
|
setActiveTabId('vault');
|
|
setNavigateToSection('snippets');
|
|
}
|
|
break;
|
|
case 'toggleSidePanel':
|
|
toggleSidePanelRef.current?.();
|
|
break;
|
|
case 'broadcast': {
|
|
// Workspace tabs keep their local broadcast state; orphan tabs share
|
|
// the global state when at least two visible orphan tabs exist.
|
|
const currentId = activeTabStore.getActiveTabId();
|
|
const activeWs = workspaces.find(w => w.id === currentId);
|
|
if (activeWs) {
|
|
toggleBroadcast(activeWs.id);
|
|
} else if (sessions.some((session: TerminalSession) => (
|
|
session.id === currentId && !session.workspaceId
|
|
)) && canUseGlobalBroadcast) {
|
|
toggleGlobalBroadcast();
|
|
}
|
|
break;
|
|
}
|
|
case 'openSettings':
|
|
handleOpenSettingsRef.current();
|
|
break;
|
|
case 'splitHorizontal': {
|
|
const currentId = activeTabStore.getActiveTabId();
|
|
const activeSession = sessions.find(s => s.id === currentId);
|
|
const activeWs = workspaces.find(w => w.id === currentId);
|
|
if (activeSession && !activeSession.workspaceId) {
|
|
splitSessionWithCurrentShell(activeSession.id, 'horizontal');
|
|
} else if (activeWs) {
|
|
const liveIds = collectSessionIds(activeWs.root);
|
|
const targetId = (activeWs.focusedSessionId && liveIds.includes(activeWs.focusedSessionId))
|
|
? activeWs.focusedSessionId
|
|
: liveIds[0];
|
|
if (targetId) splitSessionWithCurrentShell(targetId, 'horizontal');
|
|
}
|
|
break;
|
|
}
|
|
case 'splitVertical': {
|
|
const currentId = activeTabStore.getActiveTabId();
|
|
const activeSession = sessions.find(s => s.id === currentId);
|
|
const activeWs = workspaces.find(w => w.id === currentId);
|
|
if (activeSession && !activeSession.workspaceId) {
|
|
splitSessionWithCurrentShell(activeSession.id, 'vertical');
|
|
} else if (activeWs) {
|
|
const liveIds = collectSessionIds(activeWs.root);
|
|
const targetId = (activeWs.focusedSessionId && liveIds.includes(activeWs.focusedSessionId))
|
|
? activeWs.focusedSessionId
|
|
: liveIds[0];
|
|
if (targetId) splitSessionWithCurrentShell(targetId, 'vertical');
|
|
}
|
|
break;
|
|
}
|
|
case 'togglePaneZoom': {
|
|
getAvailablePaneMagnificationController([
|
|
sftpPaneMagnificationRef?.current,
|
|
terminalPaneMagnificationRef?.current,
|
|
])?.toggle();
|
|
break;
|
|
}
|
|
case 'moveFocus': {
|
|
const magnificationController = getAvailablePaneMagnificationController([
|
|
sftpPaneMagnificationRef?.current,
|
|
terminalPaneMagnificationRef?.current,
|
|
]);
|
|
if (magnificationController?.getState() === 'focused') break;
|
|
// Debounce to prevent double-triggering when focus switches between terminals
|
|
const now = Date.now();
|
|
if (now - lastMoveFocusTimeRef.current < MOVE_FOCUS_DEBOUNCE_MS) {
|
|
if (IS_DEV) console.log('[App] moveFocus debounced, ignoring');
|
|
break;
|
|
}
|
|
lastMoveFocusTimeRef.current = now;
|
|
|
|
// Move focus between split panes
|
|
if (IS_DEV) console.log('[App] moveFocus action triggered, key:', e.key);
|
|
const direction = e.key === 'ArrowUp' ? 'up'
|
|
: e.key === 'ArrowDown' ? 'down'
|
|
: e.key === 'ArrowLeft' ? 'left'
|
|
: e.key === 'ArrowRight' ? 'right'
|
|
: null;
|
|
if (IS_DEV) console.log('[App] moveFocus direction:', direction);
|
|
if (direction) {
|
|
// Find the active workspace
|
|
const currentId = activeTabStore.getActiveTabId();
|
|
if (IS_DEV) console.log('[App] Active tab ID:', currentId);
|
|
const activeWs = workspaces.find(w => w.id === currentId);
|
|
if (IS_DEV) console.log('[App] Active workspace:', activeWs?.id, activeWs?.title);
|
|
if (activeWs) {
|
|
const result = moveFocusInWorkspace(activeWs.id, direction as 'up' | 'down' | 'left' | 'right');
|
|
if (IS_DEV) console.log('[App] moveFocusInWorkspace result:', result);
|
|
} else {
|
|
if (IS_DEV) console.log('[App] No active workspace found');
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function handleCreateLocalTerminalImpl(
|
|
getCtx: AppContextGetter,
|
|
shell?: { command: string; args?: string[]; name?: string; icon?: string },
|
|
options?: { localStartDir?: string },
|
|
) {
|
|
const { addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
|
|
{
|
|
const { username, hostname } = systemInfoRef.current;
|
|
const resolved = shell ?? resolveShellSetting(terminalSettings.localShell, discoveredShells, terminalSettings.localShellArgs);
|
|
// Match by ID (not command) to avoid WSL distros all sharing wsl.exe
|
|
const matchedShell = !shell ? discoveredShells.find(s => s.id === terminalSettings.localShell) : undefined;
|
|
const shellName = shell?.name ?? matchedShell?.name;
|
|
const shellIcon = shell?.icon ?? matchedShell?.icon;
|
|
const sessionId = createLocalTerminal({
|
|
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
|
|
shell: resolved?.command,
|
|
shellArgs: resolved?.args,
|
|
shellName,
|
|
shellIcon,
|
|
localStartDir: options?.localStartDir,
|
|
});
|
|
addConnectionLog({
|
|
sessionId,
|
|
hostId: '',
|
|
hostLabel: shellName || 'Local Terminal',
|
|
hostname: 'localhost',
|
|
username: username,
|
|
protocol: 'local',
|
|
startTime: Date.now(),
|
|
localUsername: username,
|
|
localHostname: hostname,
|
|
saved: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hidden = false) {
|
|
const { addConnectionLog, connectToHost, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef } = getCtx();
|
|
{
|
|
const { username, hostname: localHost } = systemInfoRef.current;
|
|
|
|
const effectiveHost = resolveEffectiveHost(host);
|
|
|
|
// Handle serial hosts separately
|
|
if (effectiveHost.protocol === 'serial') {
|
|
const portName = host.hostname.split('/').pop() || host.hostname;
|
|
const sessionId = connectToHost(effectiveHost, { hidden });
|
|
addConnectionLog({
|
|
sessionId,
|
|
hostId: host.id,
|
|
hostLabel: host.label || `Serial: ${portName}`,
|
|
hostname: host.hostname,
|
|
username: username,
|
|
protocol: 'serial',
|
|
...getLogHostVisualSnapshot(effectiveHost),
|
|
startTime: Date.now(),
|
|
localUsername: username,
|
|
localHostname: localHost,
|
|
saved: false,
|
|
});
|
|
return sessionId;
|
|
}
|
|
|
|
const protocol = resolveEffectiveTerminalProtocol(effectiveHost);
|
|
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
|
|
const sessionId = connectToHost(effectiveHost, { hidden });
|
|
addConnectionLog({
|
|
sessionId,
|
|
hostId: host.id,
|
|
hostLabel: host.label,
|
|
hostname: host.hostname,
|
|
username: resolvedAuth.username || 'root',
|
|
protocol,
|
|
...getLogHostVisualSnapshot(effectiveHost),
|
|
startTime: Date.now(),
|
|
localUsername: username,
|
|
localHostname: localHost,
|
|
saved: false,
|
|
});
|
|
return sessionId;
|
|
}
|
|
}
|
|
|
|
export function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionId: string, data: string) {
|
|
const { IS_DEV, connectionLogs, selectConnectionLogForTerminalDataCapture, sessions, updateConnectionLog } = getCtx();
|
|
{
|
|
if (IS_DEV) console.log('[handleTerminalDataCapture] Called', { sessionId, dataLength: data.length });
|
|
const session = sessions.find(s => s.id === sessionId);
|
|
if (IS_DEV) console.log('[handleTerminalDataCapture] Session', session);
|
|
if (IS_DEV) console.log('[handleTerminalDataCapture] All logs:', connectionLogs.map(l => ({ id: l.id, sessionId: l.sessionId, hostname: l.hostname, endTime: l.endTime, hasTerminalData: !!l.terminalData })));
|
|
|
|
const matchingLog = selectConnectionLogForTerminalDataCapture(
|
|
connectionLogs,
|
|
{ sessionId, hostname: session?.hostname },
|
|
);
|
|
|
|
if (IS_DEV) console.log('[handleTerminalDataCapture] Matching log', matchingLog);
|
|
|
|
if (matchingLog) {
|
|
updateConnectionLog(matchingLog.id, {
|
|
endTime: Date.now(),
|
|
terminalData: data,
|
|
});
|
|
if (IS_DEV) console.log('[handleTerminalDataCapture] Updated log with terminalData');
|
|
|
|
// Auto-save is now handled by real-time streaming in the main process
|
|
// via sessionLogStreamManager. No renderer-side fallback needed.
|
|
} else {
|
|
if (IS_DEV) console.log('[handleTerminalDataCapture] No matching log found!');
|
|
}
|
|
}
|
|
}
|
|
|
|
export function hasMultipleProtocolsImpl(getCtx: AppContextGetter, host: Host) {
|
|
const { resolveEffectiveHost } = getCtx();
|
|
{
|
|
// Gates the protocol picker (legacy name kept for its existing wiring).
|
|
// Only prompt when Telnet is available but isn't the host's default protocol;
|
|
// SSH-only, SSH+Mosh and Telnet-default all connect directly.
|
|
const effective = resolveEffectiveHost(host);
|
|
return Boolean(effective.telnetEnabled) && effective.protocol !== 'telnet';
|
|
}
|
|
}
|
|
|
|
export function handleHostConnectWithProtocolCheckImpl(getCtx: AppContextGetter, host: Host) {
|
|
const { handleConnectToHost, hasMultipleProtocols, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost, setQuickSearch } = getCtx();
|
|
{
|
|
if (hasMultipleProtocols(host)) {
|
|
setProtocolSelectHost(resolveEffectiveHost(host));
|
|
setIsQuickSwitcherOpen(false);
|
|
setQuickSearch('');
|
|
} else {
|
|
handleConnectToHost(host);
|
|
setIsQuickSwitcherOpen(false);
|
|
setQuickSearch('');
|
|
}
|
|
}
|
|
}
|
|
|
|
export function handleProtocolSelectImpl(getCtx: AppContextGetter, protocol: HostProtocol, port: number) {
|
|
const { handleConnectToHost, protocolSelectHost, setProtocolSelectHost } = getCtx();
|
|
{
|
|
if (protocolSelectHost) {
|
|
const hostWithProtocol: Host = {
|
|
...protocolSelectHost,
|
|
protocol: (protocol === 'mosh' || protocol === 'et') ? 'ssh' : protocol,
|
|
port,
|
|
moshEnabled: protocol === 'mosh',
|
|
etEnabled: protocol === 'et',
|
|
};
|
|
handleConnectToHost(hostWithProtocol);
|
|
setProtocolSelectHost(null);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function handleRootContextMenuImpl(getCtx: AppContextGetter, e: React.MouseEvent<HTMLDivElement>) {
|
|
void getCtx;
|
|
{
|
|
const editableSelector =
|
|
"input, textarea, [contenteditable], .monaco-editor, .monaco-diff-editor, .monaco-inputbox, .monaco-menu-container";
|
|
|
|
const nativeEvent = e.nativeEvent;
|
|
const path = typeof nativeEvent.composedPath === "function" ? nativeEvent.composedPath() : [];
|
|
const allowFromPath = path.some(
|
|
(node) => node instanceof Element && !!node.closest(editableSelector),
|
|
);
|
|
|
|
const target = e.target;
|
|
const targetElement =
|
|
target instanceof Element
|
|
? target
|
|
: target instanceof Node
|
|
? target.parentElement
|
|
: null;
|
|
const allowFromTarget = !!targetElement?.closest(editableSelector);
|
|
|
|
const allowNativeContextMenu = allowFromPath || allowFromTarget;
|
|
|
|
if (allowNativeContextMenu) {
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
}
|
|
}
|