Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c49346f6cc | ||
|
|
39a398aa2b | ||
|
|
0b7c52523e | ||
|
|
cb63f105aa | ||
|
|
316e46de4b | ||
|
|
1af5182b59 | ||
|
|
35194036cb | ||
|
|
6a077a3855 | ||
|
|
43f4687bb9 | ||
|
|
bbb888ae1e | ||
|
|
c74b78a49d | ||
|
|
7b2590e54e | ||
|
|
a7f42ec93e | ||
|
|
a886d509f8 | ||
|
|
d6fea6c328 | ||
|
|
b6169f1735 | ||
|
|
c97470a085 | ||
|
|
98cb9d09df | ||
|
|
9deb39dec2 | ||
|
|
bb45279d4e | ||
|
|
6b1d9ee409 | ||
|
|
c0c0378df0 | ||
|
|
093951150c | ||
|
|
a0418039c4 | ||
|
|
559e71cfcc | ||
|
|
a0a2567fa5 | ||
|
|
d080a43ae6 | ||
|
|
2c551cf5e8 | ||
|
|
c54aa52191 | ||
|
|
b8c838059a | ||
|
|
007b4bd389 | ||
|
|
13fd198243 | ||
|
|
2c562463c4 | ||
|
|
859d4b8156 | ||
|
|
c6e07cf149 | ||
|
|
0ab18ce186 | ||
|
|
f814719b32 | ||
|
|
ee6b05892d | ||
|
|
0f98ffd4f7 | ||
|
|
7ca5d0c832 | ||
|
|
1a76d34696 | ||
|
|
0b2d1b613b | ||
|
|
ded989b374 | ||
|
|
04c6348bc0 | ||
|
|
54297859e3 | ||
|
|
d236adcd48 | ||
|
|
4971f18bbe | ||
|
|
15687bd56e | ||
|
|
76675ec515 | ||
|
|
7c6304c355 | ||
|
|
8fdcbf87c2 | ||
|
|
0326ba7556 | ||
|
|
964230a737 | ||
|
|
5d551ee8e9 | ||
|
|
ec4e209972 | ||
|
|
c141fbc11e | ||
|
|
8e61ccac91 | ||
|
|
7c5047f22e | ||
|
|
c10100a314 | ||
|
|
5a294aa306 | ||
|
|
54b3ba2c01 | ||
|
|
f25822fdae | ||
|
|
69f433c161 | ||
|
|
6087343203 | ||
|
|
bb63de2658 | ||
|
|
fd938a84e4 | ||
|
|
c2e629ad61 | ||
|
|
4bf61c02a0 | ||
|
|
4747217929 | ||
|
|
fb3cdd0661 | ||
|
|
11ca8fba87 | ||
|
|
7ffc4b4c7f | ||
|
|
fe27dd8a9d | ||
|
|
eca11e9d2a | ||
|
|
779aa31ef8 | ||
|
|
2c8670a6c6 | ||
|
|
a94293d31e | ||
|
|
04b62f7ba3 | ||
|
|
45794b7f6f | ||
|
|
314072a631 | ||
|
|
c9f1951e28 | ||
|
|
7f83b22c95 | ||
|
|
b7082ab198 | ||
|
|
9369495e22 | ||
|
|
e3fdb1f7ff | ||
|
|
b9bc6b95e5 | ||
|
|
5cbaae8d2f | ||
|
|
915e571c63 | ||
|
|
86a43655e1 | ||
|
|
e47d86874f | ||
|
|
369de6fff2 | ||
|
|
3aa414ad05 | ||
|
|
356c27d0fb | ||
|
|
ae94e7e529 | ||
|
|
5828503ffc | ||
|
|
1c0f45e410 | ||
|
|
5c791cebe5 | ||
|
|
0ce6b0f777 | ||
|
|
6fca38a209 | ||
|
|
52541a6066 | ||
|
|
6d35301436 |
76
App.tsx
76
App.tsx
@@ -1,6 +1,7 @@
|
||||
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive } from './application/state/activeTabStore';
|
||||
import { useAutoSync } from './application/state/useAutoSync';
|
||||
import { useImmersiveMode } from './application/state/useImmersiveMode';
|
||||
import { useManagedSourceSync } from './application/state/useManagedSourceSync';
|
||||
import { usePortForwardingAutoStart } from './application/state/usePortForwardingAutoStart';
|
||||
import { usePortForwardingState } from './application/state/usePortForwardingState';
|
||||
@@ -14,6 +15,10 @@ import { initializeUIFonts } from './application/state/uiFontStore';
|
||||
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
|
||||
import { matchesKeyBinding } from './domain/models';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
|
||||
import { collectSessionIds } from './domain/workspace';
|
||||
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from './application/state/customThemeStore';
|
||||
import { applySyncPayload } from './domain/syncPayload';
|
||||
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
|
||||
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
|
||||
@@ -29,7 +34,7 @@ import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './componen
|
||||
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
|
||||
import { cn } from './lib/utils';
|
||||
import { classifyLocalShellType } from './lib/localShell';
|
||||
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types';
|
||||
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalSession, TerminalTheme } from './types';
|
||||
import { LogView as LogViewType } from './application/state/useSessionState';
|
||||
import type { SftpView as SftpViewComponent } from './components/SftpView';
|
||||
import type { TerminalLayer as TerminalLayerComponent } from './components/TerminalLayer';
|
||||
@@ -192,6 +197,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
reapplyCurrentTheme,
|
||||
immersiveMode,
|
||||
} = settings;
|
||||
|
||||
const {
|
||||
@@ -271,6 +278,56 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// isMacClient is used for window controls styling
|
||||
const isMacClient = typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Immersive Mode — derive UI chrome colors from the active terminal's theme
|
||||
// ---------------------------------------------------------------------------
|
||||
const activeTabId = useActiveTabId();
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
// Resolve the effective TerminalTheme for the currently focused terminal tab
|
||||
const activeTerminalTheme = useMemo<TerminalTheme | null>(() => {
|
||||
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
|
||||
|
||||
const resolveTheme = (s: TerminalSession): TerminalTheme => {
|
||||
const host = hosts.find(h => h.id === s.hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
return TERMINAL_THEMES.find(t => t.id === themeId)
|
||||
|| customThemes.find(t => t.id === themeId)
|
||||
|| currentTerminalTheme;
|
||||
};
|
||||
|
||||
// Workspace
|
||||
const workspace = workspaces.find(w => w.id === activeTabId);
|
||||
if (workspace) {
|
||||
// Focus mode: use the focused (or first remaining) session's theme
|
||||
if (workspace.viewMode === 'focus') {
|
||||
const wsSessionIds = collectSessionIds(workspace.root);
|
||||
const focused = sessions.find(s => s.id === workspace.focusedSessionId)
|
||||
?? sessions.find(s => wsSessionIds.includes(s.id));
|
||||
return focused ? resolveTheme(focused) : null;
|
||||
}
|
||||
// Split mode: require all sessions to share the same theme
|
||||
const sessionIds = collectSessionIds(workspace.root);
|
||||
const wsSessions = sessionIds.map(id => sessions.find(s => s.id === id)).filter(Boolean) as TerminalSession[];
|
||||
if (wsSessions.length === 0) return null;
|
||||
const firstTheme = resolveTheme(wsSessions[0]);
|
||||
const allSame = wsSessions.every(s => resolveTheme(s).id === firstTheme.id);
|
||||
return allSame ? firstTheme : null;
|
||||
}
|
||||
|
||||
// Single session tab
|
||||
const session = sessions.find(s => s.id === activeTabId);
|
||||
if (!session) return null;
|
||||
return resolveTheme(session);
|
||||
}, [activeTabId, sessions, workspaces, hosts, currentTerminalTheme, customThemes]);
|
||||
|
||||
useImmersiveMode({
|
||||
isImmersive: immersiveMode,
|
||||
activeTabId,
|
||||
activeTerminalTheme,
|
||||
restoreOriginalTheme: reapplyCurrentTheme,
|
||||
});
|
||||
|
||||
// Get port forwarding rules and import function
|
||||
const { rules: portForwardingRules, importRules: importPortForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
|
||||
|
||||
@@ -381,16 +438,11 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
}, [updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openReleasePage]);
|
||||
|
||||
// Memoize keys for port forwarding to prevent unnecessary re-renders
|
||||
const portForwardingKeys = useMemo(
|
||||
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase, })),
|
||||
[keys]
|
||||
);
|
||||
|
||||
// Auto-start port forwarding rules on app launch
|
||||
usePortForwardingAutoStart({
|
||||
hosts,
|
||||
keys: portForwardingKeys,
|
||||
keys,
|
||||
identities,
|
||||
});
|
||||
|
||||
// Sync tray menu data + handle tray actions
|
||||
@@ -452,9 +504,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keysForPf = keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase }));
|
||||
if (start) {
|
||||
void startTunnel(rule, host, keysForPf, (status, error) => {
|
||||
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart);
|
||||
} else {
|
||||
@@ -466,7 +517,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
unsubscribeFocus?.();
|
||||
unsubscribeToggle?.();
|
||||
};
|
||||
}, [hosts, keys, portForwardingRules, sessions, setActiveTabId, setWorkspaceFocusedSession, startTunnel, stopTunnel, t]);
|
||||
}, [hosts, identities, keys, portForwardingRules, sessions, setActiveTabId, setWorkspaceFocusedSession, startTunnel, stopTunnel, t]);
|
||||
|
||||
// Tray panel actions (from main process)
|
||||
useEffect(() => {
|
||||
@@ -1210,7 +1261,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
|
||||
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", immersiveMode && activeTerminalTheme && "immersive-transition")} onContextMenu={handleRootContextMenu}>
|
||||
<TopTabs
|
||||
theme={resolvedTheme}
|
||||
hosts={hosts}
|
||||
@@ -1231,6 +1282,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onToggleTheme={handleToggleTheme}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onSyncNow={handleSyncNowManual}
|
||||
isImmersiveActive={immersiveMode && activeTerminalTheme !== null}
|
||||
onStartSessionDrag={setDraggingSessionId}
|
||||
onEndSessionDrag={handleEndSessionDrag}
|
||||
onReorderTabs={reorderTabs}
|
||||
|
||||
@@ -231,6 +231,9 @@ const en: Messages = {
|
||||
'settings.appearance.themeColor.desc': 'Pick a preset palette for each theme',
|
||||
'settings.appearance.themeColor.light': 'Light palette',
|
||||
'settings.appearance.themeColor.dark': 'Dark palette',
|
||||
'settings.appearance.immersiveMode': 'Immersive Mode',
|
||||
'settings.appearance.immersiveMode.desc':
|
||||
'When enabled, the UI chrome (tab bar, sidebar, status bar) adapts its colors to match the active terminal theme for a visually cohesive experience.',
|
||||
'settings.appearance.customCss': 'Custom CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Add custom CSS to personalize the app appearance. Changes apply immediately.',
|
||||
@@ -712,6 +715,7 @@ const en: Messages = {
|
||||
'sftp.upload.phase.compressed': 'Compressed',
|
||||
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': 'Copy file path',
|
||||
'sftp.context.openWith': 'Open with...',
|
||||
'sftp.context.edit': 'Edit',
|
||||
'sftp.context.preview': 'Preview',
|
||||
@@ -894,9 +898,12 @@ const en: Messages = {
|
||||
'hostDetails.password.save': 'Save password',
|
||||
'hostDetails.identity.suggestions': 'Identities',
|
||||
'hostDetails.identity.missing': 'Identity not found',
|
||||
'hostDetails.credential.keyCertificate': 'Key, Certificate',
|
||||
'hostDetails.credential.keyCertificate': 'Key, Certificate, Local Key File',
|
||||
'hostDetails.credential.key': 'Key',
|
||||
'hostDetails.credential.certificate': 'Certificate',
|
||||
'hostDetails.credential.localKeyFile': 'Local Key File',
|
||||
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
|
||||
'hostDetails.credential.browseKeyFile': 'Browse...',
|
||||
'hostDetails.credential.missing': 'Credential not found',
|
||||
'hostDetails.keys.search': 'Search keys...',
|
||||
'hostDetails.keys.empty': 'No keys available',
|
||||
|
||||
@@ -215,6 +215,9 @@ const zhCN: Messages = {
|
||||
'settings.appearance.themeColor.desc': '为浅色与深色主题选择预设配色',
|
||||
'settings.appearance.themeColor.light': '浅色主题',
|
||||
'settings.appearance.themeColor.dark': '深色主题',
|
||||
'settings.appearance.immersiveMode': '沉浸模式',
|
||||
'settings.appearance.immersiveMode.desc':
|
||||
'启用后,UI 外观(标签栏、侧边栏、状态栏)会自动适配当前终端主题的配色,营造视觉一体化的沉浸体验。',
|
||||
'settings.appearance.customCss': '自定义 CSS',
|
||||
'settings.appearance.customCss.desc': '使用自定义 CSS 个性化界面,修改会立即生效。',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
@@ -583,9 +586,12 @@ const zhCN: Messages = {
|
||||
'hostDetails.password.save': '保存密码',
|
||||
'hostDetails.identity.suggestions': '身份',
|
||||
'hostDetails.identity.missing': '身份不存在',
|
||||
'hostDetails.credential.keyCertificate': '密钥 / 证书',
|
||||
'hostDetails.credential.keyCertificate': '密钥 / 证书 / 本地密钥',
|
||||
'hostDetails.credential.key': '密钥',
|
||||
'hostDetails.credential.certificate': '证书',
|
||||
'hostDetails.credential.localKeyFile': '本地密钥文件',
|
||||
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
|
||||
'hostDetails.credential.browseKeyFile': '浏览…',
|
||||
'hostDetails.credential.missing': '凭据不存在',
|
||||
'hostDetails.keys.search': '搜索密钥…',
|
||||
'hostDetails.keys.empty': '暂无密钥',
|
||||
@@ -1050,6 +1056,7 @@ const zhCN: Messages = {
|
||||
'sftp.upload.phase.compressed': '压缩传输',
|
||||
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': '复制文件路径',
|
||||
'sftp.context.openWith': '打开方式...',
|
||||
'sftp.context.edit': '编辑',
|
||||
'sftp.context.preview': '预览',
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface SftpPane {
|
||||
loading: boolean;
|
||||
reconnecting: boolean;
|
||||
error: string | null;
|
||||
connectionLogs: string[];
|
||||
selectedFiles: Set<string>;
|
||||
filter: string;
|
||||
filenameEncoding: SftpFilenameEncoding;
|
||||
@@ -33,6 +34,7 @@ export const createEmptyPane = (
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: null,
|
||||
connectionLogs: [],
|
||||
selectedFiles: new Set(),
|
||||
filter: "",
|
||||
filenameEncoding: "auto",
|
||||
|
||||
@@ -159,6 +159,7 @@ export const useSftpConnections = ({
|
||||
loading: true,
|
||||
reconnecting: false,
|
||||
error: null,
|
||||
connectionLogs: [],
|
||||
filenameEncoding, // Reset encoding for new connection
|
||||
}));
|
||||
|
||||
@@ -213,13 +214,57 @@ export const useSftpConnections = ({
|
||||
loading: true,
|
||||
reconnecting: prev.reconnecting,
|
||||
error: null,
|
||||
connectionLogs: [],
|
||||
files: prev.reconnecting ? prev.files : (sharedHostCache?.files ?? []),
|
||||
filenameEncoding, // Reset encoding for new connection
|
||||
}));
|
||||
|
||||
// Subscribe to SFTP connection progress events for auth logging
|
||||
const sftpSessionId = `sftp-${connectionId}`;
|
||||
let unsubSftpProgress: (() => void) | undefined;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.onSftpConnectionProgress) {
|
||||
unsubSftpProgress = bridge.onSftpConnectionProgress((sid, label, status, detail) => {
|
||||
if (sid !== sftpSessionId) return;
|
||||
let logLine: string;
|
||||
switch (status) {
|
||||
case 'connecting':
|
||||
logLine = `Connecting to ${label}...`;
|
||||
break;
|
||||
case 'authenticating':
|
||||
logLine = `${label} - Key exchange complete`;
|
||||
break;
|
||||
case 'auth-attempt':
|
||||
if (detail?.endsWith('rejected')) {
|
||||
logLine = `${label} - ✗ ${detail}`;
|
||||
} else if (detail === 'all methods exhausted') {
|
||||
logLine = `${label} - ✗ All authentication methods exhausted`;
|
||||
} else if (detail === 'waiting for user input...' || detail === 'user responded') {
|
||||
logLine = `${label} - ${detail}`;
|
||||
} else {
|
||||
logLine = `${label} - Trying ${detail}...`;
|
||||
}
|
||||
break;
|
||||
case 'connected':
|
||||
logLine = `${label} - Connected`;
|
||||
break;
|
||||
case 'error':
|
||||
logLine = `${label} - Error${detail ? `: ${detail}` : ''}`;
|
||||
break;
|
||||
default:
|
||||
logLine = `${label} - ${status}${detail ? `: ${detail}` : ''}`;
|
||||
}
|
||||
// Only update if this is still the active request (avoids stale logs leaking)
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connectionLogs: [...prev.connectionLogs, logLine],
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const credentials = getHostCredentials(host);
|
||||
const bridge = netcattyBridge.get();
|
||||
const openSftp = bridge?.openSftp;
|
||||
if (!openSftp) throw new Error("SFTP bridge unavailable");
|
||||
|
||||
@@ -407,6 +452,7 @@ export const useSftpConnections = ({
|
||||
files,
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
connectionLogs: [], // Clear after successful connect to avoid replay during navigation
|
||||
}));
|
||||
} catch (err) {
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
@@ -424,6 +470,8 @@ export const useSftpConnections = ({
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
} finally {
|
||||
unsubSftpProgress?.();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -49,6 +49,7 @@ export const useSftpDirectoryListing = () => {
|
||||
sizeFormatted: formatFileSize(size),
|
||||
lastModified,
|
||||
lastModifiedFormatted: formatDate(lastModified),
|
||||
permissions: f.permissions,
|
||||
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Host, Identity, SSHKey } from "../../../domain/models";
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
|
||||
import { resolveHostAuth } from "../../../domain/sshAuth";
|
||||
|
||||
interface UseSftpHostCredentialsParams {
|
||||
@@ -24,22 +25,32 @@ export const useSftpHostCredentials = ({
|
||||
host: host.proxyConfig.host,
|
||||
port: host.proxyConfig.port,
|
||||
username: host.proxyConfig.username,
|
||||
password: host.proxyConfig.password,
|
||||
password: sanitizeCredentialValue(host.proxyConfig.password),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
let jumpHosts: NetcattyJumpHost[] | undefined;
|
||||
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
|
||||
jumpHosts = host.hostChain.hostIds
|
||||
.map((hostId) => hosts.find((h) => h.id === hostId))
|
||||
.filter((h): h is Host => !!h)
|
||||
.map((jumpHost) => {
|
||||
.map((jumpHost, index) => {
|
||||
const jumpAuth = resolveHostAuth({
|
||||
host: jumpHost,
|
||||
keys,
|
||||
identities,
|
||||
});
|
||||
const jumpKey = jumpAuth.key;
|
||||
const hasConfiguredJumpProxyEndpoint =
|
||||
index === 0 &&
|
||||
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
|
||||
if (
|
||||
hasConfiguredJumpProxyEndpoint &&
|
||||
jumpHost.proxyConfig?.username &&
|
||||
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig.password) &&
|
||||
!sanitizeCredentialValue(jumpHost.proxyConfig.password)
|
||||
) {
|
||||
throw new Error(`Proxy credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`);
|
||||
}
|
||||
return {
|
||||
hostname: jumpHost.hostname,
|
||||
port: jumpHost.port || 22,
|
||||
@@ -52,9 +63,23 @@ export const useSftpHostCredentials = ({
|
||||
keyId: jumpAuth.keyId,
|
||||
keySource: jumpKey?.source,
|
||||
label: jumpHost.label,
|
||||
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
|
||||
? {
|
||||
type: jumpHost.proxyConfig.type,
|
||||
host: jumpHost.proxyConfig.host,
|
||||
port: jumpHost.proxyConfig.port,
|
||||
username: jumpHost.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
|
||||
}
|
||||
: undefined,
|
||||
identityFilePaths: jumpHost.identityFilePaths,
|
||||
};
|
||||
});
|
||||
}
|
||||
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts?.[0]?.proxy;
|
||||
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxyConfig?.password) {
|
||||
throw new Error("Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.");
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: host.hostname,
|
||||
@@ -70,6 +95,7 @@ export const useSftpHostCredentials = ({
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
sudo: host.sftpSudo,
|
||||
identityFilePaths: host.identityFilePaths,
|
||||
};
|
||||
},
|
||||
[hosts, identities, keys],
|
||||
|
||||
@@ -48,7 +48,7 @@ export const joinPath = (base: string, name: string): string => {
|
||||
return `${normalizedBase}\\${name}`;
|
||||
}
|
||||
if (base === "/") return `/${name}`;
|
||||
return `${base}/${name}`;
|
||||
return `${base.replace(/\/+$/, "")}/${name}`;
|
||||
};
|
||||
|
||||
export const getParentPath = (path: string): string => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
@@ -32,6 +33,12 @@ function getAIBridge() {
|
||||
return (window as unknown as { netcatty?: Record<string, (...args: unknown[]) => unknown> }).netcatty;
|
||||
}
|
||||
|
||||
const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
|
||||
|
||||
function emitAIStateChanged(key: string) {
|
||||
window.dispatchEvent(new CustomEvent<{ key: string }>(AI_STATE_CHANGED_EVENT, { detail: { key } }));
|
||||
}
|
||||
|
||||
function cleanupAcpSessions(sessionIds: string[]) {
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiAcpCleanup || sessionIds.length === 0) return;
|
||||
@@ -40,6 +47,48 @@ function cleanupAcpSessions(sessionIds: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
const currentSessions = latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
const removedSessionIds = currentSessions
|
||||
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
|
||||
.map((session) => session.id);
|
||||
|
||||
if (removedSessionIds.length === 0) return;
|
||||
|
||||
cleanupAcpSessions(removedSessionIds);
|
||||
|
||||
const removedSessionIdSet = new Set(removedSessionIds);
|
||||
|
||||
const nextSessions = currentSessions.filter((session) => {
|
||||
if (!session.scope.targetId) return true;
|
||||
return activeTargetIds.has(session.scope.targetId);
|
||||
});
|
||||
setLatestAISessionsSnapshot(nextSessions);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(nextSessions));
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
|
||||
const activeSessionIdMap = latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {};
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap = { ...activeSessionIdMap };
|
||||
|
||||
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
if (sessionId && removedSessionIdSet.has(sessionId)) {
|
||||
nextActiveSessionIdMap[scopeKey] = null;
|
||||
activeSessionMapChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (activeSessionMapChanged) {
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Maximum number of sessions to keep in localStorage. */
|
||||
const MAX_STORED_SESSIONS = 50;
|
||||
@@ -66,6 +115,17 @@ function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
||||
});
|
||||
}
|
||||
|
||||
let latestAISessionsSnapshot: AISession[] | null = null;
|
||||
let latestAIActiveSessionMapSnapshot: Record<string, string | null> | null = null;
|
||||
|
||||
function setLatestAISessionsSnapshot(sessions: AISession[]) {
|
||||
latestAISessionsSnapshot = sessions;
|
||||
}
|
||||
|
||||
function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<string, string | null>) {
|
||||
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
|
||||
}
|
||||
|
||||
export function useAIState() {
|
||||
// ── Provider Config ──
|
||||
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
|
||||
@@ -117,7 +177,9 @@ export function useAIState() {
|
||||
sessionsRef.current = sessions;
|
||||
}, [sessions]);
|
||||
// Per-scope active session: keyed by `${scopeType}:${scopeTargetId}`
|
||||
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>({});
|
||||
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>(() =>
|
||||
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {}
|
||||
);
|
||||
|
||||
// Per-agent model selection: remembers last selected model per agent
|
||||
const [agentModelMap, setAgentModelMapRaw] = useState<Record<string, string>>(() =>
|
||||
@@ -129,8 +191,43 @@ export function useAIState() {
|
||||
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLatestAISessionsSnapshot(sessions);
|
||||
}, [sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
setLatestAIActiveSessionMapSnapshot(activeSessionIdMap);
|
||||
}, [activeSessionIdMap]);
|
||||
|
||||
useEffect(() => {
|
||||
const validSessionIds = new Set(sessions.map((session) => session.id));
|
||||
let changed = false;
|
||||
const nextActiveSessionIdMap: Record<string, string | null> = {};
|
||||
|
||||
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
const nextSessionId = sessionId && validSessionIds.has(sessionId) ? sessionId : null;
|
||||
nextActiveSessionIdMap[scopeKey] = nextSessionId;
|
||||
if (nextSessionId !== sessionId) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
setActiveSessionIdMapRaw(nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}, [sessions, activeSessionIdMap]);
|
||||
|
||||
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
|
||||
setActiveSessionIdMapRaw(prev => ({ ...prev, [scopeKey]: id }));
|
||||
setActiveSessionIdMapRaw(prev => {
|
||||
const next = { ...prev, [scopeKey]: id };
|
||||
setLatestAIActiveSessionMapSnapshot(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setAgentModel = useCallback((agentId: string, modelId: string) => {
|
||||
@@ -303,9 +400,22 @@ export function useAIState() {
|
||||
setHostPermissionsRaw(perms ?? []);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_SESSIONS: {
|
||||
const nextSessions = localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
|
||||
setLatestAISessionsSnapshot(nextSessions);
|
||||
setSessionsRaw(nextSessions);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_AGENT_MODEL_MAP:
|
||||
setAgentModelMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {});
|
||||
break;
|
||||
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP: {
|
||||
const nextActiveSessionIdMap =
|
||||
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
setActiveSessionIdMapRaw(nextActiveSessionIdMap);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_WEB_SEARCH:
|
||||
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
|
||||
break;
|
||||
@@ -315,7 +425,33 @@ export function useAIState() {
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => window.removeEventListener('storage', handleStorage);
|
||||
const handleLocalStateChanged = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (!key) return;
|
||||
switch (key) {
|
||||
case STORAGE_KEY_AI_SESSIONS:
|
||||
setSessionsRaw(
|
||||
latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [],
|
||||
);
|
||||
return;
|
||||
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP:
|
||||
setActiveSessionIdMapRaw(
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {},
|
||||
);
|
||||
return;
|
||||
default:
|
||||
handleStorage({ key } as StorageEvent);
|
||||
}
|
||||
};
|
||||
window.addEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorage);
|
||||
window.removeEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Sync initial safety settings to MCP Server on mount ──
|
||||
@@ -375,6 +511,7 @@ export function useAIState() {
|
||||
};
|
||||
setSessionsRaw(prev => {
|
||||
const next = [session, ...prev];
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
@@ -391,12 +528,19 @@ export function useAIState() {
|
||||
}
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.filter(s => s.id !== sessionId);
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
if (scopeKey) {
|
||||
setActiveSessionIdMapRaw(prev => {
|
||||
if (prev[scopeKey] === sessionId) return { ...prev, [scopeKey]: null };
|
||||
if (prev[scopeKey] === sessionId) {
|
||||
const next = { ...prev, [scopeKey]: null };
|
||||
setLatestAIActiveSessionMapSnapshot(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
return next;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
@@ -415,12 +559,19 @@ export function useAIState() {
|
||||
const next = prev.filter(s => {
|
||||
return !(s.scope.type === scopeType && s.scope.targetId === targetId);
|
||||
});
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
const scopeKey = `${scopeType}:${targetId}`;
|
||||
setActiveSessionIdMapRaw(prev => {
|
||||
if (prev[scopeKey] != null) return { ...prev, [scopeKey]: null };
|
||||
if (prev[scopeKey] != null) {
|
||||
const next = { ...prev, [scopeKey]: null };
|
||||
setLatestAIActiveSessionMapSnapshot(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
return next;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
@@ -428,6 +579,7 @@ export function useAIState() {
|
||||
const updateSessionTitle = useCallback((sessionId: string, title: string) => {
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => s.id === sessionId ? { ...s, title, updatedAt: Date.now() } : s);
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
@@ -440,6 +592,7 @@ export function useAIState() {
|
||||
? { ...s, externalSessionId, updatedAt: Date.now() }
|
||||
: s
|
||||
));
|
||||
setLatestAISessionsSnapshot(next);
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
@@ -463,6 +616,7 @@ export function useAIState() {
|
||||
}
|
||||
return { ...s, messages: msgs, updatedAt: Date.now() };
|
||||
});
|
||||
setLatestAISessionsSnapshot(next);
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
@@ -476,6 +630,7 @@ export function useAIState() {
|
||||
msgs[msgs.length - 1] = updater(msgs[msgs.length - 1]);
|
||||
return { ...s, messages: msgs, updatedAt: Date.now() };
|
||||
});
|
||||
setLatestAISessionsSnapshot(next);
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
@@ -491,6 +646,7 @@ export function useAIState() {
|
||||
msgs[idx] = updater(msgs[idx]);
|
||||
return { ...s, messages: msgs, updatedAt: Date.now() };
|
||||
});
|
||||
setLatestAISessionsSnapshot(next);
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
@@ -503,29 +659,21 @@ export function useAIState() {
|
||||
}
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => s.id === sessionId ? { ...s, messages: [], updatedAt: Date.now() } : s);
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
|
||||
const removedSessionIds = sessionsRef.current
|
||||
.filter(s => s.scope.targetId && !activeTargetIds.has(s.scope.targetId))
|
||||
.map(s => s.id);
|
||||
cleanupAcpSessions(removedSessionIds);
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.filter(s => {
|
||||
// Keep sessions without a targetId (global scope)
|
||||
if (!s.scope.targetId) return true;
|
||||
// Keep sessions whose target still exists
|
||||
return activeTargetIds.has(s.scope.targetId);
|
||||
});
|
||||
if (next.length !== prev.length) {
|
||||
persistSessions(next);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
cleanupOrphanedAISessions(activeTargetIds);
|
||||
setSessionsRaw(latestAISessionsSnapshot ?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? []);
|
||||
setActiveSessionIdMapRaw(
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {},
|
||||
);
|
||||
}, []);
|
||||
|
||||
// ── Provider CRUD helpers ──
|
||||
const addProvider = useCallback((provider: ProviderConfig) => {
|
||||
|
||||
214
application/state/useImmersiveMode.ts
Normal file
214
application/state/useImmersiveMode.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Immersive Mode — makes the entire UI chrome adapt colors to match the active terminal's theme.
|
||||
*
|
||||
* Performance strategy:
|
||||
* - All built-in themes' CSS strings are pre-computed at module load (zero cost at switch time)
|
||||
* - Custom/unknown themes are computed lazily and cached
|
||||
* - A single `<style>` tag with `!important` overrides inline CSS variables atomically
|
||||
* - `useLayoutEffect` ensures the update happens before browser paint (no flash)
|
||||
*/
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { TerminalTheme } from '../../domain/models';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hex → HSL conversion (returns "H S% L%" without the hsl() wrapper)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function hexToHsl(hex: string): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
const l = (max + min) / 2;
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
||||
case g: h = ((b - r) / d + 2) / 6; break;
|
||||
case b: h = ((r - g) / d + 4) / 6; break;
|
||||
}
|
||||
}
|
||||
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
|
||||
}
|
||||
|
||||
function adjustLightness(hsl: string, delta: number): string {
|
||||
const parts = hsl.split(/\s+/);
|
||||
const newL = Math.max(0, Math.min(100, parseFloat(parts[2]) + delta));
|
||||
return `${parts[0]} ${parts[1]} ${Math.round(newL * 10) / 10}%`;
|
||||
}
|
||||
|
||||
function adjustSaturation(hsl: string, factor: number): string {
|
||||
const parts = hsl.split(/\s+/);
|
||||
const newS = Math.max(0, Math.min(100, parseFloat(parts[1]) * factor));
|
||||
return `${parts[0]} ${Math.round(newS * 10) / 10}% ${parts[2]}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build the CSS rule string from a TerminalTheme
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CSS_VARS = [
|
||||
'background', 'foreground', 'card', 'card-foreground',
|
||||
'popover', 'popover-foreground', 'primary', 'primary-foreground',
|
||||
'secondary', 'secondary-foreground', 'muted', 'muted-foreground',
|
||||
'accent', 'accent-foreground', 'destructive', 'destructive-foreground',
|
||||
'border', 'input', 'ring',
|
||||
] as const;
|
||||
|
||||
function buildImmersiveCss(theme: TerminalTheme): string {
|
||||
const bg = hexToHsl(theme.colors.background);
|
||||
const fg = hexToHsl(theme.colors.foreground);
|
||||
const cursor = hexToHsl(theme.colors.cursor);
|
||||
const isDark = theme.type === 'dark';
|
||||
|
||||
const card = adjustLightness(bg, isDark ? 4 : -3);
|
||||
const secondary = adjustLightness(bg, isDark ? 6 : -5);
|
||||
const muted = adjustLightness(bg, isDark ? 10 : -8);
|
||||
const mutedFg = adjustSaturation(adjustLightness(fg, isDark ? -20 : 20), 0.5);
|
||||
const border = adjustLightness(bg, isDark ? 12 : -10);
|
||||
const cursorL = parseFloat(cursor.split(' ')[2] ?? '50');
|
||||
const primaryFg = cursorL > 55 ? '0 0% 0%' : '0 0% 100%';
|
||||
|
||||
const values = [
|
||||
bg, fg, card, fg, // background, foreground, card, card-foreground
|
||||
card, fg, // popover, popover-foreground
|
||||
cursor, primaryFg, // primary, primary-foreground
|
||||
secondary, fg, // secondary, secondary-foreground
|
||||
muted, mutedFg, // muted, muted-foreground
|
||||
cursor, primaryFg, // accent, accent-foreground
|
||||
'0 70% 50%', '0 0% 100%', // destructive, destructive-foreground
|
||||
border, border, cursor, // border, input, ring
|
||||
];
|
||||
|
||||
const rules = CSS_VARS.map((name, i) => `--${name}: ${values[i]} !important`).join('; ');
|
||||
return `:root { ${rules}; }`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pre-compute CSS for all built-in themes at module load — O(1) lookup at switch time
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const cssCache = new Map<string, string>();
|
||||
|
||||
// Fingerprint: id + type + 3 key colors (detects in-place edits including dark↔light)
|
||||
function themeFingerprint(t: TerminalTheme): string {
|
||||
return `${t.id}\0${t.type}\0${t.colors.background}\0${t.colors.foreground}\0${t.colors.cursor}`;
|
||||
}
|
||||
|
||||
// Pre-compute built-in themes
|
||||
for (const theme of TERMINAL_THEMES) {
|
||||
cssCache.set(themeFingerprint(theme), buildImmersiveCss(theme));
|
||||
}
|
||||
|
||||
/** Get (or lazily compute & cache) the immersive CSS for a theme. */
|
||||
function getImmersiveCss(theme: TerminalTheme): string {
|
||||
const fp = themeFingerprint(theme);
|
||||
let css = cssCache.get(fp);
|
||||
if (!css) {
|
||||
css = buildImmersiveCss(theme);
|
||||
cssCache.set(fp, css);
|
||||
}
|
||||
return css;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Style tag management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STYLE_ID = 'netcatty-immersive-override';
|
||||
|
||||
function applyImmersiveStyle(css: string, isDark: boolean, bg: string) {
|
||||
const root = document.documentElement;
|
||||
const targetClass = isDark ? 'dark' : 'light';
|
||||
if (!root.classList.contains(targetClass)) {
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(targetClass);
|
||||
}
|
||||
let style = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = STYLE_ID;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = css;
|
||||
// Sync native Electron window chrome
|
||||
netcattyBridge.get()?.setTheme?.(isDark ? 'dark' : 'light');
|
||||
netcattyBridge.get()?.setBackgroundColor?.(bg);
|
||||
}
|
||||
|
||||
function removeImmersiveStyle() {
|
||||
document.getElementById(STYLE_ID)?.remove();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useImmersiveMode({
|
||||
isImmersive,
|
||||
activeTabId,
|
||||
activeTerminalTheme,
|
||||
restoreOriginalTheme,
|
||||
}: {
|
||||
isImmersive: boolean;
|
||||
activeTabId: string;
|
||||
activeTerminalTheme: TerminalTheme | null;
|
||||
restoreOriginalTheme: () => void;
|
||||
}) {
|
||||
const overrideActiveRef = useRef(false);
|
||||
const appliedFpRef = useRef<string | null>(null);
|
||||
const restoreRef = useRef(restoreOriginalTheme);
|
||||
restoreRef.current = restoreOriginalTheme;
|
||||
|
||||
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !activeTabId.startsWith('log-');
|
||||
|
||||
// APPLY: useLayoutEffect — runs before paint, O(1) Map lookup, single DOM write
|
||||
useLayoutEffect(() => {
|
||||
if (isImmersive && isTerminalTab && activeTerminalTheme) {
|
||||
const fp = themeFingerprint(activeTerminalTheme);
|
||||
if (appliedFpRef.current === fp) return;
|
||||
overrideActiveRef.current = true;
|
||||
appliedFpRef.current = fp;
|
||||
applyImmersiveStyle(getImmersiveCss(activeTerminalTheme), activeTerminalTheme.type === 'dark', activeTerminalTheme.colors.background);
|
||||
}
|
||||
}, [isImmersive, isTerminalTab, activeTerminalTheme]);
|
||||
|
||||
// RESTORE: useEffect — runs after paint, with fade overlay
|
||||
useEffect(() => {
|
||||
if (isImmersive && isTerminalTab && activeTerminalTheme) return;
|
||||
if (!overrideActiveRef.current) return;
|
||||
overrideActiveRef.current = false;
|
||||
appliedFpRef.current = null;
|
||||
const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'immersive-fade-overlay';
|
||||
overlay.style.backgroundColor = `hsl(${bg})`;
|
||||
document.body.appendChild(overlay);
|
||||
removeImmersiveStyle();
|
||||
restoreOriginalTheme();
|
||||
requestAnimationFrame(() => {
|
||||
overlay.classList.add('fade-out');
|
||||
overlay.addEventListener('transitionend', () => overlay.remove(), { once: true });
|
||||
});
|
||||
const fallback = setTimeout(() => { if (overlay.parentNode) overlay.remove(); }, 400);
|
||||
return () => { clearTimeout(fallback); if (overlay.parentNode) overlay.remove(); };
|
||||
}, [isImmersive, isTerminalTab, activeTerminalTheme, restoreOriginalTheme]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
removeImmersiveStyle();
|
||||
appliedFpRef.current = null;
|
||||
if (overrideActiveRef.current) {
|
||||
overrideActiveRef.current = false;
|
||||
restoreRef.current();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
* This should be used at the App level to ensure auto-start happens
|
||||
* when the application starts, not when the user navigates to the port forwarding page.
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Host, PortForwardingRule } from "../../domain/models";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
@@ -17,7 +17,8 @@ import { logger } from "../../lib/logger";
|
||||
|
||||
export interface UsePortForwardingAutoStartOptions {
|
||||
hosts: Host[];
|
||||
keys: { id: string; privateKey: string; passphrase: string }[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,10 +28,37 @@ export interface UsePortForwardingAutoStartOptions {
|
||||
export const usePortForwardingAutoStart = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
}: UsePortForwardingAutoStartOptions): void => {
|
||||
const autoStartExecutedRef = useRef(false);
|
||||
const hostsRef = useRef<Host[]>(hosts);
|
||||
const keysRef = useRef<{ id: string; privateKey: string; passphrase: string }[]>(keys);
|
||||
const keysRef = useRef<SSHKey[]>(keys);
|
||||
const identitiesRef = useRef<Identity[]>(identities);
|
||||
|
||||
const isHostAuthReady = useCallback((host: Host, seen = new Set<string>()): boolean => {
|
||||
if (!host || seen.has(host.id)) return true;
|
||||
seen.add(host.id);
|
||||
|
||||
if (host.identityId) {
|
||||
const identity = identitiesRef.current.find((candidate) => candidate.id === host.identityId);
|
||||
if (!identity) return false;
|
||||
if (identity.keyId && !keysRef.current.some((key) => key.id === identity.keyId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (host.identityFileId && !keysRef.current.some((key) => key.id === host.identityFileId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const chainIds = host.hostChain?.hostIds || [];
|
||||
for (const chainId of chainIds) {
|
||||
const chainHost = hostsRef.current.find((candidate) => candidate.id === chainId);
|
||||
if (!chainHost) return false;
|
||||
if (!isHostAuthReady(chainHost, seen)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
// Keep refs in sync
|
||||
useEffect(() => {
|
||||
@@ -41,6 +69,10 @@ export const usePortForwardingAutoStart = ({
|
||||
keysRef.current = keys;
|
||||
}, [keys]);
|
||||
|
||||
useEffect(() => {
|
||||
identitiesRef.current = identities;
|
||||
}, [identities]);
|
||||
|
||||
// Set up the reconnect callback
|
||||
useEffect(() => {
|
||||
const handleReconnect = async (
|
||||
@@ -62,7 +94,7 @@ export const usePortForwardingAutoStart = ({
|
||||
return { success: false, error: "Host not found" };
|
||||
}
|
||||
|
||||
return startPortForward(rule, host, keysRef.current, onStatusChange, true);
|
||||
return startPortForward(rule, host, hostsRef.current, keysRef.current, identitiesRef.current, onStatusChange, true);
|
||||
};
|
||||
|
||||
setReconnectCallback(handleReconnect);
|
||||
@@ -76,6 +108,17 @@ export const usePortForwardingAutoStart = ({
|
||||
if (autoStartExecutedRef.current) return;
|
||||
if (hosts.length === 0) return;
|
||||
|
||||
const storedRules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
) ?? [];
|
||||
const pendingAutoStartRules = storedRules.filter((rule) => rule.autoStart && rule.hostId);
|
||||
if (pendingAutoStartRules.some((rule) => {
|
||||
const host = hosts.find((candidate) => candidate.id === rule.hostId);
|
||||
return !host || !isHostAuthReady(host);
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as executed immediately to prevent duplicate runs
|
||||
// (React StrictMode or dependency changes could cause re-runs)
|
||||
autoStartExecutedRef.current = true;
|
||||
@@ -108,7 +151,9 @@ export const usePortForwardingAutoStart = ({
|
||||
void startPortForward(
|
||||
rule,
|
||||
host,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
(status, error) => {
|
||||
// Update the rule status in storage
|
||||
const currentRules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
@@ -135,5 +180,5 @@ export const usePortForwardingAutoStart = ({
|
||||
};
|
||||
|
||||
void runAutoStart();
|
||||
}, [hosts, keys]);
|
||||
}, [hosts, identities, isHostAuthReady, keys]);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Host, PortForwardingRule } from "../../domain/models";
|
||||
import { Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
|
||||
import {
|
||||
STORAGE_KEY_PF_PREFER_FORM_MODE,
|
||||
STORAGE_KEY_PF_VIEW_MODE,
|
||||
@@ -63,7 +63,9 @@ export interface UsePortForwardingStateResult {
|
||||
startTunnel: (
|
||||
rule: PortForwardingRule,
|
||||
host: Host,
|
||||
keys: { id: string; privateKey: string; passphrase: string }[],
|
||||
hosts: Host[],
|
||||
keys: SSHKey[],
|
||||
identities: Identity[],
|
||||
onStatusChange?: (status: PortForwardingRule["status"], error?: string) => void,
|
||||
enableReconnect?: boolean,
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
@@ -377,14 +379,16 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
async (
|
||||
rule: PortForwardingRule,
|
||||
host: Host,
|
||||
keys: { id: string; privateKey: string; passphrase: string }[],
|
||||
hosts: Host[],
|
||||
keys: SSHKey[],
|
||||
identities: Identity[],
|
||||
onStatusChange?: (
|
||||
status: PortForwardingRule["status"],
|
||||
error?: string,
|
||||
) => void,
|
||||
enableReconnect = false,
|
||||
) => {
|
||||
return startPortForward(rule, host, keys, (status, error) => {
|
||||
return startPortForward(rule, host, hosts, keys, identities, (status, error) => {
|
||||
setRuleStatus(rule.id, status, error);
|
||||
onStatusChange?.(status, error ?? undefined);
|
||||
}, enableReconnect);
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_IMMERSIVE_MODE,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
@@ -38,7 +39,6 @@ import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
|
||||
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
import { UI_FONTS, DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
|
||||
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
|
||||
import { useAvailableFonts } from './fontStore';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
@@ -121,8 +121,11 @@ const applyThemeTokens = (
|
||||
accentOverride: string,
|
||||
) => {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(resolvedTheme);
|
||||
// If immersive mode is active (style tag present), it owns the dark/light class — don't override
|
||||
if (!document.getElementById('netcatty-immersive-override')) {
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(resolvedTheme);
|
||||
}
|
||||
root.style.setProperty('--background', tokens.background);
|
||||
root.style.setProperty('--foreground', tokens.foreground);
|
||||
root.style.setProperty('--card', tokens.card);
|
||||
@@ -155,7 +158,6 @@ const applyThemeTokens = (
|
||||
};
|
||||
|
||||
export const useSettingsState = () => {
|
||||
const availableFonts = useAvailableFonts();
|
||||
const uiFontsLoaded = useUIFontsLoaded();
|
||||
const [theme, setTheme] = useState<'dark' | 'light' | 'system'>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_THEME);
|
||||
@@ -287,6 +289,10 @@ export const useSettingsState = () => {
|
||||
const localTerminalSettingsVersionRef = useRef(0);
|
||||
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
|
||||
|
||||
// Fix 1: Mount guard — skip redundant IPC broadcasts & localStorage writes on initial mount.
|
||||
// Set to true by the LAST useEffect declaration; all persist effects see false on first render.
|
||||
const persistMountedRef = useRef(false);
|
||||
|
||||
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
|
||||
setTerminalSettingsState((prev) => {
|
||||
const candidate = typeof nextValue === 'function'
|
||||
@@ -322,6 +328,21 @@ export const useSettingsState = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [immersiveMode, setImmersiveModeState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_IMMERSIVE_MODE);
|
||||
if (stored === null || stored === '') {
|
||||
// Persist default so collectSyncableSettings() can include it
|
||||
localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, 'true');
|
||||
return true;
|
||||
}
|
||||
return stored === 'true';
|
||||
});
|
||||
const setImmersiveMode = useCallback((enabled: boolean) => {
|
||||
setImmersiveModeState(enabled);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, String(enabled));
|
||||
notifySettingsChanged(STORAGE_KEY_IMMERSIVE_MODE, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const syncAppearanceFromStorage = useCallback(() => {
|
||||
const storedTheme = readStoredString(STORAGE_KEY_THEME);
|
||||
const nextTheme = storedTheme && isValidTheme(storedTheme) ? storedTheme : theme;
|
||||
@@ -334,6 +355,17 @@ export const useSettingsState = () => {
|
||||
const storedAccent = readStoredString(STORAGE_KEY_COLOR);
|
||||
const nextAccent = storedAccent && isValidHslToken(storedAccent) ? storedAccent.trim() : customAccent;
|
||||
|
||||
// Fix 2: Skip expensive DOM operations if nothing actually changed
|
||||
if (
|
||||
nextTheme === theme &&
|
||||
nextLightId === lightUiThemeId &&
|
||||
nextDarkId === darkUiThemeId &&
|
||||
nextAccentMode === accentMode &&
|
||||
nextAccent === customAccent
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTheme(nextTheme);
|
||||
setLightUiThemeId(nextLightId);
|
||||
setDarkUiThemeId(nextDarkId);
|
||||
@@ -402,9 +434,17 @@ export const useSettingsState = () => {
|
||||
const storedAutoOpenSidebar = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
if (storedAutoOpenSidebar === 'true' || storedAutoOpenSidebar === 'false') setSftpAutoOpenSidebar(storedAutoOpenSidebar === 'true');
|
||||
|
||||
// Immersive mode
|
||||
const storedImmersive = readStoredString(STORAGE_KEY_IMMERSIVE_MODE);
|
||||
if (storedImmersive === 'true' || storedImmersive === 'false') {
|
||||
const val = storedImmersive === 'true';
|
||||
setImmersiveModeState(val);
|
||||
notifySettingsChanged(STORAGE_KEY_IMMERSIVE_MODE, val);
|
||||
}
|
||||
|
||||
// Custom terminal themes
|
||||
customThemeStore.loadFromStorage();
|
||||
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
|
||||
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings, notifySettingsChanged]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
@@ -414,12 +454,11 @@ export const useSettingsState = () => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, accentMode);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_COLOR, customAccent);
|
||||
// Notify other windows
|
||||
// Fix 1: Skip IPC broadcast on initial mount (values already match localStorage)
|
||||
if (!persistMountedRef.current) return;
|
||||
// Fix 3: Send a single IPC instead of 5 — the receiver calls syncAppearanceFromStorage()
|
||||
// which re-reads ALL appearance values from localStorage.
|
||||
notifySettingsChanged(STORAGE_KEY_THEME, theme);
|
||||
notifySettingsChanged(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
|
||||
notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
|
||||
notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode);
|
||||
notifySettingsChanged(STORAGE_KEY_COLOR, customAccent);
|
||||
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, notifySettingsChanged]);
|
||||
|
||||
// Listen for OS color scheme changes to keep systemPreference in sync
|
||||
@@ -437,7 +476,10 @@ export const useSettingsState = () => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
|
||||
document.documentElement.lang = uiLanguage;
|
||||
netcattyBridge.get()?.setLanguage?.(uiLanguage);
|
||||
notifySettingsChanged(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
|
||||
// Fix 1: Skip IPC broadcast on initial mount
|
||||
if (persistMountedRef.current) {
|
||||
notifySettingsChanged(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
|
||||
}
|
||||
}, [uiLanguage, notifySettingsChanged]);
|
||||
|
||||
// Apply and persist UI font family
|
||||
@@ -446,7 +488,10 @@ export const useSettingsState = () => {
|
||||
const font = uiFontStore.getFontById(uiFontFamilyId);
|
||||
document.documentElement.style.setProperty('--font-sans', font.family);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
|
||||
notifySettingsChanged(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
|
||||
// Fix 1: Skip IPC broadcast on initial mount
|
||||
if (persistMountedRef.current) {
|
||||
notifySettingsChanged(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
|
||||
}
|
||||
}, [uiFontFamilyId, uiFontsLoaded, notifySettingsChanged]);
|
||||
|
||||
// Listen for settings changes from other windows via IPC
|
||||
@@ -540,6 +585,9 @@ export const useSettingsState = () => {
|
||||
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
|
||||
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_IMMERSIVE_MODE && typeof value === 'boolean') {
|
||||
setImmersiveModeState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
try {
|
||||
@@ -567,53 +615,76 @@ export const useSettingsState = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fix 4: Keep a ref snapshot of current settings so the storage event handler
|
||||
// can compare without capturing 25+ state variables in its closure / dep array.
|
||||
// This avoids constant listener detach/reattach on every state change.
|
||||
const settingsSnapshotRef = useRef({
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, immersiveMode,
|
||||
});
|
||||
settingsSnapshotRef.current = {
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, immersiveMode,
|
||||
};
|
||||
|
||||
// Listen for storage changes from other windows (cross-window sync)
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
const s = settingsSnapshotRef.current;
|
||||
if (e.key === STORAGE_KEY_THEME && e.newValue) {
|
||||
if (isValidTheme(e.newValue) && e.newValue !== theme) {
|
||||
if (isValidTheme(e.newValue) && e.newValue !== s.theme) {
|
||||
setTheme(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_THEME_LIGHT && e.newValue) {
|
||||
if (isValidUiThemeId('light', e.newValue) && e.newValue !== lightUiThemeId) {
|
||||
if (isValidUiThemeId('light', e.newValue) && e.newValue !== s.lightUiThemeId) {
|
||||
setLightUiThemeId(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_THEME_DARK && e.newValue) {
|
||||
if (isValidUiThemeId('dark', e.newValue) && e.newValue !== darkUiThemeId) {
|
||||
if (isValidUiThemeId('dark', e.newValue) && e.newValue !== s.darkUiThemeId) {
|
||||
setDarkUiThemeId(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_ACCENT_MODE && e.newValue) {
|
||||
if ((e.newValue === 'theme' || e.newValue === 'custom') && e.newValue !== accentMode) {
|
||||
if ((e.newValue === 'theme' || e.newValue === 'custom') && e.newValue !== s.accentMode) {
|
||||
setAccentMode(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_COLOR && e.newValue) {
|
||||
if (isValidHslToken(e.newValue) && e.newValue !== customAccent) {
|
||||
if (isValidHslToken(e.newValue) && e.newValue !== s.customAccent) {
|
||||
setCustomAccent(e.newValue.trim());
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_CUSTOM_CSS && e.newValue !== null) {
|
||||
if (e.newValue !== customCSS) {
|
||||
if (e.newValue !== s.customCSS) {
|
||||
setCustomCSS(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_FONT_FAMILY && e.newValue) {
|
||||
if (isValidUiFontId(e.newValue) && e.newValue !== uiFontFamilyId) {
|
||||
if (isValidUiFontId(e.newValue) && e.newValue !== s.uiFontFamilyId) {
|
||||
setUiFontFamilyId(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_HOTKEY_SCHEME && e.newValue) {
|
||||
const newScheme = e.newValue as HotkeyScheme;
|
||||
if (newScheme !== hotkeyScheme) {
|
||||
if (newScheme !== s.hotkeyScheme) {
|
||||
setHotkeyScheme(newScheme);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_LANGUAGE && e.newValue) {
|
||||
const next = resolveSupportedLocale(e.newValue);
|
||||
if (next !== uiLanguage) {
|
||||
if (next !== s.uiLanguage) {
|
||||
setUiLanguage(next as UILanguage);
|
||||
}
|
||||
}
|
||||
@@ -636,64 +707,64 @@ export const useSettingsState = () => {
|
||||
}
|
||||
// Sync terminal theme from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_THEME && e.newValue) {
|
||||
if (e.newValue !== terminalThemeId) {
|
||||
if (e.newValue !== s.terminalThemeId) {
|
||||
setTerminalThemeId(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync terminal font family from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_FONT_FAMILY && e.newValue) {
|
||||
if (e.newValue !== terminalFontFamilyId) {
|
||||
if (e.newValue !== s.terminalFontFamilyId) {
|
||||
setTerminalFontFamilyId(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync terminal font size from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_FONT_SIZE && e.newValue) {
|
||||
const newSize = parseInt(e.newValue, 10);
|
||||
if (!isNaN(newSize) && newSize !== terminalFontSize) {
|
||||
if (!isNaN(newSize) && newSize !== s.terminalFontSize) {
|
||||
setTerminalFontSize(newSize);
|
||||
}
|
||||
}
|
||||
// Sync SFTP double-click behavior from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR && e.newValue) {
|
||||
if ((e.newValue === 'open' || e.newValue === 'transfer') && e.newValue !== sftpDoubleClickBehavior) {
|
||||
if ((e.newValue === 'open' || e.newValue === 'transfer') && e.newValue !== s.sftpDoubleClickBehavior) {
|
||||
setSftpDoubleClickBehavior(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP auto-sync setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_AUTO_SYNC && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sftpAutoSync) {
|
||||
if (newValue !== s.sftpAutoSync) {
|
||||
setSftpAutoSync(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP show hidden files setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sftpShowHiddenFiles) {
|
||||
if (newValue !== s.sftpShowHiddenFiles) {
|
||||
setSftpShowHiddenFiles(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_EDITOR_WORD_WRAP && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== editorWordWrap) {
|
||||
if (newValue !== s.editorWordWrap) {
|
||||
setEditorWordWrapState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sessionLogsEnabled) {
|
||||
if (newValue !== s.sessionLogsEnabled) {
|
||||
setSessionLogsEnabled(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_DIR && e.newValue !== null) {
|
||||
if (e.newValue !== sessionLogsDir) {
|
||||
if (e.newValue !== s.sessionLogsDir) {
|
||||
setSessionLogsDir(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_FORMAT && e.newValue) {
|
||||
if (
|
||||
(e.newValue === 'txt' || e.newValue === 'raw' || e.newValue === 'html') &&
|
||||
e.newValue !== sessionLogsFormat
|
||||
e.newValue !== s.sessionLogsFormat
|
||||
) {
|
||||
setSessionLogsFormat(e.newValue);
|
||||
}
|
||||
@@ -701,54 +772,65 @@ export const useSettingsState = () => {
|
||||
// Sync SFTP compressed upload setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
|
||||
if (newValue !== sftpUseCompressedUpload) {
|
||||
if (newValue !== s.sftpUseCompressedUpload) {
|
||||
setSftpUseCompressedUpload(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP auto-open sidebar setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sftpAutoOpenSidebar) {
|
||||
if (newValue !== s.sftpAutoOpenSidebar) {
|
||||
setSftpAutoOpenSidebar(newValue);
|
||||
}
|
||||
}
|
||||
// Sync global hotkey enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== globalHotkeyEnabled) {
|
||||
if (newValue !== s.globalHotkeyEnabled) {
|
||||
setGlobalHotkeyEnabled(newValue);
|
||||
}
|
||||
}
|
||||
// Sync auto-update enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_AUTO_UPDATE_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== autoUpdateEnabled) {
|
||||
if (newValue !== s.autoUpdateEnabled) {
|
||||
setAutoUpdateEnabled(newValue);
|
||||
}
|
||||
}
|
||||
// Sync immersive mode from other windows
|
||||
if (e.key === STORAGE_KEY_IMMERSIVE_MODE && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.immersiveMode) {
|
||||
setImmersiveModeState(newValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, globalHotkeyEnabled, autoUpdateEnabled, mergeIncomingTerminalSettings]);
|
||||
}, [mergeIncomingTerminalSettings]); // Fix 4: stable deps only — state comparisons use settingsSnapshotRef
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
}, [terminalThemeId, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
|
||||
}, [terminalFontFamilyId, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_TERM_FONT_SIZE, terminalFontSize);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_FONT_SIZE, terminalFontSize);
|
||||
}, [terminalFontSize, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
|
||||
if (!persistMountedRef.current) return;
|
||||
const currentSignature = serializeTerminalSettings(terminalSettings);
|
||||
const hasPendingUnbroadcastLocalChanges =
|
||||
localTerminalSettingsVersionRef.current !== broadcastedLocalTerminalSettingsVersionRef.current;
|
||||
@@ -763,11 +845,13 @@ export const useSettingsState = () => {
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_HOTKEY_SCHEME, hotkeyScheme);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_HOTKEY_SCHEME, hotkeyScheme);
|
||||
}, [hotkeyScheme, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.write(STORAGE_KEY_CUSTOM_KEY_BINDINGS, customKeyBindings);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_CUSTOM_KEY_BINDINGS, customKeyBindings);
|
||||
}, [customKeyBindings, notifySettingsChanged]);
|
||||
|
||||
@@ -778,10 +862,7 @@ export const useSettingsState = () => {
|
||||
|
||||
// Apply and persist custom CSS
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, customCSS);
|
||||
notifySettingsChanged(STORAGE_KEY_CUSTOM_CSS, customCSS);
|
||||
|
||||
// Apply custom CSS to document
|
||||
// Always apply CSS to document (needed on mount)
|
||||
let styleEl = document.getElementById('netcatty-custom-css') as HTMLStyleElement | null;
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
@@ -789,59 +870,69 @@ export const useSettingsState = () => {
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleEl.textContent = customCSS;
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, customCSS);
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_CUSTOM_CSS, customCSS);
|
||||
}, [customCSS, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP double-click behavior
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
|
||||
}, [sftpDoubleClickBehavior, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP auto-sync setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync);
|
||||
}, [sftpAutoSync, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP show hidden files setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles);
|
||||
}, [sftpShowHiddenFiles, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP compressed upload setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload);
|
||||
}, [sftpUseCompressedUpload, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP auto-open sidebar setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar);
|
||||
}, [sftpAutoOpenSidebar, notifySettingsChanged]);
|
||||
|
||||
// Persist Session Logs settings
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled);
|
||||
}, [sessionLogsEnabled, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
|
||||
}, [sessionLogsDir, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
|
||||
}, [sessionLogsFormat, notifySettingsChanged]);
|
||||
|
||||
// Persist and sync toggle window hotkey setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
// Register/unregister the global hotkey in main process
|
||||
// Register/unregister the global hotkey in main process (needed on mount)
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.registerGlobalHotkey) {
|
||||
if (toggleWindowHotkey && globalHotkeyEnabled) {
|
||||
@@ -865,25 +956,32 @@ export const useSettingsState = () => {
|
||||
});
|
||||
}
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
}, [toggleWindowHotkey, globalHotkeyEnabled, notifySettingsChanged]);
|
||||
|
||||
// Persist global hotkey enabled setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled);
|
||||
}, [globalHotkeyEnabled, notifySettingsChanged]);
|
||||
|
||||
// Persist and sync close to tray setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
|
||||
// Update main process tray behavior
|
||||
// Update main process tray behavior (needed on mount)
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.setCloseToTray) {
|
||||
bridge.setCloseToTray(closeToTray).catch((err) => {
|
||||
console.warn('[SystemTray] Failed to set close-to-tray:', err);
|
||||
});
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray ? 'true' : 'false');
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
|
||||
}, [closeToTray, notifySettingsChanged]);
|
||||
|
||||
// Hydrate auto-update state from the main-process preference file on mount.
|
||||
@@ -904,16 +1002,11 @@ export const useSettingsState = () => {
|
||||
}, []);
|
||||
|
||||
// Persist auto-update enabled setting.
|
||||
// Skip IPC on initial mount to avoid overwriting the main-process preference
|
||||
// file when localStorage has been cleared (where the default is true).
|
||||
const autoUpdateMountedRef = useRef(false);
|
||||
// Initial mount still writes localStorage, but skips cross-window/main-process IPC.
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled);
|
||||
if (!autoUpdateMountedRef.current) {
|
||||
autoUpdateMountedRef.current = true;
|
||||
return; // Skip IPC on initial mount
|
||||
}
|
||||
// Notify main process on user-initiated changes
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.setAutoUpdate?.(autoUpdateEnabled).catch((err: unknown) => {
|
||||
@@ -921,6 +1014,13 @@ export const useSettingsState = () => {
|
||||
});
|
||||
}, [autoUpdateEnabled, notifySettingsChanged]);
|
||||
|
||||
// Fix 1: Mark all persist effects as mounted.
|
||||
// This MUST be declared AFTER all persist useEffects so that React runs it last
|
||||
// during the initial mount cycle (effects fire in declaration order).
|
||||
useEffect(() => {
|
||||
persistMountedRef.current = true;
|
||||
}, []);
|
||||
|
||||
// Get merged key bindings (defaults + custom overrides)
|
||||
const keyBindings = useMemo((): KeyBinding[] => {
|
||||
return DEFAULT_KEY_BINDINGS.map(binding => {
|
||||
@@ -983,11 +1083,6 @@ export const useSettingsState = () => {
|
||||
[terminalThemeId, customThemes]
|
||||
);
|
||||
|
||||
const currentTerminalFont = useMemo(
|
||||
() => availableFonts.find(f => f.id === terminalFontFamilyId) || availableFonts[0],
|
||||
[terminalFontFamilyId, availableFonts]
|
||||
);
|
||||
|
||||
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
|
||||
key: K,
|
||||
value: TerminalSettings[K]
|
||||
@@ -995,6 +1090,12 @@ export const useSettingsState = () => {
|
||||
setTerminalSettings(prev => ({ ...prev, [key]: value }));
|
||||
}, [setTerminalSettings]);
|
||||
|
||||
/** Re-apply the current UI theme tokens (used to restore after immersive mode override). */
|
||||
const reapplyCurrentTheme = useCallback(() => {
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
|
||||
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
|
||||
|
||||
return {
|
||||
theme,
|
||||
setTheme,
|
||||
@@ -1018,7 +1119,6 @@ export const useSettingsState = () => {
|
||||
currentTerminalTheme,
|
||||
terminalFontFamilyId,
|
||||
setTerminalFontFamilyId,
|
||||
currentTerminalFont,
|
||||
terminalFontSize,
|
||||
setTerminalFontSize,
|
||||
terminalSettings,
|
||||
@@ -1052,7 +1152,6 @@ export const useSettingsState = () => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(enabled));
|
||||
notifySettingsChanged(STORAGE_KEY_EDITOR_WORD_WRAP, enabled);
|
||||
}, [notifySettingsChanged]),
|
||||
availableFonts,
|
||||
// Session Logs
|
||||
sessionLogsEnabled,
|
||||
setSessionLogsEnabled,
|
||||
@@ -1071,6 +1170,9 @@ export const useSettingsState = () => {
|
||||
globalHotkeyEnabled,
|
||||
setGlobalHotkeyEnabled,
|
||||
rehydrateAllFromStorage,
|
||||
reapplyCurrentTheme,
|
||||
immersiveMode,
|
||||
setImmersiveMode,
|
||||
// Opaque version that changes when any synced setting changes, used by useAutoSync.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
settingsVersion: useMemo(() => Math.random(), [
|
||||
@@ -1079,7 +1181,7 @@ export const useSettingsState = () => {
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar,
|
||||
customThemes,
|
||||
customThemes, immersiveMode,
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -420,7 +420,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
/** Ensure a session exists for the current scope and return its ID. */
|
||||
const ensureSession = useCallback((): string => {
|
||||
if (activeSessionId) return activeSessionId;
|
||||
if (activeSessionId && sessionsRef.current.some((session) => session.id === activeSessionId)) {
|
||||
return activeSessionId;
|
||||
}
|
||||
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
|
||||
const session = createSession(scope, currentAgentId);
|
||||
setActiveSessionId(session.id);
|
||||
@@ -543,6 +545,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}));
|
||||
// Clear pending approvals for this session (so tool execute functions don't hang)
|
||||
clearAllPendingApprovals(activeSessionId);
|
||||
// Cancel in-flight command executions (Catty Agent + ACP Agent)
|
||||
const bridge = getNetcattyBridge();
|
||||
bridge?.aiCattyCancelExec?.(activeSessionId);
|
||||
bridge?.aiAcpCancel?.('', activeSessionId);
|
||||
}, [activeSessionId, setStreamingForScope, updateLastMessage, abortControllersRef]);
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
Tag,
|
||||
TerminalSquare,
|
||||
User,
|
||||
FileKey,
|
||||
FolderOpen,
|
||||
Trash2,
|
||||
Variable,
|
||||
Wifi,
|
||||
X,
|
||||
@@ -69,7 +72,7 @@ import {
|
||||
ProxyPanel,
|
||||
} from "./host-details";
|
||||
|
||||
type CredentialType = "sshid" | "key" | "certificate" | null;
|
||||
type CredentialType = "sshid" | "key" | "certificate" | "localKeyFile" | null;
|
||||
type SubPanel =
|
||||
| "none"
|
||||
| "create-group"
|
||||
@@ -147,6 +150,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
// Password visibility state
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// Local key file path input state
|
||||
const [newKeyFilePath, setNewKeyFilePath] = useState("");
|
||||
|
||||
// New group creation state
|
||||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [newGroupParent, setNewGroupParent] = useState("");
|
||||
@@ -469,6 +475,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
authMethod: identity.authMethod,
|
||||
password: undefined,
|
||||
identityFileId: undefined,
|
||||
identityFilePaths: undefined,
|
||||
}));
|
||||
setSelectedCredentialType(null);
|
||||
setCredentialPopoverOpen(false);
|
||||
@@ -969,6 +976,31 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local key file paths display */}
|
||||
{!selectedIdentity && !form.identityFileId && form.identityFilePaths && form.identityFilePaths.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{form.identityFilePaths.map((keyPath, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
|
||||
<FileKey size={14} className="text-primary shrink-0" />
|
||||
<span className="text-xs flex-1 truncate font-mono" title={keyPath}>
|
||||
{keyPath}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => {
|
||||
const paths = form.identityFilePaths?.filter((_, i) => i !== idx) || [];
|
||||
update("identityFilePaths", paths.length > 0 ? paths : undefined);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected credential display */}
|
||||
{!selectedIdentity && form.identityFileId && (
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
|
||||
@@ -1046,6 +1078,20 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
{t("hostDetails.credential.certificate")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
|
||||
onClick={() => {
|
||||
setSelectedCredentialType("localKeyFile");
|
||||
setCredentialPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<FileKey size={16} className="text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{t("hostDetails.credential.localKeyFile")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -1067,6 +1113,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onValueChange={(val) => {
|
||||
update("identityFileId", val);
|
||||
update("authMethod", "key");
|
||||
update("identityFilePaths", undefined);
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
placeholder={t("hostDetails.keys.search")}
|
||||
@@ -1102,6 +1149,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onValueChange={(val) => {
|
||||
update("identityFileId", val);
|
||||
update("authMethod", "certificate");
|
||||
update("identityFilePaths", undefined);
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
placeholder={t("hostDetails.certs.search")}
|
||||
@@ -1121,6 +1169,67 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local key file path input - appears after selecting "Local Key File" type */}
|
||||
{!selectedIdentity &&
|
||||
selectedCredentialType === "localKeyFile" &&
|
||||
!form.identityFileId && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 h-8 px-2 text-xs font-mono bg-background border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder={t("hostDetails.credential.localKeyFilePlaceholder")}
|
||||
value={newKeyFilePath}
|
||||
onChange={(e) => setNewKeyFilePath(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newKeyFilePath.trim()) {
|
||||
e.preventDefault();
|
||||
const paths = [...(form.identityFilePaths || []), newKeyFilePath.trim()];
|
||||
update("identityFilePaths", paths);
|
||||
update("identityFileId", undefined);
|
||||
update("authMethod", "key");
|
||||
setNewKeyFilePath("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
title={t("hostDetails.credential.browseKeyFile")}
|
||||
onClick={async () => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (!bridge?.selectFile) return;
|
||||
const filePath = await bridge.selectFile(
|
||||
"Select SSH Private Key",
|
||||
undefined,
|
||||
[{ name: "All Files", extensions: ["*"] }]
|
||||
);
|
||||
if (filePath) {
|
||||
const paths = [...(form.identityFilePaths || []), filePath];
|
||||
update("identityFilePaths", paths);
|
||||
update("identityFileId", undefined);
|
||||
update("authMethod", "key");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => {
|
||||
setSelectedCredentialType(null);
|
||||
setNewKeyFilePath("");
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -130,7 +130,9 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
const result = await startTunnel(
|
||||
rule,
|
||||
_host,
|
||||
keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase })),
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
(status, error) => {
|
||||
// Show toast on error (only once)
|
||||
if (status === "error" && error && !errorShown) {
|
||||
@@ -159,7 +161,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
});
|
||||
}
|
||||
},
|
||||
[hosts, keys, setRuleStatus, startTunnel, t],
|
||||
[hosts, identities, keys, setRuleStatus, startTunnel, t],
|
||||
);
|
||||
|
||||
// Stop a port forwarding tunnel
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, Sparkles, TerminalSquare, X } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { useAvailableFonts } from "../application/state/fontStore";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import { useVaultState } from "../application/state/useVaultState";
|
||||
import { useWindowControls } from "../application/state/useWindowControls";
|
||||
@@ -19,7 +20,6 @@ import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
|
||||
import SettingsSystemTab from "./settings/tabs/SettingsSystemTab";
|
||||
const SettingsAITab = React.lazy(() => import("./settings/tabs/SettingsAITab"));
|
||||
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
|
||||
import type { TerminalFont } from "../infrastructure/config/fonts";
|
||||
|
||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
@@ -45,12 +45,63 @@ class AITabErrorBoundary extends React.Component<
|
||||
}
|
||||
}
|
||||
|
||||
type SettingsState = ReturnType<typeof useSettingsState> & {
|
||||
availableFonts: TerminalFont[];
|
||||
};
|
||||
type SettingsState = ReturnType<typeof useSettingsState>;
|
||||
|
||||
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
|
||||
|
||||
const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ settings }) => {
|
||||
const availableFonts = useAvailableFonts();
|
||||
|
||||
return (
|
||||
<SettingsTerminalTab
|
||||
terminalThemeId={settings.terminalThemeId}
|
||||
setTerminalThemeId={settings.setTerminalThemeId}
|
||||
terminalFontFamilyId={settings.terminalFontFamilyId}
|
||||
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
|
||||
terminalFontSize={settings.terminalFontSize}
|
||||
setTerminalFontSize={settings.setTerminalFontSize}
|
||||
terminalSettings={settings.terminalSettings}
|
||||
updateTerminalSetting={settings.updateTerminalSetting}
|
||||
availableFonts={availableFonts}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsAITabContainer: React.FC = () => {
|
||||
const aiState = useAIState();
|
||||
|
||||
return (
|
||||
<AITabErrorBoundary>
|
||||
<React.Suspense fallback={<div className="flex-1 px-6 py-5 text-sm text-muted-foreground">Loading AI settings...</div>}>
|
||||
<SettingsAITab
|
||||
providers={aiState.providers}
|
||||
addProvider={aiState.addProvider}
|
||||
updateProvider={aiState.updateProvider}
|
||||
removeProvider={aiState.removeProvider}
|
||||
activeProviderId={aiState.activeProviderId}
|
||||
setActiveProviderId={aiState.setActiveProviderId}
|
||||
activeModelId={aiState.activeModelId}
|
||||
setActiveModelId={aiState.setActiveModelId}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
setDefaultAgentId={aiState.setDefaultAgentId}
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
setCommandBlocklist={aiState.setCommandBlocklist}
|
||||
commandTimeout={aiState.commandTimeout}
|
||||
setCommandTimeout={aiState.setCommandTimeout}
|
||||
maxIterations={aiState.maxIterations}
|
||||
setMaxIterations={aiState.setMaxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
setWebSearchConfig={aiState.setWebSearchConfig}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</AITabErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = ({ onSettingsApplied }) => {
|
||||
const {
|
||||
hosts,
|
||||
@@ -99,9 +150,12 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
const { t } = useI18n();
|
||||
const { notifyRendererReady, closeSettingsWindow } = useWindowControls();
|
||||
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
|
||||
const aiState = useAIState();
|
||||
const [activeTab, setActiveTab] = useState("application");
|
||||
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
|
||||
const isImmersive = settings.immersiveMode;
|
||||
const toggleImmersive = useCallback(() => {
|
||||
settings.setImmersiveMode(!isImmersive);
|
||||
}, [settings, isImmersive]);
|
||||
|
||||
useEffect(() => {
|
||||
notifyRendererReady();
|
||||
@@ -227,21 +281,13 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setUiLanguage={settings.setUiLanguage}
|
||||
customCSS={settings.customCSS}
|
||||
setCustomCSS={settings.setCustomCSS}
|
||||
isImmersive={isImmersive}
|
||||
onToggleImmersive={toggleImmersive}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("terminal") && (
|
||||
<SettingsTerminalTab
|
||||
terminalThemeId={settings.terminalThemeId}
|
||||
setTerminalThemeId={settings.setTerminalThemeId}
|
||||
terminalFontFamilyId={settings.terminalFontFamilyId}
|
||||
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
|
||||
terminalFontSize={settings.terminalFontSize}
|
||||
setTerminalFontSize={settings.setTerminalFontSize}
|
||||
terminalSettings={settings.terminalSettings}
|
||||
updateTerminalSetting={settings.updateTerminalSetting}
|
||||
availableFonts={settings.availableFonts}
|
||||
/>
|
||||
<SettingsTerminalTabContainer settings={settings} />
|
||||
)}
|
||||
|
||||
{mountedTabs.has("shortcuts") && (
|
||||
@@ -261,34 +307,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
)}
|
||||
|
||||
{mountedTabs.has("ai") && (
|
||||
<AITabErrorBoundary>
|
||||
<React.Suspense fallback={null}>
|
||||
<SettingsAITab
|
||||
providers={aiState.providers}
|
||||
addProvider={aiState.addProvider}
|
||||
updateProvider={aiState.updateProvider}
|
||||
removeProvider={aiState.removeProvider}
|
||||
activeProviderId={aiState.activeProviderId}
|
||||
setActiveProviderId={aiState.setActiveProviderId}
|
||||
activeModelId={aiState.activeModelId}
|
||||
setActiveModelId={aiState.setActiveModelId}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
setDefaultAgentId={aiState.setDefaultAgentId}
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
setCommandBlocklist={aiState.setCommandBlocklist}
|
||||
commandTimeout={aiState.commandTimeout}
|
||||
setCommandTimeout={aiState.setCommandTimeout}
|
||||
maxIterations={aiState.maxIterations}
|
||||
setMaxIterations={aiState.setMaxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
setWebSearchConfig={aiState.setWebSearchConfig}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</AITabErrorBoundary>
|
||||
<SettingsAITabContainer />
|
||||
)}
|
||||
|
||||
{mountedTabs.has("sync") && (
|
||||
|
||||
@@ -8,7 +8,7 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "
|
||||
// flushSync removed - no longer needed
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn } from "../lib/utils";
|
||||
import { cn, normalizeLineEndings, wrapBracketedPaste } from "../lib/utils";
|
||||
import {
|
||||
Host,
|
||||
Identity,
|
||||
@@ -157,6 +157,10 @@ interface TerminalProps {
|
||||
onToggleComposeBar?: () => void;
|
||||
isWorkspaceComposeBarOpen?: boolean;
|
||||
onBroadcastInput?: (data: string, sourceSessionId: string) => void;
|
||||
onSnippetExecutorChange?: (
|
||||
sessionId: string,
|
||||
executor: ((command: string, noAutoRun?: boolean) => void) | null,
|
||||
) => void;
|
||||
// Session log configuration for real-time streaming
|
||||
sessionLog?: { enabled: boolean; directory: string; format: string };
|
||||
}
|
||||
@@ -216,6 +220,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onToggleComposeBar,
|
||||
isWorkspaceComposeBarOpen,
|
||||
onBroadcastInput,
|
||||
onSnippetExecutorChange,
|
||||
sessionLog,
|
||||
}) => {
|
||||
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
|
||||
@@ -346,12 +351,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const isLocalConnection = host.protocol === "local";
|
||||
const isSerialConnection = host.protocol === "serial";
|
||||
|
||||
// Server stats (CPU, Memory, Disk) for Linux servers
|
||||
// Server stats (CPU, Memory, Disk) — only for Linux/macOS
|
||||
const { stats: serverStats } = useServerStats({
|
||||
sessionId,
|
||||
enabled: terminalSettings?.showServerStats ?? true,
|
||||
refreshInterval: terminalSettings?.serverStatsRefreshInterval ?? 5,
|
||||
isLinux: host.os === 'linux',
|
||||
isSupportedOs: host.os === 'linux' || host.os === 'macos',
|
||||
isConnected: status === 'connected',
|
||||
});
|
||||
|
||||
@@ -1042,11 +1047,43 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const scrollOnPasteRef = useRef(terminalSettings?.scrollOnPaste ?? true);
|
||||
scrollOnPasteRef.current = terminalSettings?.scrollOnPaste ?? true;
|
||||
|
||||
const scrollToBottomAfterProgrammaticInput = (data: string) => {
|
||||
const scrollToBottomAfterProgrammaticInput = useCallback((data: string) => {
|
||||
if (termRef.current && shouldScrollOnTerminalInput(terminalSettingsRef.current, data)) {
|
||||
termRef.current.scrollToBottom();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const executeSnippetCommand = useCallback((command: string, noAutoRun?: boolean) => {
|
||||
const term = termRef.current;
|
||||
const id = sessionRef.current;
|
||||
if (!term || !id) return;
|
||||
|
||||
let data = normalizeLineEndings(command);
|
||||
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 (isMultiLine && term.modes.bracketedPasteMode && !disableBracketedPasteRef.current) {
|
||||
data = wrapBracketedPaste(data);
|
||||
}
|
||||
if (!noAutoRun) data = `${data}\r`;
|
||||
|
||||
terminalBackend.writeToSession(id, data);
|
||||
scrollToBottomAfterProgrammaticInput(data);
|
||||
term.focus();
|
||||
}, [scrollToBottomAfterProgrammaticInput, terminalBackend]);
|
||||
|
||||
// Only register the snippet executor once the terminal session is ready.
|
||||
// Before that, TerminalLayer falls back to raw writeToSession which is the
|
||||
// correct path for sessions that are still connecting.
|
||||
useEffect(() => {
|
||||
if (status !== "connected") {
|
||||
onSnippetExecutorChange?.(sessionId, null);
|
||||
return;
|
||||
}
|
||||
onSnippetExecutorChange?.(sessionId, executeSnippetCommand);
|
||||
return () => onSnippetExecutorChange?.(sessionId, null);
|
||||
}, [executeSnippetCommand, onSnippetExecutorChange, sessionId, status]);
|
||||
|
||||
const terminalContextActions = useTerminalContextActions({
|
||||
termRef,
|
||||
@@ -1356,8 +1393,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/* Server Stats Display - Linux only */}
|
||||
{host.os === 'linux' && terminalSettings?.showServerStats && status === 'connected' && serverStats.lastUpdated && (
|
||||
{/* Server Stats Display */}
|
||||
{terminalSettings?.showServerStats && status === 'connected' && serverStats.lastUpdated && (
|
||||
<div className="flex items-center gap-2.5 ml-2 text-[10px] opacity-80 flex-nowrap overflow-hidden min-w-0">
|
||||
{/* CPU with HoverCard for per-core details */}
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
@@ -1404,6 +1441,24 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : serverStats.cpu !== null ? (
|
||||
<div className="flex flex-col gap-1.5 min-w-[160px]">
|
||||
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
serverStats.cpu >= 90 ? "bg-red-500" : serverStats.cpu >= 70 ? "bg-amber-500" : "bg-emerald-500"
|
||||
)}
|
||||
style={{ width: `${serverStats.cpu}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn(
|
||||
"text-center text-[11px] font-medium",
|
||||
serverStats.cpu >= 90 ? "text-red-400" : serverStats.cpu >= 70 ? "text-amber-400" : "text-emerald-400"
|
||||
)}>
|
||||
{serverStats.cpu}% · {serverStats.cpuCores ?? '?'} cores
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Circle, FolderTree, LayoutGrid, MessageSquare, PanelLeft, PanelRight, Palette, Server, X, Zap } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useActiveTabId } from '../application/state/activeTabStore';
|
||||
import { useTerminalBackend } from '../application/state/useTerminalBackend';
|
||||
import { collectSessionIds } from '../domain/workspace';
|
||||
@@ -28,7 +28,7 @@ import { SftpSidePanel } from './SftpSidePanel';
|
||||
import { ScriptsSidePanel } from './ScriptsSidePanel';
|
||||
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
|
||||
import { AIChatSidePanel } from './AIChatSidePanel';
|
||||
import { useAIState } from '../application/state/useAIState';
|
||||
import { cleanupOrphanedAISessions, useAIState } from '../application/state/useAIState';
|
||||
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
|
||||
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../application/state/customThemeStore';
|
||||
@@ -65,6 +65,8 @@ type PendingSftpUpload = {
|
||||
entries: DropEntry[];
|
||||
};
|
||||
|
||||
type SnippetExecutor = (command: string, noAutoRun?: boolean) => void;
|
||||
|
||||
const filterTabsMap = <T,>(source: Map<string, T>, validIds: Set<string>): Map<string, T> => {
|
||||
let changed = false;
|
||||
const next = new Map<string, T>();
|
||||
@@ -90,6 +92,18 @@ type AITerminalSessionInfo = {
|
||||
connected: boolean;
|
||||
};
|
||||
|
||||
type AIPanelContext = {
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeTargetId?: string;
|
||||
scopeHostIds: string[];
|
||||
scopeLabel: string;
|
||||
terminalSessions: AITerminalSessionInfo[];
|
||||
};
|
||||
|
||||
type AIStateValue = ReturnType<typeof useAIState>;
|
||||
|
||||
const AIStateContext = createContext<AIStateValue | null>(null);
|
||||
|
||||
const buildAITerminalSessionInfo = (
|
||||
session: TerminalSession | undefined,
|
||||
host: Host | undefined,
|
||||
@@ -110,6 +124,98 @@ const buildAITerminalSessionInfo = (
|
||||
};
|
||||
};
|
||||
|
||||
interface AIChatPanelsHostProps {
|
||||
mountedTabIds: string[];
|
||||
activeTabId: string | null;
|
||||
activeSidePanelTab: SidePanelTab | null;
|
||||
contextsByTabId: Map<string, AIPanelContext>;
|
||||
resolveExecutorContext: (scope: {
|
||||
type: 'terminal' | 'workspace';
|
||||
targetId?: string;
|
||||
label?: string;
|
||||
}) => ExecutorContext;
|
||||
}
|
||||
|
||||
const AIStateProviderInner: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const aiState = useAIState();
|
||||
return (
|
||||
<AIStateContext.Provider value={aiState}>
|
||||
{children}
|
||||
</AIStateContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const AIStateProvider = memo(AIStateProviderInner);
|
||||
AIStateProvider.displayName = 'AIStateProvider';
|
||||
|
||||
const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
mountedTabIds,
|
||||
activeTabId,
|
||||
activeSidePanelTab,
|
||||
contextsByTabId,
|
||||
resolveExecutorContext,
|
||||
}) => {
|
||||
const aiState = useContext(AIStateContext);
|
||||
|
||||
if (!aiState) {
|
||||
throw new Error('AIChatPanelsHost must be rendered inside AIStateProvider');
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{mountedTabIds.map((tabId) => {
|
||||
const context = contextsByTabId.get(tabId);
|
||||
if (!context) return null;
|
||||
|
||||
const isVisible = activeTabId === tabId && activeSidePanelTab === 'ai';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tabId}
|
||||
className={cn("absolute inset-0 z-10", !isVisible && "hidden")}
|
||||
>
|
||||
<AIChatSidePanel
|
||||
sessions={aiState.sessions}
|
||||
activeSessionIdMap={aiState.activeSessionIdMap}
|
||||
setActiveSessionId={aiState.setActiveSessionId}
|
||||
createSession={aiState.createSession}
|
||||
deleteSession={aiState.deleteSession}
|
||||
updateSessionTitle={aiState.updateSessionTitle}
|
||||
updateSessionExternalSessionId={aiState.updateSessionExternalSessionId}
|
||||
addMessageToSession={aiState.addMessageToSession}
|
||||
updateLastMessage={aiState.updateLastMessage}
|
||||
updateMessageById={aiState.updateMessageById}
|
||||
providers={aiState.providers}
|
||||
activeProviderId={aiState.activeProviderId}
|
||||
activeModelId={aiState.activeModelId}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
agentModelMap={aiState.agentModelMap}
|
||||
setAgentModel={aiState.setAgentModel}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
maxIterations={aiState.maxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
scopeType={context.scopeType}
|
||||
scopeTargetId={context.scopeTargetId}
|
||||
scopeHostIds={context.scopeHostIds}
|
||||
scopeLabel={context.scopeLabel}
|
||||
terminalSessions={context.terminalSessions}
|
||||
resolveExecutorContext={resolveExecutorContext}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AIChatPanelsHost = memo(AIChatPanelsHostInner);
|
||||
AIChatPanelsHost.displayName = 'AIChatPanelsHost';
|
||||
|
||||
interface TerminalLayerProps {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
@@ -306,6 +412,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
// 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 [workspaceArea, setWorkspaceArea] = useState<{ width: number; height: number }>({ width: 0, height: 0 });
|
||||
const workspaceOuterRef = useRef<HTMLDivElement>(null);
|
||||
@@ -633,6 +748,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setSftpPendingUploadsForTab(prev => filterTabsMap(prev, validTerminalTabIds));
|
||||
}, [validTerminalTabIds]);
|
||||
|
||||
useEffect(() => {
|
||||
cleanupOrphanedAISessions(validTerminalTabIds);
|
||||
}, [validTerminalTabIds]);
|
||||
|
||||
const computeWorkspaceRects = useCallback((workspace?: Workspace, size?: { width: number; height: number }): Record<string, WorkspaceRect> => {
|
||||
if (!workspace) return {} as Record<string, WorkspaceRect>;
|
||||
const wTotal = size?.width || 1;
|
||||
@@ -876,6 +995,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
() => Array.from(sftpHostForTab.keys()),
|
||||
[sftpHostForTab],
|
||||
);
|
||||
const mountedAiTabIds = useMemo(
|
||||
() =>
|
||||
Array.from(sidePanelOpenTabs.entries())
|
||||
.filter(([, panel]) => panel === 'ai')
|
||||
.map(([tabId]) => tabId),
|
||||
[sidePanelOpenTabs],
|
||||
);
|
||||
|
||||
// Get the focused terminal's current working directory
|
||||
const getTerminalCwd = useCallback(async (): Promise<string | null> => {
|
||||
@@ -982,6 +1108,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const handleSnippetClickForFocusedSession = useCallback((command: string, noAutoRun?: boolean) => {
|
||||
const sessionId = activeWorkspace?.focusedSessionId ?? activeSession?.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);
|
||||
@@ -1059,38 +1191,66 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const focusedFontFamilyOverridden = hasHostFontFamilyOverride(focusedHost);
|
||||
const focusedFontSizeOverridden = hasHostFontSizeOverride(focusedHost);
|
||||
|
||||
// AI Chat state
|
||||
const aiState = useAIState();
|
||||
const { cleanupOrphanedSessions } = aiState;
|
||||
|
||||
useEffect(() => {
|
||||
const activeIds = new Set<string>();
|
||||
for (const s of sessions) activeIds.add(s.id);
|
||||
for (const w of workspaces) activeIds.add(w.id);
|
||||
cleanupOrphanedSessions(activeIds);
|
||||
}, [sessions, workspaces, cleanupOrphanedSessions]);
|
||||
|
||||
// Keep MCP/ACP approval IPC listener alive for the entire terminal lifecycle.
|
||||
// Must live here (TerminalLayer), NOT in AIChatSidePanel (unmounts on tab switch)
|
||||
// or ChatMessageList (unmounts on panel hide).
|
||||
// Must live here (TerminalLayer), not inside the AI panel subtree, so closing
|
||||
// or hiding the panel never tears down approval handling mid-execution.
|
||||
useEffect(() => {
|
||||
return setupMcpApprovalBridge();
|
||||
}, []);
|
||||
|
||||
// Build terminal session context for the AI chat panel
|
||||
const aiTerminalSessions = useMemo(() => {
|
||||
// Build per-tab AI contexts so hidden panels can stay mounted without
|
||||
// recomputing scope resolution from scratch on every tab switch.
|
||||
const aiContextsByTabId = useMemo(() => {
|
||||
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
|
||||
const sessionIds = activeWorkspace?.root
|
||||
? collectSessionIds(activeWorkspace.root)
|
||||
: activeSession ? [activeSession.id] : [];
|
||||
const sessionById = new Map(sessions.map((session) => [session.id, session]));
|
||||
const workspaceById = new Map(workspaces.map((workspace) => [workspace.id, workspace]));
|
||||
const tabIds = new Set<string>(mountedAiTabIds);
|
||||
if (activeTabId) tabIds.add(activeTabId);
|
||||
|
||||
const result = sessionIds.map(sid => {
|
||||
const s = sessions.find(s => s.id === sid);
|
||||
const host = s?.hostId ? hosts.find(h => h.id === s.hostId) : undefined;
|
||||
return buildAITerminalSessionInfo(s, host, localOs);
|
||||
});
|
||||
return result;
|
||||
}, [sessions, hosts, activeWorkspace, activeSession]);
|
||||
const contexts = new Map<string, AIPanelContext>();
|
||||
|
||||
for (const tabId of tabIds) {
|
||||
const workspace = workspaceById.get(tabId);
|
||||
if (workspace) {
|
||||
const sessionIds = collectSessionIds(workspace.root);
|
||||
contexts.set(tabId, {
|
||||
scopeType: 'workspace',
|
||||
scopeTargetId: workspace.id,
|
||||
scopeHostIds: sessionIds
|
||||
.map((sessionId) => sessionById.get(sessionId)?.hostId)
|
||||
.filter((hostId): hostId is string => !!hostId),
|
||||
scopeLabel: workspace.title,
|
||||
terminalSessions: sessionIds.map((sessionId) =>
|
||||
buildAITerminalSessionInfo(
|
||||
sessionById.get(sessionId),
|
||||
sessionHostsMap.get(sessionId),
|
||||
localOs,
|
||||
),
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const session = sessionById.get(tabId);
|
||||
if (!session) continue;
|
||||
|
||||
contexts.set(tabId, {
|
||||
scopeType: 'terminal',
|
||||
scopeTargetId: session.id,
|
||||
scopeHostIds: session.hostId ? [session.hostId] : [],
|
||||
scopeLabel: session.hostLabel ?? '',
|
||||
terminalSessions: [
|
||||
buildAITerminalSessionInfo(
|
||||
session,
|
||||
sessionHostsMap.get(session.id),
|
||||
localOs,
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return contexts;
|
||||
}, [sessions, workspaces, mountedAiTabIds, activeTabId, sessionHostsMap]);
|
||||
|
||||
const resolveAIExecutorContext = useCallback((scope: {
|
||||
type: 'terminal' | 'workspace';
|
||||
@@ -1303,14 +1463,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={workspaceOuterRef}
|
||||
className="absolute inset-0 bg-background flex flex-col"
|
||||
style={{ display: isTerminalLayerVisible ? 'flex' : 'none', zIndex: isTerminalLayerVisible ? 10 : 0 }}
|
||||
>
|
||||
<div className={cn("flex-1 flex min-h-0 relative", sidePanelPosition === 'right' && "flex-row-reverse")}>
|
||||
<AIStateProvider>
|
||||
<div
|
||||
ref={workspaceOuterRef}
|
||||
className="absolute inset-0 bg-background flex flex-col"
|
||||
style={{ display: isTerminalLayerVisible ? 'flex' : 'none', zIndex: isTerminalLayerVisible ? 10 : 0 }}
|
||||
>
|
||||
<div className={cn("flex-1 flex min-h-0 relative", sidePanelPosition === 'right' && "flex-row-reverse")}>
|
||||
{/* Side panel with tab header + content (SFTP / Scripts / Theme) */}
|
||||
{(isSidePanelOpenForCurrentTab || mountedSftpTabIds.length > 0) && (
|
||||
{(isSidePanelOpenForCurrentTab || mountedSftpTabIds.length > 0 || mountedAiTabIds.length > 0) && (
|
||||
<>
|
||||
<div
|
||||
style={{ width: isSidePanelOpenForCurrentTab ? sidePanelWidth : 0 }}
|
||||
@@ -1488,48 +1649,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Chat sub-panel */}
|
||||
{activeSidePanelTab === 'ai' && (
|
||||
<div className="absolute inset-0 z-10">
|
||||
<AIChatSidePanel
|
||||
sessions={aiState.sessions}
|
||||
activeSessionIdMap={aiState.activeSessionIdMap}
|
||||
setActiveSessionId={aiState.setActiveSessionId}
|
||||
createSession={aiState.createSession}
|
||||
deleteSession={aiState.deleteSession}
|
||||
updateSessionTitle={aiState.updateSessionTitle}
|
||||
updateSessionExternalSessionId={aiState.updateSessionExternalSessionId}
|
||||
addMessageToSession={aiState.addMessageToSession}
|
||||
updateLastMessage={aiState.updateLastMessage}
|
||||
updateMessageById={aiState.updateMessageById}
|
||||
providers={aiState.providers}
|
||||
activeProviderId={aiState.activeProviderId}
|
||||
activeModelId={aiState.activeModelId}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
agentModelMap={aiState.agentModelMap}
|
||||
setAgentModel={aiState.setAgentModel}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
maxIterations={aiState.maxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
scopeType={activeWorkspace ? 'workspace' : 'terminal'}
|
||||
scopeTargetId={activeWorkspace?.id ?? activeSession?.id}
|
||||
scopeHostIds={activeWorkspace?.root
|
||||
? collectSessionIds(activeWorkspace.root).map(sid => {
|
||||
const s = sessions.find(s => s.id === sid);
|
||||
return s?.hostId;
|
||||
}).filter((id): id is string => !!id)
|
||||
: activeSession?.hostId ? [activeSession.hostId] : []
|
||||
}
|
||||
scopeLabel={activeWorkspace?.title ?? activeSession?.hostLabel ?? ''}
|
||||
terminalSessions={aiTerminalSessions}
|
||||
resolveExecutorContext={resolveAIExecutorContext}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<AIChatPanelsHost
|
||||
mountedTabIds={mountedAiTabIds}
|
||||
activeTabId={activeTabId}
|
||||
activeSidePanelTab={activeSidePanelTab}
|
||||
contextsByTabId={aiContextsByTabId}
|
||||
resolveExecutorContext={resolveAIExecutorContext}
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1679,6 +1806,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onToggleComposeBar={inActiveWorkspace ? handleToggleWorkspaceComposeBar : undefined}
|
||||
isWorkspaceComposeBarOpen={inActiveWorkspace ? isComposeBarOpen : undefined}
|
||||
onBroadcastInput={inActiveWorkspace && activeWorkspace && isBroadcastEnabled?.(activeWorkspace.id) ? handleBroadcastInput : undefined}
|
||||
onSnippetExecutorChange={handleSnippetExecutorChange}
|
||||
sessionLog={sessionLogsEnabled && sessionLogsDir ? { enabled: true, directory: sessionLogsDir, format: sessionLogsFormat || 'txt' } : undefined}
|
||||
/>
|
||||
</div>
|
||||
@@ -1740,25 +1868,26 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Global compose bar for workspace mode */}
|
||||
{activeWorkspace && isComposeBarOpen && (
|
||||
<TerminalComposeBar
|
||||
onSend={handleComposeSend}
|
||||
onClose={() => {
|
||||
setIsComposeBarOpen(false);
|
||||
// Refocus the terminal pane (matching solo-session behavior)
|
||||
if (focusedSessionId) {
|
||||
requestAnimationFrame(() => {
|
||||
const pane = document.querySelector(`[data-session-id="${focusedSessionId}"]`);
|
||||
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
|
||||
textarea?.focus();
|
||||
});
|
||||
}
|
||||
}}
|
||||
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
|
||||
themeColors={composeBarThemeColors}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{activeWorkspace && isComposeBarOpen && (
|
||||
<TerminalComposeBar
|
||||
onSend={handleComposeSend}
|
||||
onClose={() => {
|
||||
setIsComposeBarOpen(false);
|
||||
// Refocus the terminal pane (matching solo-session behavior)
|
||||
if (focusedSessionId) {
|
||||
requestAnimationFrame(() => {
|
||||
const pane = document.querySelector(`[data-session-id="${focusedSessionId}"]`);
|
||||
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
|
||||
textarea?.focus();
|
||||
});
|
||||
}
|
||||
}}
|
||||
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
|
||||
themeColors={composeBarThemeColors}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AIStateProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
// Ref to store the latest save function to avoid stale closure in keyboard shortcut
|
||||
const handleSaveRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
const handlePasteRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
const readClipboardTextRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
|
||||
|
||||
// Track theme from document.documentElement class (syncs with app theme)
|
||||
const [isDarkTheme, setIsDarkTheme] = useState(() =>
|
||||
@@ -254,6 +255,10 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
}
|
||||
}, [readClipboardTextFromBridge]);
|
||||
|
||||
useEffect(() => {
|
||||
readClipboardTextRef.current = readClipboardText;
|
||||
}, [readClipboardText]);
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
@@ -316,7 +321,30 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
});
|
||||
|
||||
// Fallback paste path for Electron environments where Monaco paste can fail.
|
||||
// Skip custom paste when focus is inside the find/replace widget so that
|
||||
// its input fields receive the pasted text via default browser behavior.
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
|
||||
const active = document.activeElement;
|
||||
if (active?.closest('.find-widget')) {
|
||||
// Read clipboard and insert into the find/replace input field.
|
||||
void (async () => {
|
||||
try {
|
||||
const text = await readClipboardTextRef.current();
|
||||
if (!text) return;
|
||||
// Monaco find widget inputs are <textarea> elements inside .monaco-inputbox
|
||||
if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) {
|
||||
const start = active.selectionStart ?? active.value.length;
|
||||
const end = active.selectionEnd ?? active.value.length;
|
||||
active.focus();
|
||||
active.setSelectionRange(start, end);
|
||||
document.execCommand('insertText', false, text);
|
||||
}
|
||||
} catch {
|
||||
// Ignore – paste simply won't work
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
void handlePasteRef.current();
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -36,6 +36,7 @@ interface TopTabsProps {
|
||||
onToggleTheme: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onSyncNow?: () => Promise<void>;
|
||||
isImmersiveActive?: boolean;
|
||||
onStartSessionDrag: (sessionId: string) => void;
|
||||
onEndSessionDrag: () => void;
|
||||
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
|
||||
@@ -217,6 +218,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onToggleTheme,
|
||||
onOpenSettings,
|
||||
onSyncNow,
|
||||
isImmersiveActive,
|
||||
onStartSessionDrag,
|
||||
onEndSessionDrag,
|
||||
onReorderTabs,
|
||||
@@ -765,6 +767,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag"
|
||||
onClick={onToggleTheme}
|
||||
disabled={isImmersiveActive}
|
||||
title="Toggle theme"
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
||||
@@ -788,10 +791,12 @@ const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
|
||||
prev.orphanSessions === next.orphanSessions &&
|
||||
prev.workspaces === next.workspaces &&
|
||||
prev.orderedTabs === next.orderedTabs &&
|
||||
prev.logViews === next.logViews &&
|
||||
prev.draggingSessionId === next.draggingSessionId &&
|
||||
prev.isMacClient === next.isMacClient &&
|
||||
prev.onOpenSettings === next.onOpenSettings &&
|
||||
prev.onSyncNow === next.onSyncNow
|
||||
prev.onSyncNow === next.onSyncNow &&
|
||||
prev.isImmersiveActive === next.isImmersiveActive
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ const TrayPanelContent: React.FC = () => {
|
||||
onTrayPanelMenuData,
|
||||
} = useTrayPanelBackend();
|
||||
|
||||
const { hosts, keys } = useVaultState();
|
||||
const { hosts, keys, identities } = useVaultState();
|
||||
useSessionState();
|
||||
const { rules: portForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
|
||||
const activeTabId = useActiveTabId();
|
||||
@@ -151,11 +151,6 @@ const TrayPanelContent: React.FC = () => {
|
||||
return () => unsubscribe?.();
|
||||
}, [onTrayPanelRefresh]);
|
||||
|
||||
const keysForPf = useMemo(
|
||||
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase })),
|
||||
[keys],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
void hideTrayPanel();
|
||||
}, [hideTrayPanel]);
|
||||
@@ -339,7 +334,7 @@ const TrayPanelContent: React.FC = () => {
|
||||
if (isActive) {
|
||||
void stopTunnel(rule.id);
|
||||
} else {
|
||||
void startTunnel(rule, host, keysForPf, (status, error) => {
|
||||
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart);
|
||||
}
|
||||
|
||||
@@ -1213,6 +1213,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
return new Set(managedSources.map(s => s.groupName));
|
||||
}, [managedSources]);
|
||||
|
||||
const isHostsSectionActive = currentSection === "hosts";
|
||||
|
||||
const moveHostToGroup = (hostId: string, groupPath: string | null) => {
|
||||
const targetGroup = groupPath || "";
|
||||
// Find the most specific (deepest) managed source that matches the target group
|
||||
@@ -1440,463 +1442,454 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
|
||||
{/* Main Area */}
|
||||
<div className="flex-1 flex flex-col min-h-0 relative">
|
||||
{currentSection === "hosts" && (
|
||||
<header className="border-b border-border/50 bg-secondary/80 backdrop-blur app-drag">
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3">
|
||||
<div className="relative flex-1 app-no-drag">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("vault.hosts.search.placeholder")}
|
||||
className={cn(
|
||||
"pl-9 h-10 bg-secondary border-border/60 text-sm",
|
||||
isSearchQuickConnect &&
|
||||
"border-primary/50 ring-1 ring-primary/20",
|
||||
)}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
{isSearchQuickConnect && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<Zap size={14} className="text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant={isSearchQuickConnect ? "default" : "secondary"}
|
||||
<header
|
||||
className={cn(
|
||||
"border-b border-border/50 bg-secondary/80 backdrop-blur app-drag",
|
||||
!isHostsSectionActive && "hidden",
|
||||
)}
|
||||
>
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3">
|
||||
<div className="relative flex-1 app-no-drag">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("vault.hosts.search.placeholder")}
|
||||
className={cn(
|
||||
"h-10 px-4 app-no-drag",
|
||||
!isSearchQuickConnect &&
|
||||
currentSection === "hosts" &&
|
||||
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
|
||||
"pl-9 h-10 bg-secondary border-border/60 text-sm",
|
||||
isSearchQuickConnect &&
|
||||
"border-primary/50 ring-1 ring-primary/20",
|
||||
)}
|
||||
onClick={handleConnectClick}
|
||||
>
|
||||
{t("vault.hosts.connect")}
|
||||
</Button>
|
||||
{/* View mode, tag filter, and sort controls */}
|
||||
<div className="flex items-center gap-1 app-no-drag">
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10 app-no-drag">
|
||||
{viewMode === "grid" ? (
|
||||
<LayoutGrid size={16} />
|
||||
) : viewMode === "list" ? (
|
||||
<List size={16} />
|
||||
) : (
|
||||
<Network size={16} />
|
||||
)}
|
||||
<ChevronDown size={10} className="ml-0.5" />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent className="w-32" align="end">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
onClick={() => setViewMode("grid")}
|
||||
>
|
||||
<LayoutGrid size={14} /> {t("vault.view.grid")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "list" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
onClick={() => setViewMode("list")}
|
||||
>
|
||||
<List size={14} /> {t("vault.view.list")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "tree" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
onClick={() => setViewMode("tree")}
|
||||
>
|
||||
<Network size={14} /> {t("vault.view.tree")}
|
||||
</Button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
<TagFilterDropdown
|
||||
allTags={allTags}
|
||||
selectedTags={selectedTags}
|
||||
onChange={setSelectedTags}
|
||||
onEditTag={handleEditTag}
|
||||
onDeleteTag={handleDeleteTag}
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
<SortDropdown
|
||||
value={sortMode}
|
||||
onChange={setSortMode}
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
<Button
|
||||
variant={isMultiSelectMode ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => {
|
||||
if (isMultiSelectMode) {
|
||||
clearHostSelection();
|
||||
} else {
|
||||
setIsMultiSelectMode(true);
|
||||
}
|
||||
}}
|
||||
title={t("vault.hosts.multiSelect")}
|
||||
>
|
||||
<CheckSquare size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
{/* New Host split button */}
|
||||
<div className="flex items-center app-no-drag">
|
||||
<Dropdown>
|
||||
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-10 px-3 rounded-r-none bg-transparent hover:bg-white/10 shadow-none app-no-drag"
|
||||
onClick={handleNewHost}
|
||||
>
|
||||
<Plus size={14} className="mr-2" /> {t("vault.hosts.newHost")}
|
||||
</Button>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-10 px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none app-no-drag"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
</div>
|
||||
<DropdownContent className="w-44" align="end" alignToParent>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => {
|
||||
setTargetParentPath(selectedGroupPath);
|
||||
setNewFolderName("");
|
||||
setIsNewFolderOpen(true);
|
||||
}}
|
||||
>
|
||||
<FolderTree size={14} /> {t("vault.hosts.newGroup")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => {
|
||||
setIsImportOpen(true);
|
||||
}}
|
||||
>
|
||||
<Upload size={14} /> {t("vault.hosts.import")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={handleExportHosts}
|
||||
>
|
||||
<Download size={14} /> {t("vault.hosts.export")}
|
||||
</Button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
{isSearchQuickConnect && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<Zap size={14} className="text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant={isSearchQuickConnect ? "default" : "secondary"}
|
||||
className={cn(
|
||||
"h-10 px-4 app-no-drag",
|
||||
!isSearchQuickConnect &&
|
||||
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
|
||||
)}
|
||||
onClick={handleConnectClick}
|
||||
>
|
||||
{t("vault.hosts.connect")}
|
||||
</Button>
|
||||
{/* View mode, tag filter, and sort controls */}
|
||||
<div className="flex items-center gap-1 app-no-drag">
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10 app-no-drag">
|
||||
{viewMode === "grid" ? (
|
||||
<LayoutGrid size={16} />
|
||||
) : viewMode === "list" ? (
|
||||
<List size={16} />
|
||||
) : (
|
||||
<Network size={16} />
|
||||
)}
|
||||
<ChevronDown size={10} className="ml-0.5" />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent className="w-32" align="end">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
onClick={() => setViewMode("grid")}
|
||||
>
|
||||
<LayoutGrid size={14} /> {t("vault.view.grid")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "list" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
onClick={() => setViewMode("list")}
|
||||
>
|
||||
<List size={14} /> {t("vault.view.list")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "tree" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
onClick={() => setViewMode("tree")}
|
||||
>
|
||||
<Network size={14} /> {t("vault.view.tree")}
|
||||
</Button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
<TagFilterDropdown
|
||||
allTags={allTags}
|
||||
selectedTags={selectedTags}
|
||||
onChange={setSelectedTags}
|
||||
onEditTag={handleEditTag}
|
||||
onDeleteTag={handleDeleteTag}
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
<SortDropdown
|
||||
value={sortMode}
|
||||
onChange={setSortMode}
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"h-10 px-3 app-no-drag",
|
||||
currentSection === "hosts" &&
|
||||
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
|
||||
)}
|
||||
onClick={onCreateLocalTerminal}
|
||||
variant={isMultiSelectMode ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => {
|
||||
if (isMultiSelectMode) {
|
||||
clearHostSelection();
|
||||
} else {
|
||||
setIsMultiSelectMode(true);
|
||||
}
|
||||
}}
|
||||
title={t("vault.hosts.multiSelect")}
|
||||
>
|
||||
<TerminalSquare size={14} className="mr-2" /> {t("common.terminal")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"h-10 px-3 app-no-drag",
|
||||
currentSection === "hosts" &&
|
||||
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
|
||||
)}
|
||||
onClick={() => setIsSerialModalOpen(true)}
|
||||
>
|
||||
<Usb size={14} className="mr-2" /> {t("serial.button")}
|
||||
<CheckSquare size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
{/* New Host split button */}
|
||||
<div className="flex items-center app-no-drag">
|
||||
<Dropdown>
|
||||
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-10 px-3 rounded-r-none bg-transparent hover:bg-white/10 shadow-none app-no-drag"
|
||||
onClick={handleNewHost}
|
||||
>
|
||||
<Plus size={14} className="mr-2" /> {t("vault.hosts.newHost")}
|
||||
</Button>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-10 px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none app-no-drag"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
</div>
|
||||
<DropdownContent className="w-44" align="end" alignToParent>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => {
|
||||
setTargetParentPath(selectedGroupPath);
|
||||
setNewFolderName("");
|
||||
setIsNewFolderOpen(true);
|
||||
}}
|
||||
>
|
||||
<FolderTree size={14} /> {t("vault.hosts.newGroup")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => {
|
||||
setIsImportOpen(true);
|
||||
}}
|
||||
>
|
||||
<Upload size={14} /> {t("vault.hosts.import")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={handleExportHosts}
|
||||
>
|
||||
<Download size={14} /> {t("vault.hosts.export")}
|
||||
</Button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-10 px-3 app-no-drag bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
onClick={onCreateLocalTerminal}
|
||||
>
|
||||
<TerminalSquare size={14} className="mr-2" /> {t("common.terminal")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-10 px-3 app-no-drag bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
onClick={() => setIsSerialModalOpen(true)}
|
||||
>
|
||||
<Usb size={14} className="mr-2" /> {t("serial.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{currentSection !== "port" &&
|
||||
currentSection !== "keys" &&
|
||||
currentSection !== "knownhosts" &&
|
||||
currentSection !== "snippets" &&
|
||||
currentSection !== "logs" && (
|
||||
<div className="flex-1 overflow-auto px-4 py-4 space-y-6">
|
||||
{currentSection === "hosts" && (
|
||||
<>
|
||||
<section className="space-y-2">
|
||||
{viewMode !== "tree" && (
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<button
|
||||
className="text-primary hover:underline"
|
||||
onClick={() => setSelectedGroupPath(null)}
|
||||
>
|
||||
{t("vault.hosts.allHosts")}
|
||||
</button>
|
||||
{selectedGroupPath &&
|
||||
selectedGroupPath
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((part, idx, arr) => {
|
||||
const crumbPath = arr.slice(0, idx + 1).join("/");
|
||||
const isLast = idx === arr.length - 1;
|
||||
return (
|
||||
<span
|
||||
key={crumbPath}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span className="text-muted-foreground">›</span>
|
||||
<button
|
||||
className={cn(
|
||||
isLast
|
||||
? "text-foreground font-semibold"
|
||||
: "text-primary hover:underline",
|
||||
)}
|
||||
onClick={() =>
|
||||
setSelectedGroupPath(crumbPath)
|
||||
}
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{viewMode !== "tree" && displayedGroups.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
{t("vault.groups.title")}
|
||||
</h3>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("vault.groups.total", { count: displayedGroups.length })}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{viewMode !== "tree" && (
|
||||
<div
|
||||
className={cn(
|
||||
displayedGroups.length === 0 ? "hidden" : "",
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const hostId = e.dataTransfer.getData("host-id");
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, selectedGroupPath);
|
||||
if (groupPath && selectedGroupPath !== null)
|
||||
moveGroup(groupPath, selectedGroupPath);
|
||||
}}
|
||||
{/* Keep hosts mounted so switching sections does not reset scroll or remount the list. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 overflow-auto px-4 py-4 space-y-6",
|
||||
!isHostsSectionActive && "hidden",
|
||||
)}
|
||||
>
|
||||
<section className="space-y-2">
|
||||
{viewMode !== "tree" && (
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<button
|
||||
className="text-primary hover:underline"
|
||||
onClick={() => setSelectedGroupPath(null)}
|
||||
>
|
||||
{displayedGroups.map((node) => (
|
||||
<ContextMenu key={node.path}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer",
|
||||
viewMode === "grid"
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
||||
)}
|
||||
draggable
|
||||
onDragStart={(e) =>
|
||||
e.dataTransfer.setData("group-path", node.path)
|
||||
}
|
||||
onDoubleClick={() =>
|
||||
setSelectedGroupPath(node.path)
|
||||
}
|
||||
onClick={() => setSelectedGroupPath(node.path)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const hostId =
|
||||
e.dataTransfer.getData("host-id");
|
||||
const groupPath =
|
||||
e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, node.path);
|
||||
if (groupPath) moveGroup(groupPath, node.path);
|
||||
}}
|
||||
{t("vault.hosts.allHosts")}
|
||||
</button>
|
||||
{selectedGroupPath &&
|
||||
selectedGroupPath
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((part, idx, arr) => {
|
||||
const crumbPath = arr.slice(0, idx + 1).join("/");
|
||||
const isLast = idx === arr.length - 1;
|
||||
return (
|
||||
<span
|
||||
key={crumbPath}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center">
|
||||
<FolderTree size={20} />
|
||||
<span className="text-muted-foreground">›</span>
|
||||
<button
|
||||
className={cn(
|
||||
isLast
|
||||
? "text-foreground font-semibold"
|
||||
: "text-primary hover:underline",
|
||||
)}
|
||||
onClick={() =>
|
||||
setSelectedGroupPath(crumbPath)
|
||||
}
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{viewMode !== "tree" && displayedGroups.length > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
{t("vault.groups.title")}
|
||||
</h3>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("vault.groups.total", { count: displayedGroups.length })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{viewMode !== "tree" && (
|
||||
<div
|
||||
className={cn(
|
||||
displayedGroups.length === 0 ? "hidden" : "",
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const hostId = e.dataTransfer.getData("host-id");
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, selectedGroupPath);
|
||||
if (groupPath && selectedGroupPath !== null)
|
||||
moveGroup(groupPath, selectedGroupPath);
|
||||
}}
|
||||
>
|
||||
{displayedGroups.map((node) => (
|
||||
<ContextMenu key={node.path}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer",
|
||||
viewMode === "grid"
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
||||
)}
|
||||
draggable
|
||||
onDragStart={(e) =>
|
||||
e.dataTransfer.setData("group-path", node.path)
|
||||
}
|
||||
onDoubleClick={() =>
|
||||
setSelectedGroupPath(node.path)
|
||||
}
|
||||
onClick={() => setSelectedGroupPath(node.path)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const hostId =
|
||||
e.dataTransfer.getData("host-id");
|
||||
const groupPath =
|
||||
e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, node.path);
|
||||
if (groupPath) moveGroup(groupPath, node.path);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center">
|
||||
<FolderTree size={20} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold truncate flex items-center gap-2">
|
||||
{node.name}
|
||||
{managedGroupPaths.has(node.path) && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/15 text-primary shrink-0">
|
||||
<FileSymlink size={10} />
|
||||
Managed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold truncate flex items-center gap-2">
|
||||
{node.name}
|
||||
{managedGroupPaths.has(node.path) && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/15 text-primary shrink-0">
|
||||
<FileSymlink size={10} />
|
||||
Managed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t("vault.groups.hostsCount", { count: node.totalHostCount ?? node.hosts.length })}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t("vault.groups.hostsCount", { count: node.totalHostCount ?? node.hosts.length })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
setTargetParentPath(node.path);
|
||||
setNewFolderName("");
|
||||
setIsNewFolderOpen(true);
|
||||
}}
|
||||
>
|
||||
<FolderPlus className="mr-2 h-4 w-4" /> {t("vault.groups.newSubgroup")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
setRenameTargetPath(node.path);
|
||||
setRenameGroupName(node.name);
|
||||
setRenameGroupError(null);
|
||||
setIsRenameGroupOpen(true);
|
||||
}}
|
||||
>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t("vault.groups.rename")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
setDeleteTargetPath(node.path);
|
||||
setIsDeleteGroupOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t("vault.groups.delete")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
setTargetParentPath(node.path);
|
||||
setNewFolderName("");
|
||||
setIsNewFolderOpen(true);
|
||||
}}
|
||||
>
|
||||
<FolderPlus className="mr-2 h-4 w-4" /> {t("vault.groups.newSubgroup")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
setRenameTargetPath(node.path);
|
||||
setRenameGroupName(node.name);
|
||||
setRenameGroupError(null);
|
||||
setIsRenameGroupOpen(true);
|
||||
}}
|
||||
>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t("vault.groups.rename")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
setDeleteTargetPath(node.path);
|
||||
setIsDeleteGroupOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t("vault.groups.delete")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
{t("vault.nav.hosts")}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("vault.hosts.header.entries", { count: viewMode === "tree" ? treeViewHosts.length : displayedHosts.length })}
|
||||
</span>
|
||||
<div className="bg-secondary/80 border border-border/70 rounded-md px-2 py-1 text-[11px]">
|
||||
{t("vault.hosts.header.live", { count: sessions.length })}
|
||||
</div>
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
{t("vault.nav.hosts")}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("vault.hosts.header.entries", { count: viewMode === "tree" ? treeViewHosts.length : displayedHosts.length })}
|
||||
</span>
|
||||
<div className="bg-secondary/80 border border-border/70 rounded-md px-2 py-1 text-[11px]">
|
||||
{t("vault.hosts.header.live", { count: sessions.length })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMultiSelectMode && (
|
||||
<div className="flex items-center gap-2 p-2 bg-secondary/60 rounded-lg border border-border/40">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("vault.hosts.selected", { count: selectedHostIds.size })}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const allIds = new Set(displayedHosts.map(h => h.id));
|
||||
setSelectedHostIds(allIds);
|
||||
}}
|
||||
>
|
||||
{t("vault.hosts.selectAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearHostSelection}
|
||||
>
|
||||
{t("vault.hosts.deselectAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={selectedHostIds.size === 0}
|
||||
onClick={deleteSelectedHosts}
|
||||
>
|
||||
<Trash2 size={14} className="mr-1" />
|
||||
{t("vault.hosts.deleteSelected", { count: selectedHostIds.size })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={clearHostSelection}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isMultiSelectMode && (
|
||||
<div className="flex items-center gap-2 p-2 bg-secondary/60 rounded-lg border border-border/40">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("vault.hosts.selected", { count: selectedHostIds.size })}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const allIds = new Set(displayedHosts.map(h => h.id));
|
||||
setSelectedHostIds(allIds);
|
||||
}}
|
||||
>
|
||||
{t("vault.hosts.selectAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearHostSelection}
|
||||
>
|
||||
{t("vault.hosts.deselectAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={selectedHostIds.size === 0}
|
||||
onClick={deleteSelectedHosts}
|
||||
>
|
||||
<Trash2 size={14} className="mr-1" />
|
||||
{t("vault.hosts.deleteSelected", { count: selectedHostIds.size })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={clearHostSelection}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === "tree" ? (
|
||||
<HostTreeView
|
||||
groupTree={treeViewGroupTree}
|
||||
hosts={treeViewHosts} // Use filtered and sorted hosts for tree view
|
||||
sortMode={sortMode}
|
||||
expandedPaths={treeExpandedState.expandedPaths}
|
||||
onTogglePath={treeExpandedState.togglePath}
|
||||
onExpandAll={treeExpandedState.expandAll}
|
||||
onCollapseAll={treeExpandedState.collapseAll}
|
||||
onConnect={handleHostConnect}
|
||||
onEditHost={handleEditHost}
|
||||
onDuplicateHost={handleDuplicateHost}
|
||||
onDeleteHost={(host) => onDeleteHost(host.id)}
|
||||
onCopyCredentials={handleCopyCredentials}
|
||||
onNewHost={(groupPath) => {
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(groupPath || null);
|
||||
setIsHostPanelOpen(true);
|
||||
}}
|
||||
onNewGroup={(parentPath) => {
|
||||
setTargetParentPath(parentPath || null);
|
||||
setNewFolderName("");
|
||||
setIsNewFolderOpen(true);
|
||||
}}
|
||||
onEditGroup={(groupPath) => {
|
||||
setRenameTargetPath(groupPath);
|
||||
const groupName = groupPath.split('/').pop() || '';
|
||||
setRenameGroupName(groupName);
|
||||
setRenameGroupError(null);
|
||||
setIsRenameGroupOpen(true);
|
||||
}}
|
||||
onDeleteGroup={(groupPath) => {
|
||||
setDeleteTargetPath(groupPath);
|
||||
setIsDeleteGroupOpen(true);
|
||||
}}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={handleUnmanageGroup}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
/>
|
||||
) : sortMode === "group" && groupedDisplayHosts ? (
|
||||
<div className="space-y-6">
|
||||
{viewMode === "tree" ? (
|
||||
<HostTreeView
|
||||
groupTree={treeViewGroupTree}
|
||||
hosts={treeViewHosts} // Use filtered and sorted hosts for tree view
|
||||
sortMode={sortMode}
|
||||
expandedPaths={treeExpandedState.expandedPaths}
|
||||
onTogglePath={treeExpandedState.togglePath}
|
||||
onExpandAll={treeExpandedState.expandAll}
|
||||
onCollapseAll={treeExpandedState.collapseAll}
|
||||
onConnect={handleHostConnect}
|
||||
onEditHost={handleEditHost}
|
||||
onDuplicateHost={handleDuplicateHost}
|
||||
onDeleteHost={(host) => onDeleteHost(host.id)}
|
||||
onCopyCredentials={handleCopyCredentials}
|
||||
onNewHost={(groupPath) => {
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(groupPath || null);
|
||||
setIsHostPanelOpen(true);
|
||||
}}
|
||||
onNewGroup={(parentPath) => {
|
||||
setTargetParentPath(parentPath || null);
|
||||
setNewFolderName("");
|
||||
setIsNewFolderOpen(true);
|
||||
}}
|
||||
onEditGroup={(groupPath) => {
|
||||
setRenameTargetPath(groupPath);
|
||||
const groupName = groupPath.split('/').pop() || '';
|
||||
setRenameGroupName(groupName);
|
||||
setRenameGroupError(null);
|
||||
setIsRenameGroupOpen(true);
|
||||
}}
|
||||
onDeleteGroup={(groupPath) => {
|
||||
setDeleteTargetPath(groupPath);
|
||||
setIsDeleteGroupOpen(true);
|
||||
}}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={handleUnmanageGroup}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
/>
|
||||
) : sortMode === "group" && groupedDisplayHosts ? (
|
||||
<div className="space-y-6">
|
||||
{groupedDisplayHosts.map((group) => (
|
||||
<div key={group.name || "__ungrouped__"}>
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border/40">
|
||||
@@ -2045,16 +2038,16 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
>
|
||||
{displayedHosts.map((host) => {
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
>
|
||||
{displayedHosts.map((host) => {
|
||||
const safeHost = sanitizeHost(host);
|
||||
const effectiveDistro = getEffectiveHostDistro(safeHost);
|
||||
const distroBadge = {
|
||||
@@ -2167,27 +2160,24 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
{displayedHosts.length === 0 && (
|
||||
<div className="col-span-full flex flex-col items-center justify-center py-24 text-muted-foreground">
|
||||
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
||||
<LayoutGrid size={32} className="opacity-60" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
{t('vault.hosts.empty.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm">
|
||||
{t('vault.hosts.empty.desc')}
|
||||
</p>
|
||||
})}
|
||||
{displayedHosts.length === 0 && (
|
||||
<div className="col-span-full flex flex-col items-center justify-center py-24 text-muted-foreground">
|
||||
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
||||
<LayoutGrid size={32} className="opacity-60" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
{t('vault.hosts.empty.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm">
|
||||
{t('vault.hosts.empty.desc')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{currentSection === "snippets" && (
|
||||
<SnippetsManager
|
||||
|
||||
@@ -75,17 +75,18 @@ export const ToolCall = ({
|
||||
: approvalStatus === 'denied'
|
||||
? 'border-red-500/20 bg-red-500/[0.03]'
|
||||
: 'border-border/25 bg-muted/10';
|
||||
const statusIconClass = 'shrink-0';
|
||||
|
||||
const statusIcon = approvalStatus === 'pending' ? (
|
||||
<ShieldAlert size={12} className="text-yellow-500/70 shrink-0" />
|
||||
<ShieldAlert size={12} className={cn('text-yellow-500/70', statusIconClass)} />
|
||||
) : isLoading ? (
|
||||
<Loader2 size={12} className="animate-spin text-blue-400/70" />
|
||||
<Loader2 size={12} className={cn('animate-spin text-blue-400/70', statusIconClass)} />
|
||||
) : isInterrupted ? (
|
||||
<Slash size={12} className="text-muted-foreground/55" />
|
||||
<Slash size={12} className={cn('text-muted-foreground/55', statusIconClass)} />
|
||||
) : isError ? (
|
||||
<XCircle size={12} className="text-red-400/70" />
|
||||
<XCircle size={12} className={cn('text-red-400/70', statusIconClass)} />
|
||||
) : result !== undefined ? (
|
||||
<CheckCircle2 size={12} className="text-green-400/70" />
|
||||
<CheckCircle2 size={12} className={cn('text-green-400/70', statusIconClass)} />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
@@ -105,7 +106,13 @@ export const ToolCall = ({
|
||||
? <ChevronDown size={12} className="text-muted-foreground/40 shrink-0" />
|
||||
: <ChevronRight size={12} className="text-muted-foreground/40 shrink-0" />
|
||||
}
|
||||
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
|
||||
{name === 'terminal_execute' && args?.command ? (
|
||||
<span className="font-mono text-muted-foreground/70 truncate" title={String(args.command)}>
|
||||
<span className="text-muted-foreground/40">$ </span>{String(args.command)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
{/* Approval badge for resolved approvals */}
|
||||
{approvalStatus === 'approved' && (
|
||||
|
||||
@@ -187,18 +187,27 @@ export const AgentIconBadge: React.FC<{
|
||||
|
||||
if (variant === 'plain') {
|
||||
return (
|
||||
<img
|
||||
src={visual.src}
|
||||
alt=""
|
||||
<div
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn('shrink-0', imageSize, visual.imageClassName, className)}
|
||||
className={cn('shrink-0', imageSize, className)}
|
||||
style={{
|
||||
maskImage: `url(${visual.src})`,
|
||||
WebkitMaskImage: `url(${visual.src})`,
|
||||
maskSize: 'contain',
|
||||
WebkitMaskSize: 'contain',
|
||||
maskRepeat: 'no-repeat',
|
||||
WebkitMaskRepeat: 'no-repeat',
|
||||
maskPosition: 'center',
|
||||
WebkitMaskPosition: 'center',
|
||||
backgroundColor: 'currentColor',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-agent-badge=""
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center overflow-hidden border',
|
||||
badgeSize,
|
||||
|
||||
@@ -208,7 +208,7 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
<DropdownContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="w-[288px] rounded-2xl border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-xl"
|
||||
className="w-[288px] overflow-hidden rounded-2xl border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-xl"
|
||||
>
|
||||
{BUILTIN_AGENTS.map((agent) => (
|
||||
<AgentMenuRow
|
||||
|
||||
@@ -144,12 +144,14 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
.flatMap((m) => m.toolResults?.map((tr) => tr.toolCallId) ?? []),
|
||||
);
|
||||
|
||||
// Build a map from toolCallId → toolName for display
|
||||
// Build maps from toolCallId → toolName / toolArgs for display
|
||||
const toolCallNames = new Map<string, string>();
|
||||
const toolCallArgs = new Map<string, Record<string, unknown>>();
|
||||
for (const m of visibleMessages) {
|
||||
if (m.role === 'assistant' && m.toolCalls) {
|
||||
for (const tc of m.toolCalls) {
|
||||
toolCallNames.set(tc.id, tc.name);
|
||||
if (tc.arguments) toolCallArgs.set(tc.id, tc.arguments);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,6 +180,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
<ToolCall
|
||||
key={tr.toolCallId}
|
||||
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
|
||||
args={toolCallArgs.get(tr.toolCallId)}
|
||||
result={tr.content}
|
||||
isError={tr.isError}
|
||||
/>
|
||||
|
||||
@@ -25,6 +25,8 @@ export default function SettingsAppearanceTab(props: {
|
||||
setUiLanguage: (language: string) => void;
|
||||
customCSS: string;
|
||||
setCustomCSS: (css: string) => void;
|
||||
isImmersive?: boolean;
|
||||
onToggleImmersive?: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const availableUIFonts = useAvailableUIFonts();
|
||||
@@ -45,6 +47,8 @@ export default function SettingsAppearanceTab(props: {
|
||||
setUiLanguage,
|
||||
customCSS,
|
||||
setCustomCSS,
|
||||
isImmersive,
|
||||
onToggleImmersive,
|
||||
} = props;
|
||||
|
||||
const getHslStyle = useCallback((hsl: string) => ({ backgroundColor: `hsl(${hsl})` }), []);
|
||||
@@ -254,6 +258,19 @@ export default function SettingsAppearanceTab(props: {
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.appearance.immersiveMode")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
label={t("settings.appearance.immersiveMode")}
|
||||
description={t("settings.appearance.immersiveMode.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={!!isImmersive}
|
||||
onChange={() => onToggleImmersive?.()}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.appearance.customCss")} />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -88,7 +88,7 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
<Link size={8} className="absolute -bottom-0.5 -right-0.5 text-muted-foreground" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<span className={cn("truncate", entry.type === 'symlink' && "italic pr-1")}>
|
||||
<span className={cn("truncate", entry.type === 'symlink' && "italic pr-1")} title={entry.name}>
|
||||
{entry.name}
|
||||
{entry.type === 'symlink' && <span className="sr-only"> (symbolic link)</span>}
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { AlertCircle, ArrowDown, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2 } from "lucide-react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { AlertCircle, ArrowDown, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2 } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ContextMenuTrigger,
|
||||
} from "../ui/context-menu";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { joinPath } from "../../application/state/sftp/utils";
|
||||
import type { SftpFileEntry } from "../../types";
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import type { ColumnWidths, SortField, SortOrder } from "./utils";
|
||||
@@ -58,6 +59,46 @@ interface SftpPaneFileListProps {
|
||||
visibleRows: { entry: SftpFileEntry; index: number; top: number }[];
|
||||
}
|
||||
|
||||
const SftpErrorWithLogs: React.FC<{
|
||||
error: string;
|
||||
connectionLogs: string[];
|
||||
onRetry: () => void;
|
||||
t: (key: string) => string;
|
||||
}> = ({ error, connectionLogs, onRetry, t }) => {
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2 text-destructive">
|
||||
<AlertCircle size={24} />
|
||||
<span className="text-sm text-center px-4">{t(error)}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
{t("sftp.retry")}
|
||||
</Button>
|
||||
{connectionLogs.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setShowLogs(!showLogs)}
|
||||
>
|
||||
<ChevronDown size={14} className={`mr-1 transition-transform ${showLogs ? 'rotate-180' : ''}`} />
|
||||
{showLogs ? "Hide logs" : "Show logs"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{showLogs && connectionLogs.length > 0 && (
|
||||
<div className="w-full max-w-sm mt-1 p-2 rounded-md bg-secondary/50 border border-border/60 space-y-0.5 max-h-40 overflow-y-auto">
|
||||
{connectionLogs.map((log, i) => (
|
||||
<div key={i} className="text-[11px] text-muted-foreground truncate font-mono">
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
t,
|
||||
pane,
|
||||
@@ -178,6 +219,14 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
<Copy size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.copyToOtherPane")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(joinPath(pane.connection.currentPath, entry.name));
|
||||
}}
|
||||
>
|
||||
<ClipboardCopy size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.copyPath")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => openRenameDialog(entry.name)}>
|
||||
<Pencil size={14} className="mr-2" /> {t("common.rename")}
|
||||
@@ -340,17 +389,25 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
onScroll={handleFileListScroll}
|
||||
>
|
||||
{pane.loading && sortedDisplayFiles.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
{pane.connectionLogs.length > 0 && (
|
||||
<div className="w-full max-w-sm mt-2 space-y-0.5 px-4">
|
||||
{pane.connectionLogs.map((log, i) => (
|
||||
<div key={i} className="text-[11px] text-muted-foreground truncate">
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : pane.error && !pane.reconnecting ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2 text-destructive">
|
||||
<AlertCircle size={24} />
|
||||
<span className="text-sm">{t(pane.error)}</span>
|
||||
<Button variant="outline" size="sm" onClick={onRefresh}>
|
||||
{t("sftp.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
<SftpErrorWithLogs
|
||||
error={pane.error}
|
||||
connectionLogs={pane.connectionLogs}
|
||||
onRetry={onRefresh}
|
||||
t={t}
|
||||
/>
|
||||
) : sortedDisplayFiles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Folder size={32} className="mb-2 opacity-50" />
|
||||
@@ -410,10 +467,19 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Loading overlay - covers entire pane when navigating directories */}
|
||||
{/* Loading overlay - covers entire pane when navigating or reconnecting */}
|
||||
{pane.loading && sortedDisplayFiles.length > 0 && !pane.reconnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/40 backdrop-blur-[1px] z-10">
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-background/40 backdrop-blur-[1px] z-10">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
{pane.connectionLogs.length > 0 && (
|
||||
<div className="w-full max-w-sm mt-2 space-y-0.5 px-4">
|
||||
{pane.connectionLogs.map((log, i) => (
|
||||
<div key={i} className="text-[11px] text-muted-foreground truncate">
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ const ThemeItem = memo(({
|
||||
onClick={() => onSelect(theme.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(theme.id); } }}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors group cursor-pointer',
|
||||
'w-full flex items-center gap-2.5 px-3 py-2 text-left group cursor-pointer',
|
||||
isSelected
|
||||
? 'bg-accent/50'
|
||||
: 'hover:bg-accent/50'
|
||||
|
||||
@@ -48,7 +48,7 @@ interface UseServerStatsOptions {
|
||||
sessionId: string;
|
||||
enabled: boolean; // Whether stats collection is enabled (from settings)
|
||||
refreshInterval: number; // Refresh interval in seconds
|
||||
isLinux: boolean; // Only collect stats for Linux servers
|
||||
isSupportedOs: boolean; // Only collect stats for Linux/macOS servers
|
||||
isConnected: boolean; // Only collect when connected
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export function useServerStats({
|
||||
sessionId,
|
||||
enabled,
|
||||
refreshInterval,
|
||||
isLinux,
|
||||
isSupportedOs,
|
||||
isConnected,
|
||||
}: UseServerStatsOptions) {
|
||||
const [stats, setStats] = useState<ServerStats>({
|
||||
@@ -86,7 +86,7 @@ export function useServerStats({
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
if (!enabled || !isLinux || !isConnected || !sessionId) {
|
||||
if (!enabled || !isSupportedOs || !isConnected || !sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ export function useServerStats({
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [sessionId, enabled, isLinux, isConnected]);
|
||||
}, [sessionId, enabled, isSupportedOs, isConnected]);
|
||||
|
||||
// Initial fetch and periodic refresh
|
||||
useEffect(() => {
|
||||
@@ -149,8 +149,7 @@ export function useServerStats({
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
// Don't run if not enabled or not a Linux system
|
||||
if (!enabled || !isLinux || !isConnected) {
|
||||
if (!enabled || !isSupportedOs || !isConnected) {
|
||||
// Reset stats when disabled or not connected
|
||||
setStats({
|
||||
cpu: null,
|
||||
@@ -193,7 +192,7 @@ export function useServerStats({
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, isLinux, isConnected, refreshInterval, fetchStats]);
|
||||
}, [enabled, isSupportedOs, isConnected, refreshInterval, fetchStats]);
|
||||
|
||||
// Manual refresh function
|
||||
const refresh = useCallback(() => {
|
||||
|
||||
@@ -323,7 +323,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
: undefined;
|
||||
|
||||
const jumpHostsWithUnavailableCredentials: string[] = [];
|
||||
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost) => {
|
||||
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost, index) => {
|
||||
const jumpAuth = resolveHostAuth({
|
||||
host: jumpHost,
|
||||
keys: ctx.keys,
|
||||
@@ -336,13 +336,20 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
const jumpPassword = sanitizeCredentialValue(rawJumpPassword);
|
||||
const jumpPrivateKey = sanitizeCredentialValue(rawJumpPrivateKey);
|
||||
const jumpPassphrase = sanitizeCredentialValue(rawJumpPassphrase);
|
||||
const hasConfiguredJumpProxyEndpoint =
|
||||
index === 0 &&
|
||||
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
|
||||
const hasEncryptedJumpProxyCredential =
|
||||
hasConfiguredJumpProxyEndpoint &&
|
||||
Boolean(jumpHost.proxyConfig?.username) &&
|
||||
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig?.password);
|
||||
|
||||
const hasEncryptedJumpCredential =
|
||||
isEncryptedCredentialPlaceholder(rawJumpPassword) ||
|
||||
isEncryptedCredentialPlaceholder(rawJumpPrivateKey) ||
|
||||
isEncryptedCredentialPlaceholder(rawJumpPassphrase);
|
||||
|
||||
if (hasEncryptedJumpCredential && !jumpPassword && !jumpPrivateKey) {
|
||||
if (hasEncryptedJumpProxyCredential || (hasEncryptedJumpCredential && !jumpPassword && !jumpPrivateKey)) {
|
||||
jumpHostsWithUnavailableCredentials.push(jumpHost.label || jumpHost.hostname);
|
||||
}
|
||||
|
||||
@@ -358,10 +365,21 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
keyId: jumpAuth.keyId,
|
||||
keySource: jumpKey?.source,
|
||||
label: jumpHost.label,
|
||||
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
|
||||
? {
|
||||
type: jumpHost.proxyConfig.type,
|
||||
host: jumpHost.proxyConfig.host,
|
||||
port: jumpHost.proxyConfig.port,
|
||||
username: jumpHost.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
|
||||
}
|
||||
: undefined,
|
||||
identityFilePaths: jumpHost.identityFilePaths,
|
||||
};
|
||||
});
|
||||
|
||||
if (hasEncryptedProxyPassword && !proxyConfig?.password && proxyConfig?.username) {
|
||||
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts[0]?.proxy;
|
||||
if (usesTargetProxyForFirstHop && hasEncryptedProxyPassword && !proxyConfig?.password && proxyConfig?.username) {
|
||||
const message = tr(
|
||||
"terminal.auth.proxyCredentialsUnavailable",
|
||||
"Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.",
|
||||
@@ -431,7 +449,15 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
logLine = `${prefix}${label} - ${tr("terminal.progress.keyExchangeComplete", "Key exchange complete")}`;
|
||||
break;
|
||||
case 'auth-attempt':
|
||||
logLine = `${prefix}${label} - ${tr("terminal.progress.trying", "Trying")} ${error}...`;
|
||||
if (error?.endsWith('rejected')) {
|
||||
logLine = `${prefix}${label} - ✗ ${error}`;
|
||||
} else if (error === 'all methods exhausted') {
|
||||
logLine = `${prefix}${label} - ✗ All authentication methods exhausted`;
|
||||
} else if (error === 'waiting for user input...' || error === 'user responded') {
|
||||
logLine = `${prefix}${label} - ${error}`;
|
||||
} else {
|
||||
logLine = `${prefix}${label} - ${tr("terminal.progress.trying", "Trying")} ${error}...`;
|
||||
}
|
||||
break;
|
||||
case 'authenticated':
|
||||
logLine = `${prefix}${label} - ${tr("terminal.progress.authenticated", "Authenticated")}`;
|
||||
@@ -491,6 +517,8 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
keepaliveInterval: ctx.terminalSettings?.keepaliveInterval,
|
||||
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||||
// Only pass local key paths if no vault key is explicitly configured
|
||||
identityFilePaths: attempt.key ? undefined : ctx.host.identityFilePaths,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -582,6 +610,18 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
}
|
||||
}, 600);
|
||||
}
|
||||
|
||||
// Run OS detection only after successful connection
|
||||
setTimeout(
|
||||
() =>
|
||||
void runDistroDetection(ctx, {
|
||||
username: effectiveUsername,
|
||||
password: usedPassword,
|
||||
key: usedKey,
|
||||
passphrase: effectivePassphrase,
|
||||
}),
|
||||
600,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const authError = isAuthError(err);
|
||||
@@ -607,17 +647,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
ctx.setChainProgress(null);
|
||||
if (unsubscribeChainProgress) unsubscribeChainProgress();
|
||||
}
|
||||
|
||||
setTimeout(
|
||||
() =>
|
||||
void runDistroDetection(ctx, {
|
||||
username: effectiveUsername,
|
||||
password: usedPassword,
|
||||
key: usedKey,
|
||||
passphrase: effectivePassphrase,
|
||||
}),
|
||||
600,
|
||||
);
|
||||
};
|
||||
|
||||
const startTelnet = async (term: XTerm) => {
|
||||
|
||||
@@ -113,6 +113,9 @@ export interface Host {
|
||||
keywordHighlightEnabled?: boolean;
|
||||
// Legacy SSH algorithm support for older network equipment (switches, routers)
|
||||
legacyAlgorithms?: boolean;
|
||||
// Local SSH key file paths (from SSH config IdentityFile or user-added)
|
||||
// Resolved at connection time — the app reads the file content when connecting.
|
||||
identityFilePaths?: string[];
|
||||
}
|
||||
|
||||
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
|
||||
@@ -534,7 +537,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
scrollOnOutput: false,
|
||||
scrollOnKeyPress: false,
|
||||
scrollOnPaste: true,
|
||||
smoothScrolling: true,
|
||||
smoothScrolling: false,
|
||||
rightClickBehavior: 'context-menu',
|
||||
copyOnSelect: false,
|
||||
middleClickPaste: true,
|
||||
|
||||
@@ -113,6 +113,15 @@ export const serializeHostsToSshConfig = (hosts: Host[], allHosts?: Host[]): str
|
||||
lines.push(` Port ${host.port}`);
|
||||
}
|
||||
|
||||
// Serialize IdentityFile paths
|
||||
if (host.identityFilePaths && host.identityFilePaths.length > 0) {
|
||||
for (const keyPath of host.identityFilePaths) {
|
||||
// Quote paths that contain spaces
|
||||
const formatted = keyPath.includes(" ") ? `"${keyPath}"` : keyPath;
|
||||
lines.push(` IdentityFile ${formatted}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize ProxyJump if host has a chain
|
||||
const proxyJumpValue = buildProxyJumpValue(host, hostsForLookup, managedHostIds);
|
||||
if (proxyJumpValue) {
|
||||
|
||||
@@ -198,6 +198,8 @@ export interface SyncPayload {
|
||||
sftpShowHiddenFiles?: boolean;
|
||||
sftpUseCompressedUpload?: boolean;
|
||||
sftpAutoOpenSidebar?: boolean;
|
||||
// Immersive mode
|
||||
immersiveMode?: boolean;
|
||||
};
|
||||
|
||||
// Sync metadata
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_CUSTOM_THEMES,
|
||||
STORAGE_KEY_IMMERSIVE_MODE,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -158,6 +159,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
|
||||
|
||||
// Immersive mode
|
||||
const immersive = localStorageAdapter.readString(STORAGE_KEY_IMMERSIVE_MODE);
|
||||
if (immersive === 'true' || immersive === 'false') settings.immersiveMode = immersive === 'true';
|
||||
|
||||
return Object.keys(settings).length > 0 ? settings : undefined;
|
||||
}
|
||||
|
||||
@@ -216,6 +221,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
|
||||
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
|
||||
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
|
||||
|
||||
// Immersive mode
|
||||
if (settings.immersiveMode != null) localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, String(settings.immersiveMode));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -519,6 +519,7 @@ const importFromSshConfig = (text: string): VaultImportResult => {
|
||||
username?: string;
|
||||
port?: number;
|
||||
proxyJump?: string;
|
||||
identityFiles?: string[];
|
||||
};
|
||||
|
||||
const blocks: Block[] = [];
|
||||
@@ -557,6 +558,12 @@ const importFromSshConfig = (text: string): VaultImportResult => {
|
||||
else if (keyword === "user") current.username = value;
|
||||
else if (keyword === "port") current.port = parsePort(value);
|
||||
else if (keyword === "proxyjump") current.proxyJump = value;
|
||||
else if (keyword === "identityfile") {
|
||||
if (!current.identityFiles) current.identityFiles = [];
|
||||
// Remove surrounding quotes (ssh_config allows quoted paths with spaces)
|
||||
const unquoted = value.replace(/^["']|["']$/g, "");
|
||||
current.identityFiles.push(unquoted);
|
||||
}
|
||||
}
|
||||
|
||||
flush();
|
||||
@@ -597,6 +604,11 @@ const importFromSshConfig = (text: string): VaultImportResult => {
|
||||
protocol: "ssh",
|
||||
});
|
||||
|
||||
// Attach IdentityFile paths if present
|
||||
if (block.identityFiles && block.identityFiles.length > 0) {
|
||||
host.identityFilePaths = [...block.identityFiles];
|
||||
}
|
||||
|
||||
parsedHosts.push(host);
|
||||
|
||||
// Store ProxyJump using hostname key (survives deduplication)
|
||||
|
||||
@@ -6,7 +6,12 @@ module.exports = {
|
||||
productName: 'Netcatty',
|
||||
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
|
||||
icon: 'public/icon.png',
|
||||
npmRebuild: false,
|
||||
// npmRebuild must stay enabled for macOS and Windows builds — without it,
|
||||
// node-pty's native module is not recompiled for the Electron ABI, causing
|
||||
// "posix_spawnp failed" on macOS. Linux builds set npm_config_arch in CI
|
||||
// and run ensure-node-pty-linux.sh before packaging, so the rebuild is
|
||||
// redundant but harmless there.
|
||||
npmRebuild: true,
|
||||
directories: {
|
||||
buildResources: 'build',
|
||||
output: 'release'
|
||||
|
||||
@@ -43,53 +43,89 @@ function subscribeToPtyData(ptyStream, onData) {
|
||||
throw new Error("PTY stream does not support data subscriptions");
|
||||
}
|
||||
|
||||
function hasExpectedPromptSuffix(text, expectedPrompt) {
|
||||
if (!expectedPrompt) return false;
|
||||
const normalizedText = stripAnsi(String(text || "")).replace(/\r/g, "");
|
||||
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
|
||||
return !!normalizedPrompt && normalizedText.endsWith(normalizedPrompt);
|
||||
}
|
||||
|
||||
function escapePosixSingleQuoted(text) {
|
||||
return String(text || "").replace(/'/g, "'\\''");
|
||||
}
|
||||
|
||||
function escapePowerShellSingleQuoted(text) {
|
||||
return String(text || "").replace(/'/g, "''");
|
||||
}
|
||||
|
||||
function escapeFishSingleQuoted(text) {
|
||||
return String(text || "").replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||
}
|
||||
|
||||
function escapeCmdForNestedShell(text) {
|
||||
return String(text || "").replace(/"/g, '""').replace(/%/g, "%%");
|
||||
}
|
||||
|
||||
function buildWrappedCommand(command, shellKind, marker) {
|
||||
switch (shellKind) {
|
||||
case "powershell": {
|
||||
// Combine into 2 PTY lines (like posix) to minimise prompt echo duplication:
|
||||
// Line 1: start marker + pager env + user command
|
||||
// Line 2: capture exit code + end marker
|
||||
// __NCMCP_ prefix ensures the echo line is buffered/filtered even if
|
||||
// the PTY delivers it in small chunks (the marker must appear early).
|
||||
const psPager = "$env:PAGER='cat'; $env:SYSTEMD_PAGER=''; $env:GIT_PAGER='cat'; $env:LESS=''; ";
|
||||
const psEscaped = escapePowerShellSingleQuoted(command);
|
||||
return (
|
||||
`Write-Output '${marker}_S'; ${psPager}${command}\r\n` +
|
||||
`Write-Output "${marker}_E:$LASTEXITCODE"\r\n`
|
||||
`$${marker}=0; $${marker}_cmd='${psEscaped}'; Write-Host '> ${psEscaped}'; & { Write-Output '${marker}_S'; ${psPager}$LASTEXITCODE=$null; try { Invoke-Expression $${marker}_cmd; $${marker}_rc = if ($LASTEXITCODE -ne $null) { $LASTEXITCODE } elseif ($?) { 0 } else { 1 } } catch { $${marker}_rc = 1 }; Write-Output "${marker}_E:$${marker}_rc" }\r\n`
|
||||
);
|
||||
}
|
||||
|
||||
case "cmd":
|
||||
return [
|
||||
'set "PAGER=cat"',
|
||||
'set "SYSTEMD_PAGER="',
|
||||
'set "GIT_PAGER=cat"',
|
||||
'set "LESS="',
|
||||
`echo ${marker}_S`,
|
||||
command,
|
||||
`echo ${marker}_E:%errorlevel%`,
|
||||
"",
|
||||
].join("\r\n");
|
||||
case "cmd": {
|
||||
const cmdEscaped = escapeCmdForNestedShell(command);
|
||||
return (
|
||||
`set "${marker}=0" & set "${marker}_CMD=${cmdEscaped}" & call <nul set /p "=^> %%${marker}_CMD%%" & echo( & (echo ${marker}_S & set "PAGER=cat" & set "SYSTEMD_PAGER=" & set "GIT_PAGER=cat" & set "LESS=" & call cmd /d /s /c "%%${marker}_CMD%%" & call echo ${marker}_E:^%errorlevel^%)\r\n`
|
||||
);
|
||||
}
|
||||
|
||||
case "fish":
|
||||
return [
|
||||
"set -gx PAGER cat",
|
||||
"set -gx SYSTEMD_PAGER ''",
|
||||
"set -gx GIT_PAGER cat",
|
||||
"set -gx LESS ''",
|
||||
`printf '%s\\n' '${marker}_S'`,
|
||||
command,
|
||||
"set __NCMCP_rc $status",
|
||||
`printf '%s\\n' '${marker}_E:'$__NCMCP_rc`,
|
||||
"",
|
||||
].join("\n");
|
||||
// set __NCMCP_... at the start ensures early marker presence in echo.
|
||||
return (
|
||||
`set ${marker} 0; function __ncmcp_int --on-signal INT; printf '%s\\n' '${marker}_E:130'; functions -e __ncmcp_int; end; ` +
|
||||
// Clear the current terminal row before the user-visible echo.
|
||||
`set -l ${marker}_cmd '${escapeFishSingleQuoted(command)}'; printf '\\r\\033[2K> %s\\n' '${escapeFishSingleQuoted(command)}'; ` +
|
||||
`begin; set -gx PAGER cat; set -gx SYSTEMD_PAGER ''; set -gx GIT_PAGER cat; set -gx LESS ''; ` +
|
||||
`printf '%s\\n' '${marker}_S'; eval -- \$${marker}_cmd; set __NCMCP_rc $status; ` +
|
||||
`functions -e __ncmcp_int; printf '%s\\n' '${marker}_E:'\$__NCMCP_rc; end\n`
|
||||
);
|
||||
|
||||
case "posix":
|
||||
default: {
|
||||
// Combine into 2 PTY lines to minimise prompt echo duplication:
|
||||
// Line 1: start marker + pager env + user command
|
||||
// Line 2: capture exit code + end marker + restore exit code
|
||||
// Single-line compound command with early marker & visible command echo.
|
||||
//
|
||||
// Layout: __NCMCP_xxx=0; printf echo; { ... MARKER_S; eval command; MARKER_E; }
|
||||
//
|
||||
// Key design decisions:
|
||||
//
|
||||
// 1) __NCMCP_xxx=0 at the VERY START ensures the PTY echo line
|
||||
// contains __NCMCP_ in its first few bytes. This is critical:
|
||||
// preload.cjs filters chunks by buffering incomplete lines that
|
||||
// contain __NCMCP_. Without this prefix, the first chunk of a
|
||||
// long echo line might not contain the marker and would leak
|
||||
// through to the terminal as garbage.
|
||||
//
|
||||
// 2) printf clears the current row and outputs "> command\n"
|
||||
// (no marker) → visible to user without prompt residue.
|
||||
//
|
||||
// 3) The user command is executed via eval on a quoted string. This
|
||||
// keeps shell syntax errors inside the eval call so the wrapper
|
||||
// can still emit the end marker and return a non-zero exit code.
|
||||
//
|
||||
// 4) Single-line { ... } is parsed fully before execution, so SIGINT
|
||||
// cannot cause bash to flush the end marker from the input buffer.
|
||||
// trap ':' INT lets child processes receive SIGINT normally while
|
||||
// preventing the shell from aborting the compound command.
|
||||
const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= ";
|
||||
const escaped = escapePosixSingleQuoted(command);
|
||||
return (
|
||||
`printf '%s\\n' '${marker}_S';${noPager}${command}\n` +
|
||||
`__NCMCP_rc=$?;printf '%s\\n' '${marker}_E:'"$__NCMCP_rc";(exit $__NCMCP_rc)\n`
|
||||
`${marker}=0; ${marker}_cmd='${escaped}'; printf '\\r\\033[2K> %s\\n' '${escaped}'; { printf '%s\\n' '${marker}_S'; trap ':' INT; ${noPager}eval "$${marker}_cmd"; __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"; (exit $__NCMCP_rc); }\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -106,6 +142,9 @@ function buildWrappedCommand(command, shellKind, marker) {
|
||||
* @param {boolean} [options.stripMarkers=false] - Strip leaked MCP markers from output
|
||||
* @param {Map} [options.trackForCancellation] - Map to register this execution in for cancellation
|
||||
* @param {number} [options.timeoutMs=60000] - Command timeout in milliseconds
|
||||
* @param {string} [options.chatSessionId] - Chat session ID for scoped cancellation
|
||||
* @param {AbortSignal} [options.abortSignal] - AbortSignal to cancel execution
|
||||
* @param {string} [options.expectedPrompt] - Last observed idle prompt for exact fallback matching
|
||||
*/
|
||||
function execViaPty(ptyStream, command, options) {
|
||||
const {
|
||||
@@ -113,33 +152,51 @@ function execViaPty(ptyStream, command, options) {
|
||||
trackForCancellation = null,
|
||||
timeoutMs = 60000,
|
||||
shellKind,
|
||||
chatSessionId,
|
||||
abortSignal,
|
||||
expectedPrompt,
|
||||
} = options || {};
|
||||
|
||||
const marker = `__NCMCP_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`;
|
||||
const resolvedShellKind = shellKind || "posix";
|
||||
|
||||
// Fast-path: already aborted before we even start
|
||||
if (abortSignal?.aborted) {
|
||||
return Promise.resolve({ ok: false, stdout: "", stderr: "", exitCode: -1, error: "Cancelled" });
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let output = "";
|
||||
let foundStart = false;
|
||||
let timeoutId = null;
|
||||
let promptFallbackTimer = null;
|
||||
let finished = false;
|
||||
let unsubscribe = null;
|
||||
const cleanupFns = [];
|
||||
|
||||
// Buffer for incomplete line data when searching for start marker.
|
||||
// SSH channels can split data at arbitrary byte boundaries, so the
|
||||
// start marker may arrive across two chunks. We keep the content
|
||||
// after the last \n (i.e. the current incomplete line) and prepend
|
||||
// it to the next chunk so indexOf can match the full marker.
|
||||
let pendingStart = "";
|
||||
|
||||
const onData = (data) => {
|
||||
const text = data.toString();
|
||||
|
||||
if (!foundStart) {
|
||||
// Look for the start marker at a line boundary (actual printf output),
|
||||
// not inside the echo of the printf command argument.
|
||||
const combined = pendingStart + text;
|
||||
pendingStart = "";
|
||||
const startMarker = marker + "_S";
|
||||
let matched = false;
|
||||
let pos = 0;
|
||||
while (pos < text.length) {
|
||||
const idx = text.indexOf(startMarker, pos);
|
||||
while (pos < combined.length) {
|
||||
const idx = combined.indexOf(startMarker, pos);
|
||||
if (idx === -1) break;
|
||||
// Accept if at start of text, or preceded by \n or \r (line boundary)
|
||||
if (idx === 0 || text[idx - 1] === '\n' || text[idx - 1] === '\r') {
|
||||
if (idx === 0 || combined[idx - 1] === '\n' || combined[idx - 1] === '\r') {
|
||||
foundStart = true;
|
||||
const afterMarker = text.slice(idx);
|
||||
matched = true;
|
||||
const afterMarker = combined.slice(idx);
|
||||
const nlIdx = afterMarker.indexOf("\n");
|
||||
if (nlIdx !== -1) {
|
||||
output += afterMarker.slice(nlIdx + 1);
|
||||
@@ -148,14 +205,42 @@ function execViaPty(ptyStream, command, options) {
|
||||
}
|
||||
pos = idx + 1;
|
||||
}
|
||||
if (foundStart) checkEnd();
|
||||
if (!matched) {
|
||||
// Keep the last incomplete line for cross-chunk matching
|
||||
const lastNl = combined.lastIndexOf("\n");
|
||||
pendingStart = lastNl === -1 ? combined : combined.slice(lastNl + 1);
|
||||
}
|
||||
if (foundStart) {
|
||||
schedulePromptFallback();
|
||||
checkEnd();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
output += text;
|
||||
schedulePromptFallback();
|
||||
checkEnd();
|
||||
};
|
||||
|
||||
function clearPromptFallback() {
|
||||
if (promptFallbackTimer) {
|
||||
clearTimeout(promptFallbackTimer);
|
||||
promptFallbackTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePromptFallback() {
|
||||
clearPromptFallback();
|
||||
if (!hasExpectedPromptSuffix(output, expectedPrompt)) return;
|
||||
|
||||
// Fallback for shells that visibly return to the same idle prompt but
|
||||
// never emit the wrapped end marker line.
|
||||
promptFallbackTimer = setTimeout(() => {
|
||||
if (!hasExpectedPromptSuffix(output, expectedPrompt)) return;
|
||||
finish(output, null, null);
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function checkEnd() {
|
||||
// Look for the end marker at a line boundary (actual printf output),
|
||||
// not inside the echo of the printf command argument.
|
||||
@@ -179,39 +264,43 @@ function execViaPty(ptyStream, command, options) {
|
||||
}
|
||||
}
|
||||
|
||||
function finish(stdout, exitCode) {
|
||||
function finish(stdout, exitCode, error) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timeoutId);
|
||||
clearPromptFallback();
|
||||
unsubscribe?.();
|
||||
for (const fn of cleanupFns) { try { fn(); } catch { /* ignore */ } }
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.delete(marker);
|
||||
}
|
||||
|
||||
let cleaned = stripAnsi(stdout || "").trim();
|
||||
let cleaned = stripAnsi(stdout || "").replace(/\r/g, "");
|
||||
if (stripMarkers) {
|
||||
cleaned = cleaned.replace(/^[^\r\n]*__NCMCP_[^\r\n]*[\r\n]*/gm, "").trim();
|
||||
cleaned = cleaned.replace(/^[^\r\n]*__NCMCP_[^\r\n]*[\r\n]*/gm, "");
|
||||
}
|
||||
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
|
||||
if (normalizedPrompt && cleaned.endsWith(normalizedPrompt)) {
|
||||
cleaned = cleaned.slice(0, cleaned.length - normalizedPrompt.length);
|
||||
}
|
||||
cleaned = cleaned.trim();
|
||||
if (error) {
|
||||
resolve({ ok: false, stdout: cleaned, stderr: "", exitCode: exitCode ?? -1, error });
|
||||
} else {
|
||||
resolve({
|
||||
ok: exitCode === 0 || exitCode === null,
|
||||
stdout: cleaned,
|
||||
stderr: "",
|
||||
exitCode: exitCode ?? 0,
|
||||
});
|
||||
}
|
||||
resolve({
|
||||
ok: exitCode === 0 || exitCode === null,
|
||||
stdout: cleaned,
|
||||
stderr: "",
|
||||
exitCode: exitCode ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
unsubscribe?.();
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.delete(marker);
|
||||
}
|
||||
// Send Ctrl+C to kill the timed-out command
|
||||
if (typeof ptyStream.write === "function") ptyStream.write("\x03");
|
||||
const cleaned = stripAnsi(output).trim();
|
||||
const timeoutSec = Math.round(timeoutMs / 1000);
|
||||
resolve({ ok: false, stdout: cleaned, stderr: "", exitCode: -1, error: `Command timed out (${timeoutSec}s)` });
|
||||
finish(output, -1, `Command timed out (${timeoutSec}s)`);
|
||||
}, timeoutMs);
|
||||
|
||||
unsubscribe = subscribeToPtyData(ptyStream, onData);
|
||||
@@ -220,6 +309,11 @@ function execViaPty(ptyStream, command, options) {
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.set(marker, {
|
||||
ptyStream,
|
||||
chatSessionId: chatSessionId || null,
|
||||
cancel: () => {
|
||||
if (typeof ptyStream.write === "function") ptyStream.write("\x03");
|
||||
finish(output, -1, "Cancelled");
|
||||
},
|
||||
cleanup: () => {
|
||||
clearTimeout(timeoutId);
|
||||
unsubscribe?.();
|
||||
@@ -227,6 +321,35 @@ function execViaPty(ptyStream, command, options) {
|
||||
});
|
||||
}
|
||||
|
||||
// Stream close/error detection — resolve immediately instead of waiting for timeout
|
||||
if (typeof ptyStream.on === "function") {
|
||||
const onClose = () => finish(output, null, "Stream closed unexpectedly");
|
||||
const onError = (err) => finish(output, -1, `Stream error: ${err?.message || err}`);
|
||||
ptyStream.on("close", onClose);
|
||||
ptyStream.on("end", onClose);
|
||||
ptyStream.on("error", onError);
|
||||
cleanupFns.push(() => {
|
||||
try { ptyStream.removeListener("close", onClose); } catch { /* */ }
|
||||
try { ptyStream.removeListener("end", onClose); } catch { /* */ }
|
||||
try { ptyStream.removeListener("error", onError); } catch { /* */ }
|
||||
});
|
||||
}
|
||||
// node-pty uses onExit instead of close/end
|
||||
if (typeof ptyStream.onExit === "function") {
|
||||
const disposable = ptyStream.onExit(() => finish(output, null, "Process exited"));
|
||||
cleanupFns.push(() => { try { disposable?.dispose?.(); } catch { /* */ } });
|
||||
}
|
||||
|
||||
// AbortSignal handling — send Ctrl+C and resolve when aborted
|
||||
if (abortSignal) {
|
||||
const onAbort = () => {
|
||||
if (typeof ptyStream.write === "function") ptyStream.write("\x03");
|
||||
finish(output, -1, "Cancelled");
|
||||
};
|
||||
abortSignal.addEventListener("abort", onAbort, { once: true });
|
||||
cleanupFns.push(() => abortSignal.removeEventListener("abort", onAbort));
|
||||
}
|
||||
|
||||
// Markers are filtered from terminal display by preload.cjs (MCP_MARKER_RE).
|
||||
ptyStream.write(buildWrappedCommand(command, resolvedShellKind, marker));
|
||||
});
|
||||
@@ -244,6 +367,7 @@ function execViaChannel(sshClient, command, options) {
|
||||
const {
|
||||
timeoutMs = 60000,
|
||||
trackForCancellation = null,
|
||||
chatSessionId,
|
||||
} = options || {};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
@@ -276,6 +400,11 @@ function execViaChannel(sshClient, command, options) {
|
||||
}, timeoutMs);
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.set(marker, {
|
||||
chatSessionId: chatSessionId || null,
|
||||
cancel: () => {
|
||||
try { execStream.close(); } catch { /* ignore */ }
|
||||
finish({ ok: false, stdout, stderr, exitCode: -1, error: "Cancelled" });
|
||||
},
|
||||
cleanup: () => {
|
||||
clearTimeout(timeoutId);
|
||||
try { execStream.close(); } catch { /* ignore */ }
|
||||
|
||||
@@ -16,6 +16,7 @@ const ANSI_ESCAPE_REGEX = /\u001B\[[0-?]*[ -/]*[@-~]/g;
|
||||
const ANSI_OSC_REGEX = /\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g;
|
||||
const URL_CANDIDATE_REGEX = /https?:\/\/[^\s]+/g;
|
||||
const WINDOWS_RUNNABLE_EXTENSIONS = [".exe", ".cmd", ".bat", ".com"];
|
||||
const MAX_PROMPT_TRACK_TAIL = 4096;
|
||||
|
||||
// ── ANSI stripping ──
|
||||
|
||||
@@ -23,6 +24,36 @@ function stripAnsi(input) {
|
||||
return String(input || "").replace(ANSI_OSC_REGEX, "").replace(ANSI_ESCAPE_REGEX, "");
|
||||
}
|
||||
|
||||
function extractTrailingIdlePrompt(output) {
|
||||
const normalized = stripAnsi(output).replace(/\r/g, "");
|
||||
if (!normalized || normalized.endsWith("\n")) return "";
|
||||
|
||||
const lastLine = normalized.split("\n").pop() || "";
|
||||
const rightTrimmed = lastLine.replace(/\s+$/, "");
|
||||
if (!rightTrimmed) return "";
|
||||
|
||||
if (/^[^\s@]+@[^\s:]+(?::[^\n\r]*)?[#$]$/.test(rightTrimmed)) {
|
||||
return lastLine;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function trackSessionIdlePrompt(session, chunk) {
|
||||
if (!session || typeof chunk !== "string" || !chunk) return "";
|
||||
|
||||
const nextTail = `${session._promptTrackTail || ""}${chunk}`.slice(-MAX_PROMPT_TRACK_TAIL);
|
||||
session._promptTrackTail = nextTail;
|
||||
|
||||
const prompt = extractTrailingIdlePrompt(nextTail);
|
||||
if (prompt) {
|
||||
session.lastIdlePrompt = prompt;
|
||||
session.lastIdlePromptAt = Date.now();
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
// ── URL helpers ──
|
||||
|
||||
function isLocalhostHostname(hostname) {
|
||||
@@ -271,6 +302,8 @@ function serializeStreamChunk(chunk) {
|
||||
|
||||
module.exports = {
|
||||
stripAnsi,
|
||||
extractTrailingIdlePrompt,
|
||||
trackSessionIdlePrompt,
|
||||
isLocalhostHostname,
|
||||
extractFirstNonLocalhostUrl,
|
||||
normalizeCliPathForPlatform,
|
||||
|
||||
@@ -60,6 +60,7 @@ const MAX_CONCURRENT_AGENTS = 5;
|
||||
const acpProviders = new Map();
|
||||
const acpActiveStreams = new Map();
|
||||
const acpRequestSessions = new Map();
|
||||
const acpPendingCancelRequests = new Set();
|
||||
const acpForceProviderReset = new Set();
|
||||
const acpChatRuns = new Map();
|
||||
|
||||
@@ -881,7 +882,7 @@ function registerHandlers(ipcMain) {
|
||||
});
|
||||
|
||||
// Execute a command on a terminal session (for Catty Agent)
|
||||
ipcMain.handle("netcatty:ai:exec", async (event, { sessionId, command }) => {
|
||||
ipcMain.handle("netcatty:ai:exec", async (event, { sessionId, command, chatSessionId }) => {
|
||||
// Validate IPC sender (Issue #17)
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
@@ -915,8 +916,11 @@ function registerHandlers(ipcMain) {
|
||||
const timeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
|
||||
return execViaPty(ptyStream, command, {
|
||||
stripMarkers: true,
|
||||
trackForCancellation: mcpServerBridge.activePtyExecs,
|
||||
timeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
chatSessionId,
|
||||
expectedPrompt: session.lastIdlePrompt || "",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -925,7 +929,11 @@ function registerHandlers(ipcMain) {
|
||||
if (sshClient && typeof sshClient.exec === "function") {
|
||||
const { execViaChannel } = require("./ai/ptyExec.cjs");
|
||||
const channelTimeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
|
||||
return execViaChannel(sshClient, command, { timeoutMs: channelTimeoutMs });
|
||||
return execViaChannel(sshClient, command, {
|
||||
timeoutMs: channelTimeoutMs,
|
||||
trackForCancellation: mcpServerBridge.activePtyExecs,
|
||||
chatSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: false, error: "No terminal stream or SSH client available for this session" };
|
||||
@@ -934,6 +942,15 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel in-flight Catty Agent command executions for a chat session
|
||||
ipcMain.handle("netcatty:ai:catty:cancel", async (event, { chatSessionId }) => {
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
}
|
||||
mcpServerBridge.cancelPtyExecsForSession(chatSessionId);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Write to terminal session (send input like a user typing)
|
||||
ipcMain.handle("netcatty:ai:terminal:write", async (event, { sessionId, data }) => {
|
||||
// Validate IPC sender (Issue #17)
|
||||
@@ -1715,11 +1732,39 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
let abortController = null;
|
||||
try {
|
||||
const existingRun = acpChatRuns.get(chatSessionId);
|
||||
if (existingRun && existingRun.requestId !== requestId) {
|
||||
existingRun.cancelRequested = true;
|
||||
const existingController = acpActiveStreams.get(existingRun.requestId);
|
||||
if (existingController) {
|
||||
existingController.abort();
|
||||
acpActiveStreams.delete(existingRun.requestId);
|
||||
}
|
||||
acpRequestSessions.delete(existingRun.requestId);
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
}
|
||||
|
||||
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, false);
|
||||
abortController = new AbortController();
|
||||
acpActiveStreams.set(requestId, abortController);
|
||||
acpRequestSessions.set(requestId, chatSessionId);
|
||||
acpChatRuns.set(chatSessionId, { requestId, cancelRequested: false });
|
||||
|
||||
const consumePendingStartupCancel = () => {
|
||||
if (!acpPendingCancelRequests.has(requestId)) return false;
|
||||
acpPendingCancelRequests.delete(requestId);
|
||||
abortController?.abort();
|
||||
return true;
|
||||
};
|
||||
|
||||
const shouldAbortStartup = () =>
|
||||
Boolean(abortController?.signal?.aborted || consumePendingStartupCancel());
|
||||
|
||||
const { createACPProvider } = require("@mcpc-tech/acp-ai-provider");
|
||||
const { streamText, stepCountIs } = require("ai");
|
||||
|
||||
const shellEnv = await getShellEnv();
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
const sessionCwd = cwd || process.cwd();
|
||||
const isCodexAgent = acpCommand === "codex-acp";
|
||||
const isClaudeAgent = acpCommand === "claude-agent-acp";
|
||||
@@ -1730,6 +1775,7 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
if (isCodexAgent && !apiKey) {
|
||||
const validation = await validateCodexChatGptAuth({ maxAgeMs: 10000 });
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
if (!validation.ok) {
|
||||
if (isCodexAuthError(validation)) {
|
||||
try {
|
||||
@@ -1752,6 +1798,7 @@ function registerHandlers(ipcMain) {
|
||||
const mcpSnapshot = isCodexAgent
|
||||
? await resolveCodexMcpSnapshot(sessionCwd)
|
||||
: { mcpServers: [], fingerprint: getCodexMcpFingerprint([]) };
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
|
||||
// Inject Netcatty MCP server for scoped terminal-session access
|
||||
try {
|
||||
@@ -1762,23 +1809,12 @@ function registerHandlers(ipcMain) {
|
||||
} catch (err) {
|
||||
console.error("[ACP] Failed to inject Netcatty MCP server:", err?.message || err);
|
||||
}
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
|
||||
// Recalculate fingerprint after injection
|
||||
mcpSnapshot.fingerprint = getCodexMcpFingerprint(mcpSnapshot.mcpServers);
|
||||
|
||||
const currentPermissionMode = mcpServerBridge.getPermissionMode();
|
||||
const existingRun = acpChatRuns.get(chatSessionId);
|
||||
if (existingRun && existingRun.requestId !== requestId) {
|
||||
existingRun.cancelRequested = true;
|
||||
const existingController = acpActiveStreams.get(existingRun.requestId);
|
||||
if (existingController) {
|
||||
existingController.abort();
|
||||
acpActiveStreams.delete(existingRun.requestId);
|
||||
}
|
||||
acpRequestSessions.delete(existingRun.requestId);
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
}
|
||||
|
||||
let providerEntry = acpProviders.get(chatSessionId);
|
||||
const shouldForceProviderReset = acpForceProviderReset.has(chatSessionId);
|
||||
const shouldReuseProvider = Boolean(
|
||||
@@ -1841,6 +1877,7 @@ function registerHandlers(ipcMain) {
|
||||
let modelInstance = providerEntry.provider.languageModel(model || undefined);
|
||||
try {
|
||||
await providerEntry.provider.initSession(providerEntry.provider.tools);
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
} catch (err) {
|
||||
const attemptedResumeSessionId = providerEntry.provider?.getSessionId?.() || existingSessionId;
|
||||
if (!attemptedResumeSessionId || !isUnsupportedLoadSessionError(err)) {
|
||||
@@ -1882,6 +1919,7 @@ function registerHandlers(ipcMain) {
|
||||
acpProviders.set(chatSessionId, providerEntry);
|
||||
modelInstance = providerEntry.provider.languageModel(model || undefined);
|
||||
await providerEntry.provider.initSession(providerEntry.provider.tools);
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
}
|
||||
const activeProviderSessionId = providerEntry.provider.getSessionId?.() || null;
|
||||
if (activeProviderSessionId) {
|
||||
@@ -1891,11 +1929,6 @@ function registerHandlers(ipcMain) {
|
||||
});
|
||||
}
|
||||
|
||||
abortController = new AbortController();
|
||||
acpActiveStreams.set(requestId, abortController);
|
||||
acpRequestSessions.set(requestId, chatSessionId);
|
||||
acpChatRuns.set(chatSessionId, { requestId, cancelRequested: false });
|
||||
|
||||
// Prepend context hint so the agent uses Netcatty MCP tools for the scoped sessions
|
||||
const contextualPrompt =
|
||||
`[Context: You are inside Netcatty, a multi-session terminal manager. ` +
|
||||
@@ -2055,6 +2088,7 @@ function registerHandlers(ipcMain) {
|
||||
} finally {
|
||||
acpActiveStreams.delete(requestId);
|
||||
acpRequestSessions.delete(requestId);
|
||||
acpPendingCancelRequests.delete(requestId);
|
||||
const activeRun = acpChatRuns.get(chatSessionId);
|
||||
if (activeRun?.requestId === requestId) {
|
||||
if (abortController?.signal?.aborted || activeRun.cancelRequested) {
|
||||
@@ -2069,20 +2103,24 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
ipcMain.handle("netcatty:ai:acp:cancel", async (event, { requestId, chatSessionId }) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
// Cancel any active PTY executions (send Ctrl+C)
|
||||
mcpServerBridge.cancelAllPtyExecs();
|
||||
const effectiveChatSessionId = chatSessionId || acpRequestSessions.get(requestId);
|
||||
const activeRun = effectiveChatSessionId ? acpChatRuns.get(effectiveChatSessionId) : null;
|
||||
const effectiveRequestId = requestId || activeRun?.requestId || "";
|
||||
// Cancel PTY executions scoped to this chat session (send Ctrl+C)
|
||||
mcpServerBridge.cancelPtyExecsForSession(effectiveChatSessionId);
|
||||
mcpServerBridge.setChatSessionCancelled?.(effectiveChatSessionId, true);
|
||||
mcpServerBridge.clearPendingApprovals(effectiveChatSessionId);
|
||||
const activeRun = effectiveChatSessionId ? acpChatRuns.get(effectiveChatSessionId) : null;
|
||||
if (activeRun && activeRun.requestId === requestId) {
|
||||
if (activeRun && activeRun.requestId === effectiveRequestId) {
|
||||
activeRun.cancelRequested = true;
|
||||
}
|
||||
const controller = acpActiveStreams.get(requestId);
|
||||
const controller = acpActiveStreams.get(effectiveRequestId);
|
||||
let cancelled = false;
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
acpActiveStreams.delete(requestId);
|
||||
acpActiveStreams.delete(effectiveRequestId);
|
||||
cancelled = true;
|
||||
} else if (effectiveRequestId) {
|
||||
acpPendingCancelRequests.add(effectiveRequestId);
|
||||
cancelled = true;
|
||||
}
|
||||
if (effectiveChatSessionId) {
|
||||
@@ -2093,7 +2131,7 @@ function registerHandlers(ipcMain) {
|
||||
// continue within the same persisted conversation context. Full provider
|
||||
// cleanup is handled by netcatty:ai:acp:cleanup when the chat is deleted.
|
||||
if (effectiveChatSessionId) cancelled = true;
|
||||
acpRequestSessions.delete(requestId);
|
||||
if (effectiveRequestId) acpRequestSessions.delete(effectiveRequestId);
|
||||
return cancelled ? { ok: true } : { ok: false, error: "Stream not found" };
|
||||
});
|
||||
|
||||
|
||||
@@ -145,16 +145,30 @@ function clearPendingApprovals(chatSessionId) {
|
||||
function cancelAllPtyExecs() {
|
||||
for (const [marker, entry] of activePtyExecs) {
|
||||
try {
|
||||
entry.cleanup();
|
||||
// Send Ctrl+C to kill the running command
|
||||
if (entry.ptyStream && typeof entry.ptyStream.write === "function") {
|
||||
entry.ptyStream.write("\x03");
|
||||
}
|
||||
if (typeof entry.cancel === "function") entry.cancel();
|
||||
else entry.cleanup();
|
||||
} catch { /* ignore */ }
|
||||
activePtyExecs.delete(marker);
|
||||
}
|
||||
activePtyExecs.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel PTY executions scoped to a specific chat session.
|
||||
* Only affects entries whose chatSessionId matches.
|
||||
*/
|
||||
function cancelPtyExecsForSession(chatSessionId) {
|
||||
if (!chatSessionId) return;
|
||||
for (const [marker, entry] of activePtyExecs) {
|
||||
if (entry.chatSessionId !== chatSessionId) continue;
|
||||
try {
|
||||
if (typeof entry.cancel === "function") entry.cancel();
|
||||
else entry.cleanup();
|
||||
} catch { /* ignore */ }
|
||||
activePtyExecs.delete(marker);
|
||||
}
|
||||
}
|
||||
|
||||
function init(deps) {
|
||||
sessions = deps.sessions;
|
||||
sftpClients = deps.sftpClients;
|
||||
@@ -598,6 +612,7 @@ function handleExec(params) {
|
||||
trackForCancellation: activePtyExecs,
|
||||
timeoutMs: commandTimeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
expectedPrompt: session.lastIdlePrompt || "",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -966,7 +981,9 @@ module.exports = {
|
||||
getScopedSessionIds,
|
||||
getOrCreateHost,
|
||||
buildMcpServerConfig,
|
||||
activePtyExecs,
|
||||
cancelAllPtyExecs,
|
||||
cancelPtyExecsForSession,
|
||||
cleanupScopedMetadata,
|
||||
cleanup,
|
||||
setMainWindowGetter,
|
||||
|
||||
@@ -3,19 +3,38 @@
|
||||
* Extracted from main.cjs for single responsibility
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const net = require("node:net");
|
||||
const { Client: SSHClient } = require("ssh2");
|
||||
const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const { connectThroughChain } = require("./sshBridge.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
|
||||
isKeyEncrypted,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
|
||||
// Active port forwarding tunnels
|
||||
const portForwardingTunnels = new Map();
|
||||
|
||||
function cleanupChainConnections(connections) {
|
||||
if (!Array.isArray(connections)) return;
|
||||
for (const chainConn of connections) {
|
||||
try { chainConn.end(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
function isTunnelCancelled(tunnelState) {
|
||||
return Boolean(tunnelState?.cancelled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to renderer safely
|
||||
*/
|
||||
@@ -44,11 +63,30 @@ async function startPortForward(event, payload) {
|
||||
username,
|
||||
password,
|
||||
privateKey,
|
||||
certificate,
|
||||
keyId,
|
||||
passphrase,
|
||||
proxy,
|
||||
jumpHosts = [],
|
||||
identityFilePaths,
|
||||
} = payload;
|
||||
|
||||
const conn = new SSHClient();
|
||||
const sender = event.sender;
|
||||
const hasJumpHosts = jumpHosts.length > 0;
|
||||
const hasProxy = !!proxy;
|
||||
let chainConnections = [];
|
||||
let connectionSocket = null;
|
||||
const tunnelState = {
|
||||
type,
|
||||
conn,
|
||||
pendingConn: null,
|
||||
server: null,
|
||||
chainConnections,
|
||||
status: 'connecting',
|
||||
webContentsId: sender.id,
|
||||
cancelled: false,
|
||||
};
|
||||
|
||||
const sendStatus = (status, error = null) => {
|
||||
if (!sender.isDestroyed()) {
|
||||
@@ -66,9 +104,53 @@ async function startPortForward(event, payload) {
|
||||
tryKeyboard: true,
|
||||
};
|
||||
|
||||
if (privateKey) {
|
||||
const hasCertificate = typeof certificate === "string" && certificate.trim().length > 0;
|
||||
|
||||
if (hasCertificate) {
|
||||
connectOpts.agent = new NetcattyAgent({
|
||||
mode: "certificate",
|
||||
webContents: sender,
|
||||
meta: {
|
||||
label: keyId || username || "",
|
||||
certificate,
|
||||
privateKey,
|
||||
passphrase,
|
||||
},
|
||||
});
|
||||
} else if (privateKey) {
|
||||
connectOpts.privateKey = privateKey;
|
||||
}
|
||||
|
||||
// Read identity files from local paths (e.g. SSH config IdentityFile)
|
||||
// when no explicit key/certificate was already configured.
|
||||
if (!connectOpts.privateKey && !connectOpts.agent && identityFilePaths?.length > 0) {
|
||||
for (const keyPath of identityFilePaths) {
|
||||
try {
|
||||
const resolvedPath = keyPath.startsWith("~/")
|
||||
? path.join(os.homedir(), keyPath.slice(2))
|
||||
: keyPath;
|
||||
const keyContent = await fs.promises.readFile(resolvedPath, "utf8");
|
||||
connectOpts.privateKey = keyContent;
|
||||
if (isKeyEncrypted(keyContent)) {
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
sender,
|
||||
resolvedPath,
|
||||
path.basename(resolvedPath),
|
||||
hostname,
|
||||
);
|
||||
if (result?.passphrase) {
|
||||
connectOpts.passphrase = result.passphrase;
|
||||
} else {
|
||||
delete connectOpts.privateKey;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
} catch (err) {
|
||||
console.warn(`[PortForward] Failed to read identity file ${keyPath}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (passphrase) {
|
||||
connectOpts.passphrase = passphrase;
|
||||
}
|
||||
@@ -76,19 +158,101 @@ async function startPortForward(event, payload) {
|
||||
connectOpts.password = password;
|
||||
}
|
||||
|
||||
// Get default keys
|
||||
const defaultKeys = await findAllDefaultPrivateKeysFromHelper();
|
||||
sendStatus('connecting');
|
||||
portForwardingTunnels.set(tunnelId, tunnelState);
|
||||
|
||||
// Build auth handler using shared helper
|
||||
const authConfig = buildAuthHandler({
|
||||
privateKey,
|
||||
password,
|
||||
passphrase,
|
||||
username: connectOpts.username,
|
||||
logPrefix: "[PortForward]",
|
||||
defaultKeys,
|
||||
});
|
||||
applyAuthToConnOpts(connectOpts, authConfig);
|
||||
let defaultKeys = [];
|
||||
try {
|
||||
// Get default keys
|
||||
defaultKeys = await findAllDefaultPrivateKeysFromHelper();
|
||||
if (isTunnelCancelled(tunnelState)) {
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
return { tunnelId, success: false, cancelled: true };
|
||||
}
|
||||
|
||||
// Build auth handler using shared helper
|
||||
const authConfig = buildAuthHandler({
|
||||
privateKey: connectOpts.privateKey,
|
||||
password,
|
||||
passphrase: connectOpts.passphrase,
|
||||
agent: connectOpts.agent,
|
||||
username: connectOpts.username,
|
||||
logPrefix: "[PortForward]",
|
||||
defaultKeys,
|
||||
});
|
||||
applyAuthToConnOpts(connectOpts, authConfig);
|
||||
if (isTunnelCancelled(tunnelState)) {
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
return { tunnelId, success: false, cancelled: true };
|
||||
}
|
||||
|
||||
if (hasJumpHosts) {
|
||||
const chainResult = await connectThroughChain(
|
||||
event,
|
||||
{
|
||||
hostname,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
privateKey,
|
||||
passphrase,
|
||||
proxy,
|
||||
jumpHosts,
|
||||
_defaultKeys: defaultKeys,
|
||||
_connectionsRef: chainConnections,
|
||||
_tunnelRef: tunnelState,
|
||||
},
|
||||
jumpHosts,
|
||||
hostname,
|
||||
port,
|
||||
tunnelId,
|
||||
);
|
||||
connectionSocket = chainResult.socket;
|
||||
chainConnections = chainResult.connections;
|
||||
tunnelState.chainConnections = chainConnections;
|
||||
if (isTunnelCancelled(tunnelState)) {
|
||||
cleanupChainConnections(chainConnections);
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
return { tunnelId, success: false, cancelled: true };
|
||||
}
|
||||
connectOpts.sock = connectionSocket;
|
||||
delete connectOpts.host;
|
||||
delete connectOpts.port;
|
||||
} else if (hasProxy) {
|
||||
connectionSocket = await createProxySocket(proxy, hostname, port, {
|
||||
onSocket: (socket) => {
|
||||
tunnelState.pendingConn = socket;
|
||||
},
|
||||
});
|
||||
if (isTunnelCancelled(tunnelState)) {
|
||||
try { connectionSocket?.end?.(); } catch { /* ignore */ }
|
||||
try { connectionSocket?.destroy?.(); } catch { /* ignore */ }
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
return { tunnelId, success: false, cancelled: true };
|
||||
}
|
||||
tunnelState.pendingConn = null;
|
||||
connectOpts.sock = connectionSocket;
|
||||
delete connectOpts.host;
|
||||
delete connectOpts.port;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isTunnelCancelled(tunnelState)) {
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
return { tunnelId, success: false, cancelled: true };
|
||||
}
|
||||
tunnelState.cancelled = true;
|
||||
if (tunnelState.pendingConn) {
|
||||
try { tunnelState.pendingConn.end(); } catch { /* ignore */ }
|
||||
}
|
||||
cleanupChainConnections(tunnelState.chainConnections);
|
||||
if (connectionSocket) {
|
||||
try { connectionSocket.end?.(); } catch { /* ignore */ }
|
||||
try { connectionSocket.destroy?.(); } catch { /* ignore */ }
|
||||
}
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
sendStatus('error', err?.message || String(err));
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Handle keyboard-interactive authentication (2FA/MFA)
|
||||
conn.on("keyboard-interactive", createKeyboardInteractiveHandler({
|
||||
@@ -133,20 +297,20 @@ async function startPortForward(event, payload) {
|
||||
console.error(`[PortForward] Server error:`, err.message);
|
||||
sendStatus('error', err.message);
|
||||
conn.end();
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
settled = true;
|
||||
reject(err);
|
||||
});
|
||||
|
||||
server.listen(localPort, bindAddress, () => {
|
||||
console.log(`[PortForward] Local forwarding active: ${bindAddress}:${localPort} -> ${remoteHost}:${remotePort}`);
|
||||
portForwardingTunnels.set(tunnelId, {
|
||||
type: 'local',
|
||||
conn,
|
||||
server,
|
||||
status: 'active',
|
||||
webContentsId: sender.id
|
||||
});
|
||||
tunnelState.type = 'local';
|
||||
tunnelState.conn = conn;
|
||||
tunnelState.server = server;
|
||||
tunnelState.chainConnections = chainConnections;
|
||||
tunnelState.status = 'active';
|
||||
tunnelState.webContentsId = sender.id;
|
||||
tunnelState.pendingConn = null;
|
||||
portForwardingTunnels.set(tunnelId, tunnelState);
|
||||
sendStatus('active');
|
||||
settled = true;
|
||||
resolve({ tunnelId, success: true });
|
||||
@@ -165,12 +329,14 @@ async function startPortForward(event, payload) {
|
||||
}
|
||||
|
||||
console.log(`[PortForward] Remote forwarding active: remote ${bindAddress}:${localPort} -> local ${remoteHost}:${remotePort}`);
|
||||
portForwardingTunnels.set(tunnelId, {
|
||||
type: 'remote',
|
||||
conn,
|
||||
status: 'active',
|
||||
webContentsId: sender.id
|
||||
});
|
||||
tunnelState.type = 'remote';
|
||||
tunnelState.conn = conn;
|
||||
tunnelState.server = null;
|
||||
tunnelState.chainConnections = chainConnections;
|
||||
tunnelState.status = 'active';
|
||||
tunnelState.webContentsId = sender.id;
|
||||
tunnelState.pendingConn = null;
|
||||
portForwardingTunnels.set(tunnelId, tunnelState);
|
||||
sendStatus('active');
|
||||
settled = true;
|
||||
resolve({ tunnelId, success: true });
|
||||
@@ -273,20 +439,20 @@ async function startPortForward(event, payload) {
|
||||
console.error(`[PortForward] SOCKS server error:`, err.message);
|
||||
sendStatus('error', err.message);
|
||||
conn.end();
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
settled = true;
|
||||
reject(err);
|
||||
});
|
||||
|
||||
server.listen(localPort, bindAddress, () => {
|
||||
console.log(`[PortForward] Dynamic SOCKS5 proxy active on ${bindAddress}:${localPort}`);
|
||||
portForwardingTunnels.set(tunnelId, {
|
||||
type: 'dynamic',
|
||||
conn,
|
||||
server,
|
||||
status: 'active',
|
||||
webContentsId: sender.id
|
||||
});
|
||||
tunnelState.type = 'dynamic';
|
||||
tunnelState.conn = conn;
|
||||
tunnelState.server = server;
|
||||
tunnelState.chainConnections = chainConnections;
|
||||
tunnelState.status = 'active';
|
||||
tunnelState.webContentsId = sender.id;
|
||||
tunnelState.pendingConn = null;
|
||||
portForwardingTunnels.set(tunnelId, tunnelState);
|
||||
sendStatus('active');
|
||||
settled = true;
|
||||
resolve({ tunnelId, success: true });
|
||||
@@ -297,10 +463,11 @@ async function startPortForward(event, payload) {
|
||||
}
|
||||
});
|
||||
|
||||
conn.once('error', (err) => {
|
||||
conn.on('error', (err) => {
|
||||
console.error(`[PortForward] SSH error:`, err.message);
|
||||
if (settled) return;
|
||||
sendStatus('error', err.message);
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
cleanupChainConnections(chainConnections);
|
||||
settled = true;
|
||||
reject(err);
|
||||
});
|
||||
@@ -314,6 +481,12 @@ async function startPortForward(event, payload) {
|
||||
if (tunnel.server) {
|
||||
try { tunnel.server.close(); } catch { }
|
||||
}
|
||||
if (Array.isArray(tunnel.chainConnections)) {
|
||||
cleanupChainConnections(tunnel.chainConnections);
|
||||
}
|
||||
if (tunnel.pendingConn) {
|
||||
try { tunnel.pendingConn.end(); } catch { /* ignore */ }
|
||||
}
|
||||
sendStatus('inactive');
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
}
|
||||
@@ -329,18 +502,6 @@ async function startPortForward(event, payload) {
|
||||
}
|
||||
});
|
||||
|
||||
sendStatus('connecting');
|
||||
// Register the connection BEFORE the handshake starts so that
|
||||
// stopPortForwardByRuleId can find and kill it at any point,
|
||||
// including during the SSH handshake window. The conn.on('ready')
|
||||
// handler updates the entry to include the server object later.
|
||||
portForwardingTunnels.set(tunnelId, {
|
||||
type,
|
||||
conn,
|
||||
server: null,
|
||||
status: 'connecting',
|
||||
webContentsId: sender.id,
|
||||
});
|
||||
conn.connect(connectOpts);
|
||||
});
|
||||
}
|
||||
@@ -363,6 +524,10 @@ async function stopPortForward(event, payload) {
|
||||
if (tunnel.server) {
|
||||
tunnel.server.close();
|
||||
}
|
||||
if (tunnel.pendingConn) {
|
||||
tunnel.pendingConn.end();
|
||||
}
|
||||
cleanupChainConnections(tunnel.chainConnections);
|
||||
if (tunnel.conn) {
|
||||
tunnel.conn.end();
|
||||
}
|
||||
@@ -417,6 +582,10 @@ function stopAllPortForwards() {
|
||||
if (tunnel.server) {
|
||||
tunnel.server.close();
|
||||
}
|
||||
if (tunnel.pendingConn) {
|
||||
tunnel.pendingConn.end();
|
||||
}
|
||||
cleanupChainConnections(tunnel.chainConnections);
|
||||
if (tunnel.conn) {
|
||||
tunnel.conn.end();
|
||||
}
|
||||
@@ -446,6 +615,8 @@ function stopPortForwardByRuleId(_event, { ruleId }) {
|
||||
// close handler resolves gracefully instead of rejecting.
|
||||
tunnel.cancelled = true;
|
||||
if (tunnel.server) tunnel.server.close();
|
||||
if (tunnel.pendingConn) tunnel.pendingConn.end();
|
||||
cleanupChainConnections(tunnel.chainConnections);
|
||||
if (tunnel.conn) tunnel.conn.end();
|
||||
// Don't delete here — let the conn.on('close') handler delete
|
||||
// the entry so it can read tunnel.cancelled first.
|
||||
|
||||
@@ -15,9 +15,12 @@ const net = require("node:net");
|
||||
* @param {string} [proxy.password] - Optional password for auth
|
||||
* @param {string} targetHost - Target host to connect through proxy
|
||||
* @param {number} targetPort - Target port to connect through proxy
|
||||
* @param {Object} [options]
|
||||
* @param {(socket: net.Socket) => void} [options.onSocket] - Called immediately with the underlying socket
|
||||
* @returns {Promise<net.Socket>} Connected socket through proxy
|
||||
*/
|
||||
function createProxySocket(proxy, targetHost, targetPort) {
|
||||
function createProxySocket(proxy, targetHost, targetPort, options = {}) {
|
||||
const { onSocket } = options;
|
||||
return new Promise((resolve, reject) => {
|
||||
if (proxy.type === 'http') {
|
||||
// HTTP CONNECT proxy
|
||||
@@ -45,6 +48,7 @@ function createProxySocket(proxy, targetHost, targetPort) {
|
||||
};
|
||||
socket.on('data', onData);
|
||||
});
|
||||
try { onSocket?.(socket); } catch { /* ignore */ }
|
||||
socket.on('error', reject);
|
||||
} else if (proxy.type === 'socks5') {
|
||||
// SOCKS5 proxy
|
||||
@@ -123,6 +127,7 @@ function createProxySocket(proxy, targetHost, targetPort) {
|
||||
|
||||
socket.on('data', onData);
|
||||
});
|
||||
try { onSocket?.(socket); } catch { /* ignore */ }
|
||||
socket.on('error', reject);
|
||||
} else {
|
||||
reject(new Error(`Unknown proxy type: ${proxy.type}`));
|
||||
|
||||
@@ -22,12 +22,14 @@ try {
|
||||
const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
safeSend: authSafeSend,
|
||||
isKeyEncrypted,
|
||||
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
|
||||
getAvailableAgentSocket,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
@@ -430,6 +432,18 @@ function init(deps) {
|
||||
electronModule = deps.electronModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SFTP connection progress to the renderer for user-visible logging
|
||||
*/
|
||||
function sendSftpProgress(sender, sessionId, label, status, detail) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send("netcatty:sftp:connection-progress", { sessionId, label, status, detail });
|
||||
} catch {
|
||||
// Ignore destroyed webContents
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect through a chain of jump hosts for SFTP
|
||||
*/
|
||||
@@ -447,6 +461,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
const hopLabel = jump.label || (jump.hostname.includes(':') && !jump.hostname.startsWith('[') ? `[${jump.hostname}]:${jump.port || 22}` : `${jump.hostname}:${jump.port || 22}`);
|
||||
|
||||
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: Connecting to ${hopLabel}...`);
|
||||
sendSftpProgress(sender, connId, hopLabel, 'connecting');
|
||||
|
||||
const conn = new SSHClient();
|
||||
// Increase max listeners to prevent Node.js warning
|
||||
@@ -485,7 +500,59 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
connOpts.agent = authAgent;
|
||||
} else if (jump.privateKey) {
|
||||
connOpts.privateKey = jump.privateKey;
|
||||
if (jump.passphrase) connOpts.passphrase = jump.passphrase;
|
||||
if (jump.passphrase) {
|
||||
connOpts.passphrase = jump.passphrase;
|
||||
} else if (isKeyEncrypted(jump.privateKey)) {
|
||||
// Key is encrypted but no passphrase provided — prompt the user
|
||||
console.log(`[SFTP Chain] Hop ${i + 1}: key is encrypted, requesting passphrase`);
|
||||
const keyLabel = jump.label || hopLabel;
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
sender,
|
||||
`SSH key for ${keyLabel}`,
|
||||
keyLabel,
|
||||
hopLabel
|
||||
);
|
||||
if (result?.passphrase) {
|
||||
connOpts.passphrase = result.passphrase;
|
||||
} else {
|
||||
delete connOpts.privateKey;
|
||||
if (result?.cancelled) {
|
||||
throw new Error(`Passphrase entry cancelled for ${hopLabel}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read identity files from local paths (e.g. from SSH config IdentityFile)
|
||||
if (!connOpts.privateKey && !connOpts.agent && jump.identityFilePaths?.length > 0) {
|
||||
for (const keyPath of jump.identityFilePaths) {
|
||||
try {
|
||||
const resolvedPath = keyPath.startsWith("~/")
|
||||
? path.join(os.homedir(), keyPath.slice(2))
|
||||
: keyPath;
|
||||
const keyContent = await fs.promises.readFile(resolvedPath, "utf8");
|
||||
connOpts.privateKey = keyContent;
|
||||
if (isKeyEncrypted(keyContent)) {
|
||||
console.log(`[SFTP Chain] Hop ${i + 1}: identity file ${resolvedPath} is encrypted, requesting passphrase`);
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
sender,
|
||||
resolvedPath,
|
||||
path.basename(resolvedPath),
|
||||
hopLabel
|
||||
);
|
||||
if (result?.passphrase) {
|
||||
connOpts.passphrase = result.passphrase;
|
||||
} else {
|
||||
delete connOpts.privateKey;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
console.log(`[SFTP Chain] Hop ${i + 1}: loaded identity file ${resolvedPath}`);
|
||||
break;
|
||||
} catch (err) {
|
||||
console.warn(`[SFTP Chain] Hop ${i + 1}: failed to read identity file ${keyPath}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (jump.password) connOpts.password = jump.password;
|
||||
@@ -505,12 +572,17 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
|
||||
defaultKeys,
|
||||
sshAgentSocketOverride: agentSocket,
|
||||
onAuthAttempt: (method) => {
|
||||
sendSftpProgress(sender, connId, hopLabel, 'auth-attempt', method);
|
||||
},
|
||||
});
|
||||
applyAuthToConnOpts(connOpts, authConfig);
|
||||
|
||||
// If first hop and proxy is configured, connect through proxy
|
||||
if (isFirst && options.proxy) {
|
||||
currentSocket = await createProxySocket(options.proxy, jump.hostname, jump.port || 22);
|
||||
const hasUsableJumpProxy = !!(jump.proxy?.host && jump.proxy?.port);
|
||||
const effectiveHopProxy = isFirst ? ((hasUsableJumpProxy ? jump.proxy : null) || options.proxy) : null;
|
||||
if (effectiveHopProxy) {
|
||||
currentSocket = await createProxySocket(effectiveHopProxy, jump.hostname, jump.port || 22);
|
||||
connOpts.sock = currentSocket;
|
||||
delete connOpts.host;
|
||||
delete connOpts.port;
|
||||
@@ -523,8 +595,12 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
|
||||
// Connect this hop
|
||||
await new Promise((resolve, reject) => {
|
||||
conn.once('handshake', () => {
|
||||
sendSftpProgress(sender, connId, hopLabel, 'authenticating');
|
||||
});
|
||||
conn.once('ready', () => {
|
||||
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} connected`);
|
||||
sendSftpProgress(sender, connId, hopLabel, 'connected');
|
||||
resolve();
|
||||
});
|
||||
conn.on('error', (err) => {
|
||||
@@ -534,6 +610,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
return;
|
||||
}
|
||||
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} error:`, err.message);
|
||||
sendSftpProgress(sender, connId, hopLabel, 'error', err.message);
|
||||
reject(err);
|
||||
});
|
||||
conn.once('timeout', () => {
|
||||
@@ -541,13 +618,23 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
reject(new Error(`Connection timeout to ${hopLabel}`));
|
||||
});
|
||||
// Handle keyboard-interactive authentication for jump hosts (2FA/MFA)
|
||||
conn.on('keyboard-interactive', createKeyboardInteractiveHandler({
|
||||
const sftpChainKiHandler = createKeyboardInteractiveHandler({
|
||||
sender,
|
||||
sessionId: connId,
|
||||
hostname: hopLabel,
|
||||
password: jump.password,
|
||||
logPrefix: `[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}`,
|
||||
}));
|
||||
});
|
||||
conn.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => {
|
||||
if (prompts && prompts.length > 0) {
|
||||
sendSftpProgress(sender, connId, hopLabel, 'auth-attempt', 'waiting for user input...');
|
||||
}
|
||||
const wrappedFinish = (...args) => {
|
||||
sendSftpProgress(sender, connId, hopLabel, 'auth-attempt', 'user responded');
|
||||
finish(...args);
|
||||
};
|
||||
sftpChainKiHandler(name, instructions, lang, prompts, wrappedFinish);
|
||||
});
|
||||
conn.connect(connOpts);
|
||||
});
|
||||
|
||||
@@ -906,7 +993,69 @@ async function openSftp(event, options) {
|
||||
connectOpts.agent = authAgent;
|
||||
} else if (options.privateKey) {
|
||||
connectOpts.privateKey = options.privateKey;
|
||||
if (options.passphrase) connectOpts.passphrase = options.passphrase;
|
||||
if (options.passphrase) {
|
||||
connectOpts.passphrase = options.passphrase;
|
||||
} else if (isKeyEncrypted(options.privateKey)) {
|
||||
// Key is encrypted but no passphrase provided — prompt the user
|
||||
console.log(`[SFTP] Key is encrypted, requesting passphrase for ${options.hostname}`);
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
event.sender,
|
||||
`SSH key for ${options.hostname}`,
|
||||
options.hostname,
|
||||
options.hostname
|
||||
);
|
||||
if (result?.passphrase) {
|
||||
connectOpts.passphrase = result.passphrase;
|
||||
} else {
|
||||
delete connectOpts.privateKey;
|
||||
if (result?.cancelled) {
|
||||
// Clean up any chain/proxy connections and proxy socket opened earlier
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch {}
|
||||
}
|
||||
if (connectionSocket) {
|
||||
try { connectionSocket.destroy(); } catch {}
|
||||
}
|
||||
// Use "authentication" in the message so the SFTP frontend's
|
||||
// isAuthError() check recognizes this and falls back to password.
|
||||
const err = new Error(`Authentication cancelled — passphrase not provided for ${options.hostname}`);
|
||||
err.level = 'client-authentication';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read identity files from local paths (e.g. from SSH config IdentityFile)
|
||||
if (!connectOpts.privateKey && !connectOpts.agent && options.identityFilePaths?.length > 0) {
|
||||
for (const keyPath of options.identityFilePaths) {
|
||||
try {
|
||||
const resolvedPath = keyPath.startsWith("~/")
|
||||
? path.join(os.homedir(), keyPath.slice(2))
|
||||
: keyPath;
|
||||
const keyContent = await fs.promises.readFile(resolvedPath, "utf8");
|
||||
connectOpts.privateKey = keyContent;
|
||||
if (isKeyEncrypted(keyContent)) {
|
||||
console.log(`[SFTP] Identity file ${resolvedPath} is encrypted, requesting passphrase`);
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
event.sender,
|
||||
resolvedPath,
|
||||
path.basename(resolvedPath),
|
||||
options.hostname
|
||||
);
|
||||
if (result?.passphrase) {
|
||||
connectOpts.passphrase = result.passphrase;
|
||||
} else {
|
||||
delete connectOpts.privateKey;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
console.log(`[SFTP] Loaded identity file ${resolvedPath}`);
|
||||
break;
|
||||
} catch (err) {
|
||||
console.warn(`[SFTP] Failed to read identity file ${keyPath}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.password) connectOpts.password = options.password;
|
||||
@@ -922,6 +1071,9 @@ async function openSftp(event, options) {
|
||||
logPrefix: "[SFTP]",
|
||||
defaultKeys,
|
||||
sshAgentSocketOverride: agentSocket,
|
||||
onAuthAttempt: (method) => {
|
||||
sendSftpProgress(event.sender, connId, options.hostname, 'auth-attempt', method);
|
||||
},
|
||||
});
|
||||
applyAuthToConnOpts(connectOpts, authConfig);
|
||||
|
||||
@@ -935,7 +1087,17 @@ async function openSftp(event, options) {
|
||||
});
|
||||
|
||||
// Add keyboard-interactive listener BEFORE connecting
|
||||
client.on("keyboard-interactive", kiHandler);
|
||||
// Wrap to emit progress events for the SFTP connection log
|
||||
client.on("keyboard-interactive", (name, instructions, lang, prompts, finish) => {
|
||||
if (prompts && prompts.length > 0) {
|
||||
sendSftpProgress(event.sender, connId, options.hostname, 'auth-attempt', 'waiting for user input...');
|
||||
}
|
||||
const wrappedFinish = (...args) => {
|
||||
sendSftpProgress(event.sender, connId, options.hostname, 'auth-attempt', 'user responded');
|
||||
finish(...args);
|
||||
};
|
||||
kiHandler(name, instructions, lang, prompts, wrappedFinish);
|
||||
});
|
||||
|
||||
// Increase timeout to allow for keyboard-interactive auth
|
||||
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
|
||||
@@ -983,14 +1145,24 @@ async function openSftp(event, options) {
|
||||
sshClient.removeListener('error', onError);
|
||||
sshClient.removeListener('end', onEnd);
|
||||
sshClient.removeListener('close', onClose);
|
||||
// Keep a catch-all error listener so post-ready errors (e.g. connection
|
||||
// drops during an active SFTP session) don't become uncaught exceptions.
|
||||
sshClient.on('error', (err) => {
|
||||
console.error(`[SFTP] Post-ready SSH error for ${connId}:`, err.message);
|
||||
});
|
||||
};
|
||||
|
||||
sshClient.on('error', onError);
|
||||
sshClient.on('end', onEnd);
|
||||
sshClient.on('close', onClose);
|
||||
|
||||
sshClient.once('handshake', () => {
|
||||
sendSftpProgress(event.sender, connId, options.hostname, 'authenticating');
|
||||
});
|
||||
|
||||
sshClient.once('ready', () => {
|
||||
cleanup();
|
||||
sendSftpProgress(event.sender, connId, options.hostname, 'connected');
|
||||
|
||||
if (options.sudo) {
|
||||
console.log(`[SFTP] Using sudo mode for connection: ${connId}`);
|
||||
@@ -1033,6 +1205,7 @@ async function openSftp(event, options) {
|
||||
}
|
||||
});
|
||||
|
||||
sendSftpProgress(event.sender, connId, options.hostname, 'connecting');
|
||||
try {
|
||||
sshClient.connect(connectOpts);
|
||||
} catch (e) {
|
||||
|
||||
@@ -11,7 +11,20 @@ const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
|
||||
// Default SSH key names in priority order
|
||||
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
const PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
const SSH_KEY_PATTERN = /^id_[\w-]+$/;
|
||||
|
||||
/**
|
||||
* Quick check if file content looks like an SSH private key.
|
||||
* Rejects non-key files that happen to match the id_* filename pattern.
|
||||
*/
|
||||
function looksLikePrivateKey(content) {
|
||||
if (!content || typeof content !== "string") return false;
|
||||
const trimmed = content.trimStart();
|
||||
return trimmed.startsWith("-----BEGIN") ||
|
||||
trimmed.startsWith("openssh-key-v1") ||
|
||||
trimmed.startsWith("PuTTY-User-Key-File");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an SSH private key is encrypted (requires passphrase)
|
||||
@@ -21,6 +34,13 @@ const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
function isKeyEncrypted(keyContent) {
|
||||
if (!keyContent || typeof keyContent !== "string") return false;
|
||||
|
||||
// Check for PuTTY PPK encrypted format (Encryption: aes256-cbc, etc.)
|
||||
// PPK keys with "Encryption: none" are unencrypted
|
||||
const ppkEncMatch = keyContent.match(/^Encryption:\s*(.+)$/m);
|
||||
if (ppkEncMatch && ppkEncMatch[1].trim() !== "none") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for PKCS#8 encrypted format (-----BEGIN ENCRYPTED PRIVATE KEY-----)
|
||||
if (keyContent.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----")) {
|
||||
return true;
|
||||
@@ -73,14 +93,25 @@ function isKeyEncrypted(keyContent) {
|
||||
*/
|
||||
async function findDefaultPrivateKey() {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
let allNames = [];
|
||||
try {
|
||||
const entries = await fs.promises.readdir(sshDir);
|
||||
allNames = entries.filter(f => SSH_KEY_PATTERN.test(f));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const preferred = PREFERRED_KEY_NAMES.filter(n => allNames.includes(n));
|
||||
const rest = allNames.filter(n => !PREFERRED_KEY_NAMES.includes(n)).sort();
|
||||
const sorted = [...preferred, ...rest];
|
||||
|
||||
for (const name of sorted) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const stat = await fs.promises.stat(keyPath);
|
||||
if (!stat.isFile()) continue; // Skip directories, FIFOs, sockets, etc.
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
if (isKeyEncrypted(privateKey)) {
|
||||
continue;
|
||||
}
|
||||
if (!looksLikePrivateKey(privateKey)) continue;
|
||||
if (isKeyEncrypted(privateKey)) continue;
|
||||
return { privateKey, keyPath, keyName: name };
|
||||
} catch {
|
||||
continue;
|
||||
@@ -99,11 +130,24 @@ async function findAllDefaultPrivateKeys(options = {}) {
|
||||
const { includeEncrypted = false } = options;
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
|
||||
const promises = DEFAULT_KEY_NAMES.map(async (name) => {
|
||||
let allNames = [];
|
||||
try {
|
||||
const entries = await fs.promises.readdir(sshDir);
|
||||
allNames = entries.filter(f => SSH_KEY_PATTERN.test(f));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const preferred = PREFERRED_KEY_NAMES.filter(n => allNames.includes(n));
|
||||
const rest = allNames.filter(n => !PREFERRED_KEY_NAMES.includes(n)).sort();
|
||||
const sorted = [...preferred, ...rest];
|
||||
|
||||
const promises = sorted.map(async (name) => {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const stat = await fs.promises.stat(keyPath);
|
||||
if (!stat.isFile()) return null;
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
if (!looksLikePrivateKey(privateKey)) return null;
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
if (encrypted && !includeEncrypted) {
|
||||
return null;
|
||||
@@ -259,7 +303,7 @@ function buildAuthHandler(options) {
|
||||
|
||||
// If only simple auth methods and no fallback keys needed, use array-based handler
|
||||
if (hasExplicitAuth && !hasFallbackOptions) {
|
||||
const authMethods = [];
|
||||
const authMethods = ["none"]; // Always try none first per RFC 4252
|
||||
if (effectiveAgent) authMethods.push("agent");
|
||||
if (privateKey) authMethods.push("publickey");
|
||||
if (password) authMethods.push("password");
|
||||
@@ -380,11 +424,29 @@ function buildAuthHandler(options) {
|
||||
|
||||
// Use dynamic authHandler to try all keys
|
||||
let authIndex = 0;
|
||||
let lastAttemptedLabel = null;
|
||||
const attemptedMethodIds = new Set();
|
||||
|
||||
let triedNone = false;
|
||||
|
||||
const authHandler = (methodsLeft, partialSuccess, callback) => {
|
||||
// Per RFC 4252, always try "none" first to discover available methods
|
||||
// and to support passwordless login (e.g. embedded devices).
|
||||
// This matches the behavior of OpenSSH and Tabby.
|
||||
if (methodsLeft === null && !triedNone) {
|
||||
triedNone = true;
|
||||
lastAttemptedLabel = "none (no credentials)";
|
||||
onAuthAttempt?.("none (no credentials)");
|
||||
return callback("none");
|
||||
}
|
||||
|
||||
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];
|
||||
|
||||
// Log rejection of previous method (authHandler is called again when server rejects)
|
||||
if (lastAttemptedLabel && !partialSuccess) {
|
||||
onAuthAttempt?.(`${lastAttemptedLabel} rejected`);
|
||||
}
|
||||
|
||||
while (authIndex < authMethods.length) {
|
||||
const method = authMethods[authIndex];
|
||||
authIndex++;
|
||||
@@ -394,6 +456,7 @@ function buildAuthHandler(options) {
|
||||
|
||||
if (method.type === "agent" && (availableMethods.includes("publickey") || availableMethods.includes("agent"))) {
|
||||
console.log(`${logPrefix} Trying agent auth`);
|
||||
lastAttemptedLabel = "SSH agent";
|
||||
onAuthAttempt?.("SSH agent");
|
||||
return callback("agent");
|
||||
} else if (method.type === "publickey" && availableMethods.includes("publickey")) {
|
||||
@@ -406,6 +469,7 @@ function buildAuthHandler(options) {
|
||||
: method.id === "publickey-user"
|
||||
? "configured key"
|
||||
: method.id;
|
||||
lastAttemptedLabel = keyLabel;
|
||||
onAuthAttempt?.(keyLabel);
|
||||
const pubkeyAuth = {
|
||||
type: "publickey",
|
||||
@@ -418,6 +482,7 @@ function buildAuthHandler(options) {
|
||||
return callback(pubkeyAuth);
|
||||
} else if (method.type === "password" && availableMethods.includes("password")) {
|
||||
console.log(`${logPrefix} Trying password auth`);
|
||||
lastAttemptedLabel = "password";
|
||||
onAuthAttempt?.("password");
|
||||
return callback({
|
||||
type: "password",
|
||||
@@ -425,10 +490,12 @@ function buildAuthHandler(options) {
|
||||
password,
|
||||
});
|
||||
} else if (method.type === "keyboard-interactive" && availableMethods.includes("keyboard-interactive")) {
|
||||
lastAttemptedLabel = "keyboard-interactive";
|
||||
onAuthAttempt?.("keyboard-interactive");
|
||||
return callback("keyboard-interactive");
|
||||
}
|
||||
}
|
||||
onAuthAttempt?.("all methods exhausted");
|
||||
return callback(false);
|
||||
};
|
||||
|
||||
@@ -589,7 +656,9 @@ async function requestPassphrasesForEncryptedKeys(sender, hostname) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_KEY_NAMES,
|
||||
PREFERRED_KEY_NAMES,
|
||||
SSH_KEY_PATTERN,
|
||||
looksLikePrivateKey,
|
||||
isKeyEncrypted,
|
||||
findDefaultPrivateKey,
|
||||
findAllDefaultPrivateKeys,
|
||||
|
||||
@@ -23,9 +23,24 @@ const {
|
||||
getSshAgentSocket,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
|
||||
// Default SSH key names in priority order
|
||||
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
// Default SSH key names in priority order (preferred keys tried first)
|
||||
const PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
// Match any private key file: id_* but not *.pub
|
||||
const SSH_KEY_PATTERN = /^id_[\w-]+$/;
|
||||
|
||||
/**
|
||||
* Quick check if file content looks like an SSH private key.
|
||||
* Rejects non-key files that happen to match the id_* filename pattern.
|
||||
*/
|
||||
function looksLikePrivateKey(content) {
|
||||
if (!content || typeof content !== "string") return false;
|
||||
const trimmed = content.trimStart();
|
||||
return trimmed.startsWith("-----BEGIN") ||
|
||||
trimmed.startsWith("openssh-key-v1") ||
|
||||
trimmed.startsWith("PuTTY-User-Key-File");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an SSH private key is encrypted (requires passphrase)
|
||||
@@ -33,6 +48,12 @@ const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
* @returns {boolean} - True if the key is encrypted
|
||||
*/
|
||||
function isKeyEncrypted(keyContent) {
|
||||
// Check for PuTTY PPK encrypted format
|
||||
const ppkEncMatch = keyContent.match(/^Encryption:\s*(.+)$/m);
|
||||
if (ppkEncMatch && ppkEncMatch[1].trim() !== "none") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for PKCS#8 encrypted format (-----BEGIN ENCRYPTED PRIVATE KEY-----)
|
||||
if (keyContent.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----")) {
|
||||
return true;
|
||||
@@ -82,14 +103,31 @@ function isKeyEncrypted(keyContent) {
|
||||
*/
|
||||
async function findDefaultPrivateKey() {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
log("Searching for default SSH keys", { sshDir, keyNames: DEFAULT_KEY_NAMES });
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
// Scan ~/.ssh/ for all files matching id_* (same as Tabby/OpenSSH),
|
||||
// with preferred key types tried first
|
||||
let allNames = [];
|
||||
try {
|
||||
const entries = await fs.promises.readdir(sshDir);
|
||||
allNames = entries.filter(f => SSH_KEY_PATTERN.test(f));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Sort: preferred keys first (in order), then rest alphabetically
|
||||
const preferred = PREFERRED_KEY_NAMES.filter(n => allNames.includes(n));
|
||||
const rest = allNames.filter(n => !PREFERRED_KEY_NAMES.includes(n)).sort();
|
||||
const sorted = [...preferred, ...rest];
|
||||
log("Searching for default SSH keys", { sshDir, found: sorted });
|
||||
|
||||
for (const name of sorted) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const stat = await fs.promises.stat(keyPath);
|
||||
if (!stat.isFile()) continue;
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
// Skip encrypted keys - they require a passphrase and would abort
|
||||
// authentication before password/keyboard-interactive can be tried
|
||||
if (!looksLikePrivateKey(privateKey)) {
|
||||
log("Skipping non-key file", { keyPath, keyName: name });
|
||||
continue;
|
||||
}
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
log("Key file read", { keyPath, keyName: name, encrypted, keyLength: privateKey.length });
|
||||
if (encrypted) {
|
||||
@@ -114,13 +152,28 @@ async function findDefaultPrivateKey() {
|
||||
*/
|
||||
async function findAllDefaultPrivateKeys() {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
log("Searching for ALL default SSH keys", { sshDir, keyNames: DEFAULT_KEY_NAMES });
|
||||
let allNames = [];
|
||||
try {
|
||||
const entries = await fs.promises.readdir(sshDir);
|
||||
allNames = entries.filter(f => SSH_KEY_PATTERN.test(f));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const preferred = PREFERRED_KEY_NAMES.filter(n => allNames.includes(n));
|
||||
const rest = allNames.filter(n => !PREFERRED_KEY_NAMES.includes(n)).sort();
|
||||
const sorted = [...preferred, ...rest];
|
||||
log("Searching for ALL default SSH keys", { sshDir, found: sorted });
|
||||
|
||||
const promises = DEFAULT_KEY_NAMES.map(async (name) => {
|
||||
const promises = sorted.map(async (name) => {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const stat = await fs.promises.stat(keyPath);
|
||||
if (!stat.isFile()) return null;
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
if (!looksLikePrivateKey(privateKey)) {
|
||||
log("Skipping non-key file", { keyPath, keyName: name });
|
||||
return null;
|
||||
}
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
if (!encrypted) {
|
||||
log("Found default key for fallback", { keyPath, keyName: name });
|
||||
@@ -330,7 +383,7 @@ function init(deps) {
|
||||
*/
|
||||
async function connectThroughChain(event, options, jumpHosts, targetHost, targetPort, sessionId) {
|
||||
const sender = event.sender;
|
||||
const connections = [];
|
||||
const connections = options?._connectionsRef || [];
|
||||
let currentSocket = null;
|
||||
|
||||
const sendProgress = (hop, total, label, status, error) => {
|
||||
@@ -352,6 +405,10 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'connecting');
|
||||
|
||||
const conn = new SSHClient();
|
||||
if (options?._tunnelRef) {
|
||||
options._tunnelRef.pendingConn = conn;
|
||||
options._tunnelRef.chainConnections = connections;
|
||||
}
|
||||
|
||||
// Build connection options
|
||||
const connOpts = {
|
||||
@@ -387,7 +444,64 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
connOpts.agent = authAgent;
|
||||
} else if (jump.privateKey) {
|
||||
connOpts.privateKey = jump.privateKey;
|
||||
if (jump.passphrase) connOpts.passphrase = jump.passphrase;
|
||||
if (jump.passphrase) {
|
||||
connOpts.passphrase = jump.passphrase;
|
||||
} else if (isKeyEncrypted(jump.privateKey)) {
|
||||
// Key is encrypted but no passphrase provided — prompt the user
|
||||
console.log(`[Chain] Hop ${i + 1}: key is encrypted, requesting passphrase`);
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'passphrase required');
|
||||
const keyLabel = jump.label || hopLabel;
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
sender,
|
||||
`SSH key for ${keyLabel}`,
|
||||
keyLabel,
|
||||
hopLabel
|
||||
);
|
||||
if (result?.passphrase) {
|
||||
connOpts.passphrase = result.passphrase;
|
||||
} else {
|
||||
// No passphrase (cancelled/skipped/timeout) — remove the encrypted
|
||||
// key so buildAuthHandler won't try it and stall auth.
|
||||
delete connOpts.privateKey;
|
||||
if (result?.cancelled) {
|
||||
throw new Error(`Passphrase entry cancelled for ${hopLabel}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read identity files from local paths (e.g. from SSH config IdentityFile)
|
||||
if (!connOpts.privateKey && !connOpts.agent && jump.identityFilePaths?.length > 0) {
|
||||
for (const keyPath of jump.identityFilePaths) {
|
||||
try {
|
||||
const resolvedPath = keyPath.startsWith("~/")
|
||||
? path.join(os.homedir(), keyPath.slice(2))
|
||||
: keyPath;
|
||||
const keyContent = await fs.promises.readFile(resolvedPath, "utf8");
|
||||
connOpts.privateKey = keyContent;
|
||||
if (isKeyEncrypted(keyContent)) {
|
||||
console.log(`[Chain] Hop ${i + 1}: identity file ${resolvedPath} is encrypted, requesting passphrase`);
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'passphrase required');
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
sender,
|
||||
resolvedPath,
|
||||
path.basename(resolvedPath),
|
||||
hopLabel
|
||||
);
|
||||
if (result?.passphrase) {
|
||||
connOpts.passphrase = result.passphrase;
|
||||
} else {
|
||||
// Cancelled/skipped/timeout — clear encrypted key, try next file
|
||||
delete connOpts.privateKey;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
console.log(`[Chain] Hop ${i + 1}: loaded identity file ${resolvedPath}`);
|
||||
break;
|
||||
} catch (err) {
|
||||
console.warn(`[Chain] Hop ${i + 1}: failed to read identity file ${keyPath}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (jump.password) connOpts.password = jump.password;
|
||||
@@ -413,8 +527,21 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
applyAuthToConnOpts(connOpts, authConfig);
|
||||
|
||||
// If first hop and proxy is configured, connect through proxy
|
||||
if (isFirst && options.proxy) {
|
||||
currentSocket = await createProxySocket(options.proxy, jump.hostname, jump.port || 22);
|
||||
const hasUsableJumpProxy = !!(jump.proxy?.host && jump.proxy?.port);
|
||||
const effectiveHopProxy = isFirst ? ((hasUsableJumpProxy ? jump.proxy : null) || options.proxy) : null;
|
||||
if (effectiveHopProxy) {
|
||||
currentSocket = await createProxySocket(effectiveHopProxy, jump.hostname, jump.port || 22, {
|
||||
onSocket: (socket) => {
|
||||
if (options?._tunnelRef) {
|
||||
options._tunnelRef.pendingConn = socket;
|
||||
options._tunnelRef.chainConnections = connections;
|
||||
}
|
||||
},
|
||||
});
|
||||
if (options?._tunnelRef) {
|
||||
options._tunnelRef.pendingConn = null;
|
||||
options._tunnelRef.chainConnections = connections;
|
||||
}
|
||||
connOpts.sock = currentSocket;
|
||||
delete connOpts.host;
|
||||
delete connOpts.port;
|
||||
@@ -434,6 +561,10 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
conn.once('ready', () => {
|
||||
console.log(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} connected`);
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'connected');
|
||||
if (options?._tunnelRef) {
|
||||
options._tunnelRef.pendingConn = null;
|
||||
options._tunnelRef.chainConnections = connections;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
conn.once('error', (err) => {
|
||||
@@ -448,13 +579,23 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
reject(new Error(errMsg));
|
||||
});
|
||||
// Handle keyboard-interactive authentication for jump hosts (2FA/MFA)
|
||||
conn.on('keyboard-interactive', createKeyboardInteractiveHandler({
|
||||
const chainKiHandler = createKeyboardInteractiveHandler({
|
||||
sender,
|
||||
sessionId,
|
||||
hostname: hopLabel,
|
||||
password: jump.password,
|
||||
logPrefix: `[Chain] Hop ${i + 1}/${totalHops}`,
|
||||
}));
|
||||
});
|
||||
conn.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => {
|
||||
if (prompts && prompts.length > 0) {
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'waiting for user input...');
|
||||
}
|
||||
const wrappedFinish = (...args) => {
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'user responded');
|
||||
finish(...args);
|
||||
};
|
||||
chainKiHandler(name, instructions, lang, prompts, wrappedFinish);
|
||||
});
|
||||
console.log(`[Chain] Hop ${i + 1}/${totalHops}: Connecting to ${hopLabel}...`);
|
||||
conn.connect(connOpts);
|
||||
});
|
||||
@@ -497,6 +638,10 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
sendProgress
|
||||
};
|
||||
} catch (err) {
|
||||
if (options?._tunnelRef) {
|
||||
options._tunnelRef.pendingConn = null;
|
||||
options._tunnelRef.chainConnections = connections;
|
||||
}
|
||||
// Cleanup on error
|
||||
for (const conn of connections) {
|
||||
try { conn.end(); } catch { }
|
||||
@@ -590,6 +735,41 @@ async function startSSHSession(event, options) {
|
||||
}
|
||||
}
|
||||
|
||||
// Read identity files from local paths (e.g. from SSH config IdentityFile)
|
||||
// Only if no explicit key was already configured
|
||||
if (!connectOpts.privateKey && !connectOpts.agent && options.identityFilePaths?.length > 0) {
|
||||
for (const keyPath of options.identityFilePaths) {
|
||||
try {
|
||||
const resolvedPath = keyPath.startsWith("~/")
|
||||
? path.join(os.homedir(), keyPath.slice(2))
|
||||
: keyPath;
|
||||
const keyContent = await fs.promises.readFile(resolvedPath, "utf8");
|
||||
connectOpts.privateKey = keyContent;
|
||||
// Check if key is encrypted — if so, prompt for passphrase
|
||||
if (isKeyEncrypted(keyContent)) {
|
||||
log("Identity file is encrypted, requesting passphrase", { keyPath: resolvedPath });
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
sender,
|
||||
resolvedPath,
|
||||
path.basename(resolvedPath),
|
||||
options.hostname
|
||||
);
|
||||
if (result?.passphrase) {
|
||||
connectOpts.passphrase = result.passphrase;
|
||||
} else {
|
||||
// Cancelled/skipped/timeout — clear encrypted key, try next file
|
||||
delete connectOpts.privateKey;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
log("Loaded identity file", { keyPath: resolvedPath, encrypted: isKeyEncrypted(keyContent) });
|
||||
break; // Use the first successfully loaded key
|
||||
} catch (err) {
|
||||
log("Failed to read identity file", { keyPath, error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.password && typeof options.password === "string" && options.password.trim().length > 0) {
|
||||
connectOpts.password = options.password;
|
||||
}
|
||||
@@ -668,7 +848,7 @@ async function startSSHSession(event, options) {
|
||||
let lastTriedMethod = null;
|
||||
|
||||
if (authAgent) {
|
||||
const order = ["agent"];
|
||||
const order = ["none", "agent"];
|
||||
if (connectOpts.password) order.push("password");
|
||||
// Add default key fallback if available and no user key configured
|
||||
// Must also set connectOpts.privateKey for ssh2 to actually try publickey auth
|
||||
@@ -746,8 +926,9 @@ async function startSSHSession(event, options) {
|
||||
}
|
||||
}
|
||||
|
||||
// Use dynamic authHandler if we have multiple auth options
|
||||
if (authMethods.length > 1) {
|
||||
// Always use dynamic authHandler to ensure consistent "none" probing
|
||||
// and auth method logging regardless of how many methods are configured
|
||||
if (authMethods.length >= 1) {
|
||||
let authIndex = 0;
|
||||
// Track methods that have been attempted (to avoid re-trying on failure)
|
||||
// This prevents reusing the same key when server requires multiple publickey auth steps
|
||||
@@ -761,6 +942,22 @@ async function startSSHSession(event, options) {
|
||||
connectOpts.authHandler = (methodsLeft, partialSuccess, callback) => {
|
||||
log("authHandler called", { methodsLeft, partialSuccess, authIndex, attemptedMethodIds: Array.from(attemptedMethodIds) });
|
||||
|
||||
// Log rejection of previous method
|
||||
if (lastTriedMethod && !partialSuccess) {
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', `${lastTriedMethod} rejected`);
|
||||
}
|
||||
|
||||
// On the very first call (methodsLeft === null), try "none" auth.
|
||||
// Per RFC 4252, the "none" request is how the client discovers which
|
||||
// methods the server supports. It also allows passwordless login on
|
||||
// embedded devices. This matches the behavior of OpenSSH and Tabby.
|
||||
if (methodsLeft === null && !attemptedMethodIds.has("none")) {
|
||||
attemptedMethodIds.add("none");
|
||||
lastTriedMethod = "none";
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'none (no credentials)');
|
||||
return callback("none");
|
||||
}
|
||||
|
||||
// methodsLeft can be null on first call (before server responds with available methods)
|
||||
// Include "agent" for SSH agent-based auth (used with agentForwarding)
|
||||
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];
|
||||
@@ -897,6 +1094,7 @@ async function startSSHSession(event, options) {
|
||||
}
|
||||
|
||||
log("All auth methods exhausted");
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'all methods exhausted');
|
||||
return callback(false);
|
||||
};
|
||||
|
||||
@@ -1008,6 +1206,9 @@ async function startSSHSession(event, options) {
|
||||
hostname: options.host || options.hostname || '',
|
||||
username: options.username || '',
|
||||
label: options.label || '',
|
||||
lastIdlePrompt: '',
|
||||
lastIdlePromptAt: 0,
|
||||
_promptTrackTail: '',
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
@@ -1055,6 +1256,7 @@ async function startSSHSession(event, options) {
|
||||
stream.on("data", (data) => {
|
||||
const decoder = getSessionDecoder(sessionId, "stdout");
|
||||
const decoded = decoder.write(data);
|
||||
trackSessionIdlePrompt(session, decoded);
|
||||
bufferData(decoded);
|
||||
sessionLogStreamManager.appendData(sessionId, decoded);
|
||||
});
|
||||
@@ -1080,17 +1282,29 @@ async function startSSHSession(event, options) {
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
// Flush any remaining data before close
|
||||
// Always flush buffered data regardless of session state
|
||||
if (flushTimeout) {
|
||||
clearTimeout(flushTimeout);
|
||||
}
|
||||
flushBuffer();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: streamExitCode, reason: streamExited ? "exited" : "closed" });
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
|
||||
// Only send exit if session hasn't already been cleaned up by
|
||||
// conn.once("close") — which fires before stream.on("close")
|
||||
// in ssh2 when the transport drops.
|
||||
if (sessions.has(sessionId)) {
|
||||
const contents = event.sender;
|
||||
const session = sessions.get(sessionId);
|
||||
const transportError = session?._transportError;
|
||||
if (transportError) {
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: transportError, reason: "error" });
|
||||
} else {
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: streamExitCode, reason: streamExited ? "exited" : "closed" });
|
||||
}
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
}
|
||||
conn.end();
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch { }
|
||||
@@ -1116,6 +1330,22 @@ async function startSSHSession(event, options) {
|
||||
});
|
||||
|
||||
conn.on("error", (err) => {
|
||||
// After the promise is settled, we can't reject again. But if the
|
||||
// session was already established (resolved), we still need to notify
|
||||
// the renderer about transport errors so the session shows as failed
|
||||
// rather than silently closing.
|
||||
// Don't send netcatty:exit here — the stream close handler will flush
|
||||
// any buffered data first and then send exit with this error info.
|
||||
if (settled) {
|
||||
console.warn(`${logPrefix} ${options.hostname} post-settle error:`, err.message);
|
||||
// Store the error so the close handler can include it in the exit event
|
||||
if (sessions.has(sessionId)) {
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) session._transportError = err.message;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const contents = event.sender;
|
||||
|
||||
const isAuthError = err.message?.toLowerCase().includes('authentication') ||
|
||||
@@ -1145,6 +1375,9 @@ async function startSSHSession(event, options) {
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
// Destroy the connection to prevent further socket errors from leaking
|
||||
// as uncaught exceptions (e.g. ECONNRESET on embedded devices).
|
||||
try { conn.destroy(); } catch { }
|
||||
settled = true;
|
||||
reject(err);
|
||||
});
|
||||
@@ -1162,6 +1395,7 @@ async function startSSHSession(event, options) {
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
try { conn.destroy(); } catch { }
|
||||
settled = true;
|
||||
reject(err);
|
||||
});
|
||||
@@ -1171,7 +1405,19 @@ async function startSSHSession(event, options) {
|
||||
if (!settled) {
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'error', `Connection to ${options.hostname} closed unexpectedly`);
|
||||
}
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
|
||||
// Only send exit if the session hasn't already been cleaned up by the
|
||||
// error handler (avoids sending a misleading exitCode:0 "closed" after
|
||||
// a real transport error was already reported).
|
||||
if (sessions.has(sessionId)) {
|
||||
const session = sessions.get(sessionId);
|
||||
const transportError = session?._transportError;
|
||||
if (transportError) {
|
||||
// A transport error was recorded — report it as an error exit
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: transportError, reason: "error" });
|
||||
} else {
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
|
||||
}
|
||||
}
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
@@ -1201,12 +1447,15 @@ async function startSSHSession(event, options) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'waiting for user input...');
|
||||
|
||||
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
|
||||
// (Prompt text is admin-customizable and may not contain expected keywords)
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
|
||||
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`${logPrefix} Received user responses, finishing keyboard-interactive`);
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'user responded');
|
||||
finish(userResponses);
|
||||
}, sender.id, sessionId);
|
||||
|
||||
@@ -1322,10 +1571,11 @@ async function execCommand(event, payload) {
|
||||
});
|
||||
});
|
||||
})
|
||||
.once("error", (err) => {
|
||||
.on("error", (err) => {
|
||||
if (settled) return;
|
||||
clearTimeout(timer);
|
||||
settled = true;
|
||||
conn.end();
|
||||
reject(err);
|
||||
})
|
||||
.once("end", () => {
|
||||
@@ -1507,7 +1757,11 @@ async function startSSHSessionWrapper(event, options) {
|
||||
authError.isAuthError = true;
|
||||
throw authError;
|
||||
}
|
||||
throw retryErr;
|
||||
// Wrap non-auth retry errors as connection errors to prevent crash
|
||||
const connError = new Error(retryErr.message);
|
||||
connError.level = retryErr.level || 'client-socket';
|
||||
connError.code = retryErr.code;
|
||||
throw connError;
|
||||
}
|
||||
} else {
|
||||
console.log('[SSH] User did not unlock any keys, not retrying');
|
||||
@@ -1522,7 +1776,15 @@ async function startSSHSessionWrapper(event, options) {
|
||||
authError.isAuthError = true;
|
||||
throw authError;
|
||||
}
|
||||
throw err;
|
||||
|
||||
// Non-auth errors (e.g. ECONNRESET, ETIMEDOUT) — wrap in a clean Error
|
||||
// so Electron's ipcMain.handle can serialize it back to the renderer
|
||||
// instead of it becoming an uncaught exception that crashes the app.
|
||||
// See: https://github.com/nicely-gg/netcatty/issues/482
|
||||
const connError = new Error(err.message);
|
||||
connError.level = err.level || 'client-socket';
|
||||
connError.code = err.code;
|
||||
throw connError;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1592,11 +1854,41 @@ async function getServerStats(event, payload) {
|
||||
|
||||
const conn = session.conn;
|
||||
|
||||
// macOS stats command: uses sysctl, vm_stat, top, ps, df, netstat
|
||||
// CPU reported as direct percentage (top computes delta internally)
|
||||
// cpuPerCore not available on macOS without sudo
|
||||
const macosStatsCommand = [
|
||||
`cores=$(sysctl -n hw.logicalcpu 2>/dev/null || echo "1")`,
|
||||
`pagesize=$(sysctl -n hw.pagesize 2>/dev/null || echo "4096")`,
|
||||
`memsize=$(sysctl -n hw.memsize 2>/dev/null || echo "0")`,
|
||||
// CPU usage: top -l 1 gives one logging sample, parse idle%
|
||||
`cpuline=$(top -l 1 -s 0 -n 0 2>/dev/null | grep "CPU usage:" | head -1)`,
|
||||
`cpupct=$(echo "$cpuline" | awk '{for(i=1;i<=NF;i++){if($(i+1)~/^idle/){v=$i;gsub(/%/,"",v);idle=v+0;found=1}};if(found)printf "%.0f",100-idle}')`,
|
||||
// Memory: single vm_stat pipe → awk extracts all page counts (strip trailing dots with gsub)
|
||||
// Outputs: "memfree memcached" in MB
|
||||
`vmmem=$(vm_stat 2>/dev/null | awk -v ps="$pagesize" '/^Pages free:/{gsub(/[^0-9]/,"",$NF);free=$NF+0} /^Pages speculative:/{gsub(/[^0-9]/,"",$NF);spec=$NF+0} /^Pages inactive:/{gsub(/[^0-9]/,"",$NF);inact=$NF+0} /^Pages purgeable:/{gsub(/[^0-9]/,"",$NF);purg=$NF+0} END{mfree=int((free+spec)*ps/1024/1024);mcached=int((inact+purg)*ps/1024/1024);printf "%d %d",mfree,mcached}')`,
|
||||
`memtotal=$(echo "$memsize" | awk '{printf "%d",$1/1024/1024}')`,
|
||||
`memfree=$(echo "$vmmem" | awk '{print $1}')`,
|
||||
`memcached=$(echo "$vmmem" | awk '{print $2}')`,
|
||||
// Swap
|
||||
`swapraw=$(sysctl vm.swapusage 2>/dev/null)`,
|
||||
`swaptotal=$(echo "$swapraw" | awk '{for(i=1;i<=NF;i++){if($i=="total"&&$(i+1)=="="){v=$(i+2);m=1;if(v~/G/)m=1024;gsub(/[MmGg]/,"",v);st=v*m}};printf "%.0f",st+0}')`,
|
||||
`swapused=$(echo "$swapraw" | awk '{for(i=1;i<=NF;i++){if($i=="used"&&$(i+1)=="="){v=$(i+2);m=1;if(v~/G/)m=1024;gsub(/[MmGg]/,"",v);su=v*m}};printf "%.0f",su+0}')`,
|
||||
`swapfree=$(echo "$swaptotal $swapused" | awk '{printf "%.0f",$1-$2}')`,
|
||||
// Top processes by memory%
|
||||
`procs=$(ps -A -o pid=,%mem=,comm= 2>/dev/null | sort -k2 -rn | head -10 | awk '{gsub(/;/,"_",$3);printf "%s;%.1f;%s,",$1,$2,$3}' | sed 's/,$//')`,
|
||||
// Disk: only show root "/" and external volumes "/Volumes/*", skip system APFS snapshots
|
||||
`disks=$(df -k 2>/dev/null | awk 'NR>1&&index($1,"/dev/")==1&&NF>=9&&($NF=="/"||index($NF,"/Volumes/")==1){u=$3/1048576;t=$2/1048576;p=$5;gsub(/%/,"",p);printf "%s:%.0f:%.0f:%s,",$NF,u,t,p}' | sed 's/,$//')`,
|
||||
// Network: Link# lines only, exclude loopback, detect column shift (no MAC addr → cols shift left)
|
||||
`net=$(netstat -ib 2>/dev/null | awk '/^[a-z]/&&$3~/Link/&&$1!~/^lo/{if($4~/:/){rx=$7;tx=$10}else{rx=$6;tx=$9};if((rx+0)>0){gsub(/[*]/,"",$1);printf "%s:%s:%s,",$1,rx,tx}}' | sed 's/,$//')`,
|
||||
`echo "CPU:$cpupct|CORES:$cores|MEMINFO:$memtotal $memfree 0 $memcached $swaptotal $swapfree|PROCS:$procs|DISKS:$disks|NET:$net"`,
|
||||
].join('; ');
|
||||
|
||||
// Command to get CPU (overall + per-core), Memory, Disk, and Network stats
|
||||
// This command is designed to work across most Linux distributions
|
||||
// Note: Using semicolons and avoiding comments for single-line execution
|
||||
// CPU: Output raw values (total and idle) instead of percentage - we calculate delta on backend
|
||||
const statsCommand = [
|
||||
const linuxStatsCommand = [
|
||||
// Get number of CPU cores
|
||||
`cores=$(nproc 2>/dev/null || grep -c "^processor" /proc/cpuinfo 2>/dev/null || echo "1")`,
|
||||
// Get raw CPU values from /proc/stat: "total idle" for overall CPU
|
||||
@@ -1620,6 +1912,8 @@ async function getServerStats(event, payload) {
|
||||
`echo "CPURAW:$cpuraw|CORES:$cores|PERCORERAW:$percoreraw|MEMINFO:$meminfo|PROCS:$procs|DISKS:$disks|NET:$net"`
|
||||
].join('; ');
|
||||
|
||||
// Auto-detect OS via uname — only Linux and macOS are supported
|
||||
const statsCommand = `ostype=$(uname -s 2>/dev/null || echo "Unknown"); if [ "$ostype" = "Darwin" ]; then ${macosStatsCommand}; elif [ "$ostype" = "Linux" ]; then ${linuxStatsCommand}; else echo "UNSUPPORTED_OS:$ostype"; fi`;
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
resolve({ success: false, error: 'Timeout getting server stats' });
|
||||
@@ -1648,8 +1942,16 @@ async function getServerStats(event, payload) {
|
||||
|
||||
// Parse the output
|
||||
const output = stdout.trim();
|
||||
|
||||
// Unsupported OS — stop polling this session
|
||||
if (output.startsWith('UNSUPPORTED_OS:')) {
|
||||
resolve({ success: false, error: `Server stats not supported on this OS (${output.substring(15)})` });
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = output.split('|');
|
||||
|
||||
let cpuDirect = null; // macOS: direct CPU percentage from top
|
||||
let cpuRawTotal = null;
|
||||
let cpuRawIdle = null;
|
||||
let cpuPerCoreRaw = []; // Array of { total, idle }
|
||||
@@ -1666,7 +1968,11 @@ async function getServerStats(event, payload) {
|
||||
let networkInterfaces = []; // Array of { name, rxBytes, txBytes }
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.startsWith('CPURAW:')) {
|
||||
if (part.startsWith('CPU:')) {
|
||||
// macOS: top reports CPU% directly (no delta needed)
|
||||
const val = parseFloat(part.substring(4).trim());
|
||||
if (!isNaN(val)) cpuDirect = Math.min(100, Math.max(0, Math.round(val)));
|
||||
} else if (part.startsWith('CPURAW:')) {
|
||||
const rawParts = part.substring(7).trim().split(/\s+/);
|
||||
if (rawParts.length >= 2) {
|
||||
cpuRawTotal = parseInt(rawParts[0], 10);
|
||||
@@ -1843,6 +2149,11 @@ async function getServerStats(event, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
// macOS: use direct percentage from top (no delta needed)
|
||||
if (cpu === null && cpuDirect !== null) {
|
||||
cpu = cpuDirect;
|
||||
}
|
||||
|
||||
// Calculate per-core CPU usage from deltas
|
||||
if (cpuPerCoreRaw.length > 0 && prevCpu.perCore.length > 0) {
|
||||
cpuPerCore = cpuPerCoreRaw.map((core, index) => {
|
||||
@@ -1878,6 +2189,12 @@ async function getServerStats(event, payload) {
|
||||
const diskUsed = rootDisk ? rootDisk.used : null;
|
||||
const diskTotal = rootDisk ? rootDisk.total : null;
|
||||
|
||||
// If no meaningful data was parsed, treat as failure to stop futile polling
|
||||
if (cpu === null && memTotal === null && cpuCores === null) {
|
||||
resolve({ success: false, error: 'Unable to parse server stats (unsupported OS or shell)' });
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
success: true,
|
||||
stats: {
|
||||
@@ -1940,14 +2257,17 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:ssh:get-default-keys", async () => {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
const keys = [];
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
keys.push({ name, path: keyPath });
|
||||
} catch {
|
||||
// ignore missing keys
|
||||
try {
|
||||
const entries = await fs.promises.readdir(sshDir);
|
||||
const names = entries.filter(f => SSH_KEY_PATTERN.test(f));
|
||||
// Preferred first, then rest alphabetically
|
||||
const preferred = PREFERRED_KEY_NAMES.filter(n => names.includes(n));
|
||||
const rest = names.filter(n => !PREFERRED_KEY_NAMES.includes(n)).sort();
|
||||
for (const name of [...preferred, ...rest]) {
|
||||
keys.push({ name, path: path.join(sshDir, name) });
|
||||
}
|
||||
} catch {
|
||||
// ~/.ssh doesn't exist
|
||||
}
|
||||
return keys;
|
||||
});
|
||||
@@ -1960,6 +2280,7 @@ function registerHandlers(ipcMain) {
|
||||
module.exports = {
|
||||
init,
|
||||
registerHandlers,
|
||||
connectThroughChain,
|
||||
createProxySocket,
|
||||
startSSHSession,
|
||||
execCommand,
|
||||
|
||||
@@ -13,6 +13,7 @@ const { SerialPort } = require("serialport");
|
||||
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
const { detectShellKind } = require("./ai/ptyExec.cjs");
|
||||
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
|
||||
// Shared references
|
||||
let sessions = null;
|
||||
@@ -52,6 +53,51 @@ function init(deps) {
|
||||
electronModule = deps.electronModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an 8ms/16KB PTY data buffer for reduced IPC overhead.
|
||||
* Mirrors the SSH stream buffering strategy in sshBridge.cjs.
|
||||
* @param {Function} sendFn - called with the accumulated string to deliver
|
||||
* @returns {{ bufferData: (data: string) => void, flush: () => void }}
|
||||
*/
|
||||
function createPtyBuffer(sendFn) {
|
||||
const FLUSH_INTERVAL = 8; // ms - flush every 8ms (~120fps equivalent)
|
||||
const MAX_BUFFER_SIZE = 16384; // 16KB - flush immediately if buffer grows too large
|
||||
|
||||
let dataBuffer = '';
|
||||
let flushTimeout = null;
|
||||
|
||||
const flushBuffer = () => {
|
||||
if (dataBuffer.length > 0) {
|
||||
sendFn(dataBuffer);
|
||||
dataBuffer = '';
|
||||
}
|
||||
flushTimeout = null;
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
if (flushTimeout) {
|
||||
clearTimeout(flushTimeout);
|
||||
flushTimeout = null;
|
||||
}
|
||||
flushBuffer();
|
||||
};
|
||||
|
||||
const bufferData = (data) => {
|
||||
dataBuffer += data;
|
||||
if (dataBuffer.length >= MAX_BUFFER_SIZE) {
|
||||
if (flushTimeout) {
|
||||
clearTimeout(flushTimeout);
|
||||
flushTimeout = null;
|
||||
}
|
||||
flushBuffer();
|
||||
} else if (!flushTimeout) {
|
||||
flushTimeout = setTimeout(flushBuffer, FLUSH_INTERVAL);
|
||||
}
|
||||
};
|
||||
|
||||
return { bufferData, flush };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find executable path on Windows
|
||||
*/
|
||||
@@ -245,6 +291,10 @@ function startLocalSession(event, payload) {
|
||||
label: "Local Terminal",
|
||||
shellExecutable: shell,
|
||||
shellKind,
|
||||
flushPendingData: null,
|
||||
lastIdlePrompt: "",
|
||||
lastIdlePromptAt: 0,
|
||||
_promptTrackTail: "",
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
@@ -259,13 +309,20 @@ function startLocalSession(event, payload) {
|
||||
});
|
||||
}
|
||||
|
||||
proc.onData((data) => {
|
||||
const { bufferData: bufferLocalData, flush: flushLocal } = createPtyBuffer((data) => {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
});
|
||||
session.flushPendingData = flushLocal;
|
||||
|
||||
proc.onData((data) => {
|
||||
trackSessionIdlePrompt(session, data);
|
||||
bufferLocalData(data);
|
||||
sessionLogStreamManager.appendData(sessionId, data);
|
||||
});
|
||||
|
||||
proc.onExit((evt) => {
|
||||
flushLocal();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
@@ -434,7 +491,12 @@ async function startTelnetSession(event, options) {
|
||||
webContentsId: event.sender.id,
|
||||
cols,
|
||||
rows,
|
||||
flushPendingData: null,
|
||||
lastIdlePrompt: "",
|
||||
lastIdlePromptAt: 0,
|
||||
_promptTrackTail: "",
|
||||
};
|
||||
session.flushPendingData = flushTelnet;
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
// Start real-time session log stream if configured
|
||||
@@ -463,6 +525,12 @@ async function startTelnetSession(event, options) {
|
||||
|
||||
const telnetDecoder = new StringDecoder(charsetToNodeEncoding(options.charset));
|
||||
|
||||
const telnetWebContentsId = event.sender.id;
|
||||
const { bufferData: bufferTelnetData, flush: flushTelnet } = createPtyBuffer((data) => {
|
||||
const contents = electronModule.webContents.fromId(telnetWebContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
});
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
@@ -472,8 +540,8 @@ async function startTelnetSession(event, options) {
|
||||
if (cleanData.length > 0) {
|
||||
const decoded = telnetDecoder.write(cleanData);
|
||||
if (decoded) {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data: decoded });
|
||||
trackSessionIdlePrompt(session, decoded);
|
||||
bufferTelnetData(decoded);
|
||||
sessionLogStreamManager.appendData(sessionId, decoded);
|
||||
}
|
||||
}
|
||||
@@ -486,6 +554,7 @@ async function startTelnetSession(event, options) {
|
||||
if (!connected) {
|
||||
reject(new Error(`Failed to connect: ${err.message}`));
|
||||
} else {
|
||||
flushTelnet();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) {
|
||||
@@ -500,6 +569,7 @@ async function startTelnetSession(event, options) {
|
||||
console.log(`[Telnet] Connection closed${hadError ? ' with error' : ''}`);
|
||||
clearTimeout(connectTimeout);
|
||||
|
||||
flushTelnet();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) {
|
||||
@@ -584,6 +654,10 @@ async function startMoshSession(event, options) {
|
||||
label: options.label || options.hostname || 'Mosh Session',
|
||||
shellKind: 'posix',
|
||||
shellExecutable: 'remote-shell',
|
||||
flushPendingData: null,
|
||||
lastIdlePrompt: "",
|
||||
lastIdlePromptAt: 0,
|
||||
_promptTrackTail: "",
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
@@ -598,13 +672,20 @@ async function startMoshSession(event, options) {
|
||||
});
|
||||
}
|
||||
|
||||
proc.onData((data) => {
|
||||
const { bufferData: bufferMoshData, flush: flushMosh } = createPtyBuffer((data) => {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
});
|
||||
session.flushPendingData = flushMosh;
|
||||
|
||||
proc.onData((data) => {
|
||||
trackSessionIdlePrompt(session, data);
|
||||
bufferMoshData(data);
|
||||
sessionLogStreamManager.appendData(sessionId, data);
|
||||
});
|
||||
|
||||
proc.onExit((evt) => {
|
||||
flushMosh();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
@@ -798,6 +879,7 @@ function closeSession(event, payload) {
|
||||
if (!session) return;
|
||||
|
||||
try {
|
||||
session.flushPendingData?.();
|
||||
if (session.stream) {
|
||||
session.stream.close();
|
||||
session.conn?.end();
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const globalShortcutBridge = require("./globalShortcutBridge.cjs");
|
||||
|
||||
const V8_CACHE_OPTIONS = "bypassHeatCheck";
|
||||
|
||||
function getGlobalShortcutBridge() {
|
||||
return require("./globalShortcutBridge.cjs");
|
||||
}
|
||||
|
||||
// Theme colors configuration
|
||||
const THEME_COLORS = {
|
||||
@@ -443,6 +448,7 @@ function createAppWindowOpenHandler(shell, { backgroundColor, appIcon }) {
|
||||
nodeIntegration: false,
|
||||
// Sandboxed because this window renders remote content and does not need a preload bridge.
|
||||
sandbox: true,
|
||||
v8CacheOptions: V8_CACHE_OPTIONS,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -531,27 +537,6 @@ function attachOAuthLoadingOverlay(win) {
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForRootPaint(win, { timeoutMs = 400, intervalMs = 30 } = {}) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
if (win.isDestroyed()) return false;
|
||||
const count = await win.webContents.executeJavaScript(
|
||||
`(() => {
|
||||
const root = document.getElementById("root");
|
||||
return root ? root.children.length : 0;
|
||||
})()`,
|
||||
true,
|
||||
);
|
||||
if (Number(count) > 0) return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function setupDeferredShow(win, { timeoutMs = 3000, waitForRendererReady = true } = {}) {
|
||||
const webContentsId = (() => {
|
||||
try {
|
||||
@@ -599,13 +584,10 @@ function setupDeferredShow(win, { timeoutMs = 3000, waitForRendererReady = true
|
||||
tryShow();
|
||||
});
|
||||
|
||||
win.webContents.once("did-finish-load", () => {
|
||||
void (async () => {
|
||||
// If the renderer mounts shortly after load, wait briefly to avoid showing a blank root.
|
||||
const painted = await waitForRootPaint(win, { timeoutMs: 800, intervalMs: 50 });
|
||||
if (painted) markRendererReady();
|
||||
})();
|
||||
});
|
||||
// Renderer calls netcattyBridge.rendererReady() after React mount,
|
||||
// which sends IPC "netcatty:renderer:ready" → markRendererReady().
|
||||
// The timeout fallback (timeoutMs) ensures the window is shown even if
|
||||
// the signal is never received.
|
||||
|
||||
// Dev/edge-case fallback: don't keep the window hidden forever.
|
||||
if (Number(timeoutMs) > 0) {
|
||||
@@ -687,6 +669,7 @@ async function createWindow(electronModule, options) {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
v8CacheOptions: V8_CACHE_OPTIONS,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -782,12 +765,12 @@ async function createWindow(electronModule, options) {
|
||||
// Save state when window is about to close
|
||||
win.on("close", (event) => {
|
||||
// Check if close-to-tray is enabled
|
||||
if (!isQuitting && globalShortcutBridge.handleWindowClose(event, win)) {
|
||||
if (!isQuitting && getGlobalShortcutBridge().handleWindowClose(event, win)) {
|
||||
// Window was hidden to tray - save state before returning
|
||||
if (saveStateTimer) clearTimeout(saveStateTimer);
|
||||
const state = getWindowBoundsState(win, lastNormalBounds);
|
||||
if (state) saveWindowStateSync(state);
|
||||
closeSettingsWindow();
|
||||
hideSettingsWindow();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -903,12 +886,13 @@ async function createWindow(electronModule, options) {
|
||||
/**
|
||||
* Create or focus the settings window
|
||||
*/
|
||||
async function openSettingsWindow(electronModule, options) {
|
||||
async function openSettingsWindow(electronModule, options, { showOnLoad = true } = {}) {
|
||||
const { BrowserWindow, shell } = electronModule;
|
||||
const { preload, devServerUrl, isDev, appIcon, isMac, electronDir } = options;
|
||||
|
||||
// If settings window already exists, just focus it
|
||||
// If settings window already exists, show and focus it
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
settingsWindow.show();
|
||||
settingsWindow.focus();
|
||||
return settingsWindow;
|
||||
}
|
||||
@@ -955,6 +939,7 @@ async function openSettingsWindow(electronModule, options) {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
v8CacheOptions: V8_CACHE_OPTIONS,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1039,10 +1024,20 @@ async function openSettingsWindow(electronModule, options) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Defer show until renderer is ready; use fallback timeout to avoid keeping window hidden forever.
|
||||
setupDeferredShow(win, { timeoutMs: isDev ? 1200 : 600, waitForRendererReady: false });
|
||||
// Hide instead of close so the window can be reused instantly.
|
||||
// When the app is quitting, allow normal close/destroy.
|
||||
win.on('close', (event) => {
|
||||
if (!isQuitting) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
win.hide();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up reference when closed
|
||||
// Clean up reference when actually destroyed
|
||||
win.on('closed', () => {
|
||||
settingsWindow = null;
|
||||
});
|
||||
@@ -1054,6 +1049,7 @@ async function openSettingsWindow(electronModule, options) {
|
||||
try {
|
||||
const baseUrl = getDevRendererBaseUrl(devServerUrl);
|
||||
await win.loadURL(`${baseUrl}${settingsPath}`);
|
||||
if (showOnLoad) { win.show(); win.focus(); }
|
||||
return win;
|
||||
} catch (e) {
|
||||
console.warn("Dev server not reachable for settings window", e);
|
||||
@@ -1062,20 +1058,51 @@ async function openSettingsWindow(electronModule, options) {
|
||||
|
||||
// Production mode - load via custom protocol.
|
||||
await win.loadURL("app://netcatty/index.html#/settings");
|
||||
if (showOnLoad) { win.show(); win.focus(); }
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the settings window
|
||||
* Destroy the settings window (used when the app is quitting).
|
||||
*/
|
||||
function closeSettingsWindow() {
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
settingsWindow.close();
|
||||
try {
|
||||
settingsWindow.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
settingsWindow = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the settings window without destroying it (used when main window hides to tray).
|
||||
*/
|
||||
function hideSettingsWindow() {
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
try {
|
||||
settingsWindow.hide();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-warm the settings window in the background so that opening it later is instant.
|
||||
* The window is created hidden and fully loaded; `openSettingsWindow` will simply show it.
|
||||
*/
|
||||
async function prewarmSettingsWindow(electronModule, options) {
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) return;
|
||||
try {
|
||||
await openSettingsWindow(electronModule, options, { showOnLoad: false });
|
||||
} catch (err) {
|
||||
debugLog("Failed to pre-warm settings window", { error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register window control IPC handlers (only once)
|
||||
*/
|
||||
@@ -1176,13 +1203,13 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
|
||||
|
||||
// Settings window close handler
|
||||
ipcMain.handle("netcatty:settings:close", (event) => {
|
||||
// Prefer closing the tracked settings window (if any).
|
||||
// Prefer hiding the tracked settings window (reused on next open).
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
debugLog("settings:close (tracked)", {
|
||||
senderId: event?.sender?.id,
|
||||
settingsId: settingsWindow.webContents?.id,
|
||||
});
|
||||
closeSettingsWindow();
|
||||
hideSettingsWindow();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1312,6 +1339,7 @@ module.exports = {
|
||||
createWindow,
|
||||
openSettingsWindow,
|
||||
closeSettingsWindow,
|
||||
prewarmSettingsWindow,
|
||||
buildAppMenu,
|
||||
getMainWindow,
|
||||
getSettingsWindow,
|
||||
|
||||
@@ -21,13 +21,52 @@ if (process.env.ELECTRON_RUN_AS_NODE) {
|
||||
// Load crash log bridge early so process-level error handlers can use it
|
||||
const crashLogBridge = require("./bridges/crashLogBridge.cjs");
|
||||
|
||||
// Handle uncaught exceptions for EPIPE errors
|
||||
// SSH / network errors that must never crash the process.
|
||||
// ssh2 can emit multiple 'error' events per connection (e.g. ECONNRESET followed
|
||||
// by "Connection lost before handshake"). If a listener is consumed after the first
|
||||
// event, the second becomes an uncaught exception. These are non-fatal for the app.
|
||||
function isNonFatalNetworkError(err) {
|
||||
if (!err) return false;
|
||||
// Any error with an ssh2 `level` property is a connection/auth-level error,
|
||||
// never a reason to kill the entire multi-session app.
|
||||
if (err.level) return true;
|
||||
const code = err.code;
|
||||
// Common TCP/DNS/routing errors that can surface from Node.js sockets
|
||||
// without an ssh2 `level` (e.g. proxy sockets, raw net.connect calls).
|
||||
switch (code) {
|
||||
case 'ECONNRESET':
|
||||
case 'ECONNREFUSED':
|
||||
case 'ECONNABORTED':
|
||||
case 'ETIMEDOUT':
|
||||
case 'ENOTFOUND':
|
||||
case 'EHOSTUNREACH':
|
||||
case 'EHOSTDOWN':
|
||||
case 'ENETUNREACH':
|
||||
case 'ENETDOWN':
|
||||
case 'EADDRNOTAVAIL':
|
||||
case 'EPROTO':
|
||||
case 'EPERM':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle uncaught exceptions — log all, only re-throw truly fatal ones
|
||||
process.on('uncaughtException', (err) => {
|
||||
// Skip benign stream teardown errors — don't pollute crash logs with false positives
|
||||
if (err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED') {
|
||||
console.warn('Ignored stream error:', err.code);
|
||||
return;
|
||||
}
|
||||
// Non-fatal SSH/network errors: log but do NOT crash the process
|
||||
if (isNonFatalNetworkError(err)) {
|
||||
if (!err.__fromUnhandledRejection) {
|
||||
try { crashLogBridge.captureError('uncaughtException', err); } catch {}
|
||||
}
|
||||
console.warn('Non-fatal uncaught exception (suppressed):', err.message);
|
||||
return;
|
||||
}
|
||||
// Skip logging if already captured by unhandledRejection handler
|
||||
if (!err.__fromUnhandledRejection) {
|
||||
try { crashLogBridge.captureError('uncaughtException', err); } catch {}
|
||||
@@ -40,6 +79,12 @@ process.on('unhandledRejection', (reason) => {
|
||||
// Skip benign stream teardown errors
|
||||
const code = reason?.code;
|
||||
if (code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED') return;
|
||||
// Non-fatal SSH/network errors: log but do NOT re-throw
|
||||
if (isNonFatalNetworkError(reason)) {
|
||||
try { crashLogBridge.captureError('unhandledRejection', reason); } catch {}
|
||||
console.warn('Non-fatal unhandled rejection (suppressed):', reason?.message || reason);
|
||||
return;
|
||||
}
|
||||
try { crashLogBridge.captureError('unhandledRejection', reason); } catch {}
|
||||
console.error('Unhandled rejection:', reason);
|
||||
// Re-throw to preserve fatal semantics. Mark so uncaughtException handler
|
||||
@@ -85,6 +130,16 @@ try {
|
||||
|
||||
// Apply ssh2 protocol patch needed for OpenSSH sk-* signature layouts.
|
||||
|
||||
function createLazyModule(modulePath) {
|
||||
let cachedModule = null;
|
||||
return () => {
|
||||
if (!cachedModule) {
|
||||
cachedModule = require(modulePath);
|
||||
}
|
||||
return cachedModule;
|
||||
};
|
||||
}
|
||||
|
||||
// Import bridge modules
|
||||
const sshBridge = require("./bridges/sshBridge.cjs");
|
||||
const sftpBridge = require("./bridges/sftpBridge.cjs");
|
||||
@@ -92,22 +147,22 @@ const localFsBridge = require("./bridges/localFsBridge.cjs");
|
||||
const transferBridge = require("./bridges/transferBridge.cjs");
|
||||
const portForwardingBridge = require("./bridges/portForwardingBridge.cjs");
|
||||
const terminalBridge = require("./bridges/terminalBridge.cjs");
|
||||
const oauthBridge = require("./bridges/oauthBridge.cjs");
|
||||
const githubAuthBridge = require("./bridges/githubAuthBridge.cjs");
|
||||
const googleAuthBridge = require("./bridges/googleAuthBridge.cjs");
|
||||
const onedriveAuthBridge = require("./bridges/onedriveAuthBridge.cjs");
|
||||
const cloudSyncBridge = require("./bridges/cloudSyncBridge.cjs");
|
||||
const fileWatcherBridge = require("./bridges/fileWatcherBridge.cjs");
|
||||
const tempDirBridge = require("./bridges/tempDirBridge.cjs");
|
||||
const sessionLogsBridge = require("./bridges/sessionLogsBridge.cjs");
|
||||
const sessionLogStreamManager = require("./bridges/sessionLogStreamManager.cjs");
|
||||
const compressUploadBridge = require("./bridges/compressUploadBridge.cjs");
|
||||
const globalShortcutBridge = require("./bridges/globalShortcutBridge.cjs");
|
||||
const credentialBridge = require("./bridges/credentialBridge.cjs");
|
||||
const autoUpdateBridge = require("./bridges/autoUpdateBridge.cjs");
|
||||
const aiBridge = require("./bridges/aiBridge.cjs");
|
||||
// crashLogBridge is required at the top of the file (before error handlers)
|
||||
const windowManager = require("./bridges/windowManager.cjs");
|
||||
const getOauthBridge = createLazyModule("./bridges/oauthBridge.cjs");
|
||||
const getGithubAuthBridge = createLazyModule("./bridges/githubAuthBridge.cjs");
|
||||
const getGoogleAuthBridge = createLazyModule("./bridges/googleAuthBridge.cjs");
|
||||
const getOnedriveAuthBridge = createLazyModule("./bridges/onedriveAuthBridge.cjs");
|
||||
const getCloudSyncBridge = createLazyModule("./bridges/cloudSyncBridge.cjs");
|
||||
const getFileWatcherBridge = createLazyModule("./bridges/fileWatcherBridge.cjs");
|
||||
const getTempDirBridge = createLazyModule("./bridges/tempDirBridge.cjs");
|
||||
const getSessionLogsBridge = createLazyModule("./bridges/sessionLogsBridge.cjs");
|
||||
const getCompressUploadBridge = createLazyModule("./bridges/compressUploadBridge.cjs");
|
||||
const getGlobalShortcutBridge = createLazyModule("./bridges/globalShortcutBridge.cjs");
|
||||
const getCredentialBridge = createLazyModule("./bridges/credentialBridge.cjs");
|
||||
const getAutoUpdateBridge = createLazyModule("./bridges/autoUpdateBridge.cjs");
|
||||
const getAiBridge = createLazyModule("./bridges/aiBridge.cjs");
|
||||
const getWindowManager = createLazyModule("./bridges/windowManager.cjs");
|
||||
|
||||
// GPU settings
|
||||
// NOTE: Do not disable Chromium sandbox by default.
|
||||
@@ -339,6 +394,19 @@ const registerBridges = (win) => {
|
||||
|
||||
const { ipcMain } = electronModule;
|
||||
const { safeStorage } = electronModule;
|
||||
const oauthBridge = getOauthBridge();
|
||||
const githubAuthBridge = getGithubAuthBridge();
|
||||
const googleAuthBridge = getGoogleAuthBridge();
|
||||
const onedriveAuthBridge = getOnedriveAuthBridge();
|
||||
const cloudSyncBridge = getCloudSyncBridge();
|
||||
const fileWatcherBridge = getFileWatcherBridge();
|
||||
const tempDirBridge = getTempDirBridge();
|
||||
const sessionLogsBridge = getSessionLogsBridge();
|
||||
const compressUploadBridge = getCompressUploadBridge();
|
||||
const globalShortcutBridge = getGlobalShortcutBridge();
|
||||
const credentialBridge = getCredentialBridge();
|
||||
const autoUpdateBridge = getAutoUpdateBridge();
|
||||
const aiBridge = getAiBridge();
|
||||
|
||||
const getCloudSyncPasswordPath = () => {
|
||||
try {
|
||||
@@ -440,7 +508,7 @@ const registerBridges = (win) => {
|
||||
// Settings window handler
|
||||
ipcMain.handle("netcatty:settings:open", async () => {
|
||||
try {
|
||||
await windowManager.openSettingsWindow(electronModule, {
|
||||
await getWindowManager().openSettingsWindow(electronModule, {
|
||||
preload,
|
||||
devServerUrl: effectiveDevServerUrl,
|
||||
isDev,
|
||||
@@ -630,6 +698,24 @@ const registerBridges = (win) => {
|
||||
return result.filePath;
|
||||
});
|
||||
|
||||
// Select a file and return the selected path
|
||||
ipcMain.handle("netcatty:selectFile", async (_event, { title, defaultPath, filters }) => {
|
||||
const { dialog } = electronModule;
|
||||
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: title || "Select File",
|
||||
defaultPath: defaultPath || os.homedir(),
|
||||
filters: filters || [{ name: "All Files", extensions: ["*"] }],
|
||||
properties: ["openFile", "showHiddenFiles"],
|
||||
});
|
||||
|
||||
if (result.canceled || !result.filePaths.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.filePaths[0];
|
||||
});
|
||||
|
||||
// Select a directory and return the selected path
|
||||
ipcMain.handle("netcatty:selectDirectory", async (_event, { title, defaultPath }) => {
|
||||
const { dialog } = electronModule;
|
||||
@@ -656,7 +742,7 @@ const registerBridges = (win) => {
|
||||
|
||||
const client = require("./bridges/sftpBridge.cjs");
|
||||
// Use tempDirBridge for dedicated Netcatty temp directory
|
||||
const localPath = await tempDirBridge.getTempFilePath(fileName);
|
||||
const localPath = await getTempDirBridge().getTempFilePath(fileName);
|
||||
|
||||
console.log(`[Main] Local temp path: ${localPath}`);
|
||||
|
||||
@@ -695,7 +781,7 @@ const registerBridges = (win) => {
|
||||
// only carries the resolved temp path. Cancellation is NOT an error here —
|
||||
// the UI already transitions the task to "cancelled" via the dedicated event.
|
||||
ipcMain.handle("netcatty:sftp:downloadToTempWithProgress", async (event, { sftpId, remotePath, fileName, encoding, transferId }) => {
|
||||
const localPath = await tempDirBridge.getTempFilePath(fileName);
|
||||
const localPath = await getTempDirBridge().getTempFilePath(fileName);
|
||||
const cleanupPartialDownload = async () => {
|
||||
try {
|
||||
await fs.promises.rm(localPath, { force: true });
|
||||
@@ -736,7 +822,7 @@ const registerBridges = (win) => {
|
||||
ipcMain.handle("netcatty:deleteTempFile", async (_event, { filePath }) => {
|
||||
try {
|
||||
// Only allow deleting files in Netcatty temp directory for security
|
||||
const netcattyTempDir = path.resolve(tempDirBridge.getTempDir());
|
||||
const netcattyTempDir = path.resolve(getTempDirBridge().getTempDir());
|
||||
const resolvedPath = path.resolve(String(filePath || ""));
|
||||
if (!isPathInside(netcattyTempDir, resolvedPath)) {
|
||||
console.warn(`[Main] Refused to delete file outside Netcatty temp dir: ${filePath}`);
|
||||
@@ -760,7 +846,7 @@ const registerBridges = (win) => {
|
||||
* Create the main application window
|
||||
*/
|
||||
async function createWindow() {
|
||||
const win = await windowManager.createWindow(electronModule, {
|
||||
const win = await getWindowManager().createWindow(electronModule, {
|
||||
preload,
|
||||
devServerUrl: effectiveDevServerUrl,
|
||||
isDev,
|
||||
@@ -819,11 +905,12 @@ if (!gotLock) {
|
||||
}
|
||||
|
||||
// Build and set application menu
|
||||
const menu = windowManager.buildAppMenu(Menu, app, isMac);
|
||||
const menu = getWindowManager().buildAppMenu(Menu, app, isMac);
|
||||
Menu.setApplicationMenu(menu);
|
||||
|
||||
app.on("browser-window-created", (_event, win) => {
|
||||
try {
|
||||
const windowManager = getWindowManager();
|
||||
const mainWin = windowManager.getMainWindow();
|
||||
const settingsWin = windowManager.getSettingsWindow();
|
||||
const isPrimary = win === mainWin || win === settingsWin;
|
||||
@@ -842,7 +929,20 @@ if (!gotLock) {
|
||||
void createWindow().then(() => {
|
||||
// Trigger auto-update check 5 s after window creation.
|
||||
// startAutoCheck() is a no-op on unsupported platforms (Linux deb/rpm/snap).
|
||||
autoUpdateBridge.startAutoCheck(5000);
|
||||
getAutoUpdateBridge().startAutoCheck(5000);
|
||||
|
||||
// Pre-warm the settings window in the background so it opens instantly.
|
||||
// Delay slightly to avoid competing with main window first-paint resources.
|
||||
setTimeout(() => {
|
||||
getWindowManager().prewarmSettingsWindow(electronModule, {
|
||||
preload,
|
||||
devServerUrl: effectiveDevServerUrl,
|
||||
isDev,
|
||||
appIcon,
|
||||
isMac,
|
||||
electronDir,
|
||||
});
|
||||
}, 3000);
|
||||
}).catch((err) => {
|
||||
console.error("[Main] Failed to create main window:", err);
|
||||
showStartupError(err);
|
||||
@@ -856,7 +956,7 @@ if (!gotLock) {
|
||||
// If the main window was hidden (e.g. "close to tray"), clicking the Dock icon
|
||||
// should bring it back. Fallback to creating a new window if none exists.
|
||||
try {
|
||||
const mainWin = windowManager.getMainWindow?.();
|
||||
const mainWin = getWindowManager().getMainWindow?.();
|
||||
if (mainWin && !mainWin.isDestroyed?.()) {
|
||||
if (mainWin.isMinimized?.()) mainWin.restore();
|
||||
mainWin.show?.();
|
||||
@@ -886,7 +986,7 @@ if (!gotLock) {
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
windowManager.setIsQuitting(true);
|
||||
getWindowManager().setIsQuitting(true);
|
||||
});
|
||||
|
||||
// Cleanup all PTY sessions and port forwarding tunnels before quitting
|
||||
@@ -907,12 +1007,12 @@ if (!gotLock) {
|
||||
console.warn("Error during port forwarding cleanup:", err);
|
||||
}
|
||||
try {
|
||||
globalShortcutBridge.cleanup();
|
||||
getGlobalShortcutBridge().cleanup();
|
||||
} catch (err) {
|
||||
console.warn("Error during global shortcut cleanup:", err);
|
||||
}
|
||||
try {
|
||||
aiBridge.cleanup();
|
||||
getAiBridge().cleanup();
|
||||
} catch (err) {
|
||||
console.warn("Error during AI bridge cleanup:", err);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const transferCompleteListeners = new Map();
|
||||
const transferErrorListeners = new Map();
|
||||
const transferCancelledListeners = new Map();
|
||||
const chainProgressListeners = new Map();
|
||||
const sftpConnectionProgressListeners = new Set();
|
||||
const authFailedListeners = new Map();
|
||||
const languageChangeListeners = new Set();
|
||||
const fullscreenChangeListeners = new Set();
|
||||
@@ -34,6 +35,7 @@ function cleanupTransferListeners(transferId) {
|
||||
// chunk, then filter complete lines that contain the marker.
|
||||
|
||||
const _mcpLineBufs = new Map(); // sessionId -> trailing fragment string
|
||||
const _mcpFlushTimers = new Map(); // sessionId -> delayed-flush timer
|
||||
|
||||
// Returns true if `s` ends with a non-empty prefix of "__NCMCP_"
|
||||
// (i.e. the next chunk might complete it into a marker-containing line).
|
||||
@@ -46,6 +48,13 @@ function _endsWithMarkerPrefix(s) {
|
||||
}
|
||||
|
||||
function filterMcpChunk(sessionId, chunk) {
|
||||
// Cancel any pending delayed flush — new data arrived
|
||||
const pendingTimer = _mcpFlushTimers.get(sessionId);
|
||||
if (pendingTimer) {
|
||||
clearTimeout(pendingTimer);
|
||||
_mcpFlushTimers.delete(sessionId);
|
||||
}
|
||||
|
||||
// Prepend any buffered fragment from the previous chunk
|
||||
const held = _mcpLineBufs.get(sessionId) || "";
|
||||
const data = held + chunk;
|
||||
@@ -58,14 +67,18 @@ function filterMcpChunk(sessionId, chunk) {
|
||||
|
||||
// Slow path: scan line by line
|
||||
let result = "";
|
||||
let droppedAny = false;
|
||||
let pos = 0;
|
||||
while (pos < data.length) {
|
||||
const nlIdx = data.indexOf("\n", pos);
|
||||
if (nlIdx === -1) {
|
||||
// Incomplete trailing line — no newline yet
|
||||
// Incomplete trailing line — no newline yet.
|
||||
// If we dropped any marker line in this chunk, or the tail itself
|
||||
// looks like it could contain a marker, buffer it. Long command
|
||||
// echoes can wrap across PTY lines; wrapped fragments that don't
|
||||
// contain __NCMCP_ would otherwise leak through as garbage.
|
||||
const tail = data.slice(pos);
|
||||
if (tail.includes("__NCMCP_") || _endsWithMarkerPrefix(tail)) {
|
||||
// Hold it; next chunk might complete or confirm the marker
|
||||
if (droppedAny || tail.includes("__NCMCP_") || _endsWithMarkerPrefix(tail)) {
|
||||
_mcpLineBufs.set(sessionId, tail);
|
||||
} else {
|
||||
result += tail; // safe to display immediately
|
||||
@@ -75,34 +88,52 @@ function filterMcpChunk(sessionId, chunk) {
|
||||
const line = data.slice(pos, nlIdx + 1); // includes the \n
|
||||
if (!line.includes("__NCMCP_")) {
|
||||
result += line;
|
||||
} else {
|
||||
droppedAny = true;
|
||||
}
|
||||
// else: drop it — it's a wrapper marker line (or echo of one)
|
||||
pos = nlIdx + 1;
|
||||
}
|
||||
|
||||
// Also strip Posix pager prefix and Fish env lines that have no __NCMCP_
|
||||
if (result) {
|
||||
result = result
|
||||
.replace(/PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= /g, "")
|
||||
.replace(/^set -gx (?:PAGER|SYSTEMD_PAGER|GIT_PAGER|LESS) [^\r\n]*[\r\n]*/gm, "")
|
||||
.replace(/^set "(?:PAGER|SYSTEMD_PAGER|GIT_PAGER|LESS)=[^"]*"[\r\n]*/gm, "");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver data to session listeners. Used both by the normal data path
|
||||
* and by the delayed-flush timer.
|
||||
*/
|
||||
function _deliverToListeners(sessionId, data) {
|
||||
const set = dataListeners.get(sessionId);
|
||||
if (!set || !data) return;
|
||||
set.forEach((cb) => {
|
||||
try { cb(data); } catch (err) { console.error("Data callback failed", err); }
|
||||
});
|
||||
}
|
||||
|
||||
ipcRenderer.on("netcatty:data", (_event, payload) => {
|
||||
const set = dataListeners.get(payload.sessionId);
|
||||
if (!set) return;
|
||||
const data = filterMcpChunk(payload.sessionId, payload.data);
|
||||
if (!data) return;
|
||||
set.forEach((cb) => {
|
||||
try {
|
||||
cb(data);
|
||||
} catch (err) {
|
||||
console.error("Data callback failed", err);
|
||||
}
|
||||
});
|
||||
if (data) {
|
||||
set.forEach((cb) => {
|
||||
try {
|
||||
cb(data);
|
||||
} catch (err) {
|
||||
console.error("Data callback failed", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
// If there is buffered content waiting for more data (e.g. a prompt
|
||||
// right after a dropped marker line), schedule a delayed flush so it
|
||||
// appears after a short pause instead of staying hidden forever.
|
||||
if (_mcpLineBufs.has(payload.sessionId)) {
|
||||
const sid = payload.sessionId;
|
||||
_mcpFlushTimers.set(sid, setTimeout(() => {
|
||||
const held = _mcpLineBufs.get(sid);
|
||||
_mcpLineBufs.delete(sid);
|
||||
_mcpFlushTimers.delete(sid);
|
||||
if (held) _deliverToListeners(sid, held);
|
||||
}, 80));
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:exit", (_event, payload) => {
|
||||
@@ -118,6 +149,11 @@ ipcRenderer.on("netcatty:exit", (_event, payload) => {
|
||||
}
|
||||
dataListeners.delete(payload.sessionId);
|
||||
exitListeners.delete(payload.sessionId);
|
||||
const pendingTimer = _mcpFlushTimers.get(payload.sessionId);
|
||||
if (pendingTimer) {
|
||||
clearTimeout(pendingTimer);
|
||||
_mcpFlushTimers.delete(payload.sessionId);
|
||||
}
|
||||
_mcpLineBufs.delete(payload.sessionId); // clean up any held fragment
|
||||
});
|
||||
|
||||
@@ -134,6 +170,17 @@ ipcRenderer.on("netcatty:chain:progress", (_event, payload) => {
|
||||
});
|
||||
});
|
||||
|
||||
// SFTP connection progress events (auth method logs)
|
||||
ipcRenderer.on("netcatty:sftp:connection-progress", (_event, payload) => {
|
||||
sftpConnectionProgressListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload.sessionId, payload.label, payload.status, payload.detail);
|
||||
} catch (err) {
|
||||
console.error("SFTP connection progress callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:languageChanged", (_event, language) => {
|
||||
languageChangeListeners.forEach((cb) => {
|
||||
try {
|
||||
@@ -808,6 +855,13 @@ const api = {
|
||||
chainProgressListeners.delete(id);
|
||||
};
|
||||
},
|
||||
// SFTP connection progress listener (auth method logs)
|
||||
onSftpConnectionProgress: (cb) => {
|
||||
sftpConnectionProgressListeners.add(cb);
|
||||
return () => {
|
||||
sftpConnectionProgressListeners.delete(cb);
|
||||
};
|
||||
},
|
||||
|
||||
// OAuth callback server
|
||||
startOAuthCallback: (expectedState) => ipcRenderer.invoke("oauth:startCallback", expectedState),
|
||||
@@ -878,6 +932,8 @@ const api = {
|
||||
ipcRenderer.invoke("netcatty:showSaveDialog", { defaultPath, filters }),
|
||||
selectDirectory: (title, defaultPath) =>
|
||||
ipcRenderer.invoke("netcatty:selectDirectory", { title, defaultPath }),
|
||||
selectFile: (title, defaultPath, filters) =>
|
||||
ipcRenderer.invoke("netcatty:selectFile", { title, defaultPath, filters }),
|
||||
|
||||
// File watcher for auto-sync feature
|
||||
startFileWatch: (localPath, remotePath, sftpId, encoding) =>
|
||||
@@ -1070,8 +1126,11 @@ const api = {
|
||||
aiAllowlistAddHost: async (baseURL) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:allowlist:add-host", { baseURL });
|
||||
},
|
||||
aiExec: async (sessionId, command) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:exec", { sessionId, command });
|
||||
aiExec: async (sessionId, command, chatSessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:exec", { sessionId, command, chatSessionId });
|
||||
},
|
||||
aiCattyCancelExec: async (chatSessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:catty:cancel", { chatSessionId });
|
||||
},
|
||||
aiTerminalWrite: async (sessionId, data) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:terminal:write", { sessionId, data });
|
||||
|
||||
19
global.d.ts
vendored
19
global.d.ts
vendored
@@ -38,6 +38,8 @@ declare global {
|
||||
keyId?: string;
|
||||
keySource?: 'generated' | 'imported';
|
||||
label?: string; // Display label for UI
|
||||
proxy?: NetcattyProxyConfig;
|
||||
identityFilePaths?: string[];
|
||||
}
|
||||
|
||||
// Host key information for verification
|
||||
@@ -84,6 +86,8 @@ declare global {
|
||||
sudo?: boolean;
|
||||
// Session log configuration for real-time streaming
|
||||
sessionLog?: { enabled: boolean; directory: string; format: string };
|
||||
// Local SSH key file paths (from SSH config IdentityFile)
|
||||
identityFilePaths?: string[];
|
||||
}
|
||||
|
||||
interface SftpStatResult {
|
||||
@@ -117,12 +121,18 @@ declare global {
|
||||
username: string;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
certificate?: string;
|
||||
keyId?: string;
|
||||
passphrase?: string;
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
identityFilePaths?: string[];
|
||||
}
|
||||
|
||||
interface PortForwardResult {
|
||||
tunnelId: string;
|
||||
success: boolean;
|
||||
cancelled?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -208,7 +218,7 @@ declare global {
|
||||
}): Promise<{ stdout: string; stderr: string; code: number | null }>;
|
||||
/** Get current working directory from an active SSH session */
|
||||
getSessionPwd?(sessionId: string): Promise<{ success: boolean; cwd?: string; error?: string }>;
|
||||
/** Get server stats (CPU, Memory, Disk, Network) from an active SSH session - Linux only */
|
||||
/** Get server stats (CPU, Memory, Disk, Network) from an active SSH session */
|
||||
getServerStats?(sessionId: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
@@ -465,6 +475,9 @@ declare global {
|
||||
// Callback receives: (sessionId: string, currentHop: number, totalHops: number, hostLabel: string, status: string, error?: string)
|
||||
onChainProgress?(cb: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void): () => void;
|
||||
|
||||
// SFTP connection progress listener (auth method logs)
|
||||
onSftpConnectionProgress?(cb: (sessionId: string, label: string, status: string, detail?: string) => void): () => void;
|
||||
|
||||
// OAuth callback server for cloud sync
|
||||
startOAuthCallback?(expectedState?: string): Promise<{ code: string; state?: string }>;
|
||||
cancelOAuthCallback?(): Promise<void>;
|
||||
@@ -579,6 +592,7 @@ declare global {
|
||||
// Save dialog for file downloads
|
||||
showSaveDialog?(defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>): Promise<string | null>;
|
||||
selectDirectory?(title?: string, defaultPath?: string): Promise<string | null>;
|
||||
selectFile?(title?: string, defaultPath?: string, filters?: Array<{ name: string; extensions: string[] }>): Promise<string | null>;
|
||||
|
||||
// File watcher for auto-sync feature
|
||||
startFileWatch?(localPath: string, remotePath: string, sftpId: string, encoding?: SftpFilenameEncoding): Promise<{ watchId: string }>;
|
||||
@@ -654,7 +668,8 @@ declare global {
|
||||
aiChatCancel?(requestId: string): Promise<boolean>;
|
||||
aiFetch?(url: string, method?: string, headers?: Record<string, string>, body?: string, providerId?: string): Promise<{ ok: boolean; status: number; data: string; error?: string }>;
|
||||
aiAllowlistAddHost?(baseURL: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiExec?(sessionId: string, command: string): Promise<{ ok: boolean; stdout?: string; stderr?: string; exitCode?: number | null; error?: string }>;
|
||||
aiExec?(sessionId: string, command: string, chatSessionId?: string): Promise<{ ok: boolean; stdout?: string; stderr?: string; exitCode?: number | null; error?: string }>;
|
||||
aiCattyCancelExec?(chatSessionId: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiTerminalWrite?(sessionId: string, data: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiDiscoverAgents?(): Promise<Array<{
|
||||
command: string;
|
||||
|
||||
19
index.css
19
index.css
@@ -113,6 +113,25 @@ body {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* Immersive mode: fade-out overlay for smooth theme restoration */
|
||||
.immersive-fade-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
transition: opacity 200ms ease-out;
|
||||
}
|
||||
.immersive-fade-overlay.fade-out {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Immersive mode: override agent icon badge colors to follow the theme */
|
||||
.immersive-transition [data-agent-badge] {
|
||||
border-color: hsl(var(--primary) / 0.2) !important;
|
||||
background-color: hsl(var(--primary) / 0.1) !important;
|
||||
}
|
||||
|
||||
.netcatty-shell {
|
||||
position: relative;
|
||||
background: linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--background)) 60%, hsl(var(--background) / 0.9) 100%);
|
||||
|
||||
68
index.tsx
68
index.tsx
@@ -13,6 +13,72 @@ import { ToastProvider } from './components/ui/toast';
|
||||
const LazySettingsPage = lazy(() => import('./components/SettingsPage'));
|
||||
const LazyTrayPanel = lazy(() => import('./components/TrayPanel'));
|
||||
|
||||
function SettingsWindowFallback() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'hsl(var(--background))',
|
||||
color: 'hsl(var(--foreground))',
|
||||
fontFamily: 'Space Grotesk, system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
borderBottom: '1px solid hsl(var(--border))',
|
||||
padding: '20px 16px 12px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 18, fontWeight: 600 }}>Settings</div>
|
||||
<div style={{ marginTop: 6, fontSize: 13, color: 'hsl(var(--muted-foreground))' }}>
|
||||
Loading preferences...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 224,
|
||||
flexShrink: 0,
|
||||
borderRight: '1px solid hsl(var(--border))',
|
||||
padding: 12,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 7 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
height: 36,
|
||||
borderRadius: 8,
|
||||
background: index === 0 ? 'hsl(var(--card))' : 'hsl(var(--muted) / 0.45)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, padding: 20, display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
height: index === 0 ? 54 : 76,
|
||||
borderRadius: 12,
|
||||
background: 'hsl(var(--muted) / 0.38)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
@@ -37,7 +103,7 @@ const renderApp = () => {
|
||||
if (route === 'settings') {
|
||||
root.render(
|
||||
<ToastProvider>
|
||||
<Suspense fallback={null}>
|
||||
<Suspense fallback={<SettingsWindowFallback />}>
|
||||
<LazySettingsPage />
|
||||
</Suspense>
|
||||
</ToastProvider>
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface NetcattyBridge {
|
||||
aiExec(
|
||||
sessionId: string,
|
||||
command: string,
|
||||
chatSessionId?: string,
|
||||
): Promise<{
|
||||
ok: boolean;
|
||||
stdout?: string;
|
||||
@@ -82,6 +83,7 @@ export function createToolExecutor(
|
||||
commandBlocklist?: string[],
|
||||
permissionMode: AIPermissionMode = 'confirm',
|
||||
webSearchConfig?: WebSearchConfig,
|
||||
chatSessionId?: string,
|
||||
): (toolCall: ToolCall) => Promise<ToolResult> {
|
||||
return async (toolCall: ToolCall): Promise<ToolResult> => {
|
||||
if (!bridge) {
|
||||
@@ -92,7 +94,7 @@ export function createToolExecutor(
|
||||
};
|
||||
}
|
||||
|
||||
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode, webSearchConfig };
|
||||
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode, webSearchConfig, chatSessionId };
|
||||
const args = toolCall.arguments;
|
||||
|
||||
try {
|
||||
|
||||
@@ -37,7 +37,7 @@ export function createCattyTools(
|
||||
webSearchConfig?: WebSearchConfig,
|
||||
chatSessionId?: string,
|
||||
) {
|
||||
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode, webSearchConfig };
|
||||
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode, webSearchConfig, chatSessionId };
|
||||
|
||||
return {
|
||||
terminal_execute: tool({
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface ToolDeps {
|
||||
commandBlocklist?: string[];
|
||||
permissionMode: AIPermissionMode;
|
||||
webSearchConfig?: WebSearchConfig;
|
||||
chatSessionId?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -82,7 +83,7 @@ export async function executeTerminalExecute(
|
||||
return { ok: false, error: `Command blocked by safety policy. Matched pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
|
||||
const result = await bridge.aiExec(sessionId, command);
|
||||
const result = await bridge.aiExec(sessionId, command, deps.chatSessionId);
|
||||
// Real execution failures (timeout, disconnect, no stream) have an `error` field
|
||||
if (!result.ok && result.error) {
|
||||
const parts = [result.error];
|
||||
|
||||
@@ -87,5 +87,9 @@ export const STORAGE_KEY_AI_COMMAND_BLOCKLIST = 'netcatty_ai_command_blocklist_v
|
||||
export const STORAGE_KEY_AI_COMMAND_TIMEOUT = 'netcatty_ai_command_timeout_v1';
|
||||
export const STORAGE_KEY_AI_MAX_ITERATIONS = 'netcatty_ai_max_iterations_v1';
|
||||
export const STORAGE_KEY_AI_SESSIONS = 'netcatty_ai_sessions_v1';
|
||||
export const STORAGE_KEY_AI_ACTIVE_SESSION_MAP = 'netcatty_ai_active_session_map_v1';
|
||||
export const STORAGE_KEY_AI_AGENT_MODEL_MAP = 'netcatty_ai_agent_model_map_v1';
|
||||
export const STORAGE_KEY_AI_WEB_SEARCH = 'netcatty_ai_web_search_v1';
|
||||
|
||||
// Immersive Mode
|
||||
export const STORAGE_KEY_IMMERSIVE_MODE = 'netcatty_immersive_mode_v1';
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* for establishing and managing SSH port forwarding tunnels.
|
||||
*/
|
||||
|
||||
import { Host,PortForwardingRule } from '../../domain/models';
|
||||
import { Host, Identity, PortForwardingRule, SSHKey } from '../../domain/models';
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from '../../domain/credentials';
|
||||
import { resolveHostAuth } from '../../domain/sshAuth';
|
||||
import { logger } from '../../lib/logger';
|
||||
import { netcattyBridge } from './netcattyBridge';
|
||||
|
||||
@@ -357,7 +359,9 @@ export const reconcileWithBackend = async (): Promise<{
|
||||
export const startPortForward = async (
|
||||
rule: PortForwardingRule,
|
||||
host: Host,
|
||||
keys: { id: string; privateKey: string; passphrase: string }[],
|
||||
hosts: Host[],
|
||||
keys: SSHKey[],
|
||||
identities: Identity[],
|
||||
onStatusChange: (status: PortForwardingRule['status'], error?: string) => void,
|
||||
enableReconnect = false
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
@@ -375,16 +379,71 @@ export const startPortForward = async (
|
||||
try {
|
||||
// Generate a unique tunnel ID
|
||||
const tunnelId = `pf-${rule.id}-${Date.now()}`;
|
||||
|
||||
// Get the private key and passphrase if using key auth
|
||||
let privateKey: string | undefined;
|
||||
let passphrase: string | undefined;
|
||||
if (host.identityFileId) {
|
||||
const key = keys.find(k => k.id === host.identityFileId);
|
||||
if (key) {
|
||||
privateKey = key.privateKey;
|
||||
passphrase = key.passphrase;
|
||||
|
||||
const resolved = resolveHostAuth({ host, keys, identities });
|
||||
const key = resolved.key;
|
||||
const proxy = host.proxyConfig
|
||||
? {
|
||||
type: host.proxyConfig.type,
|
||||
host: host.proxyConfig.host,
|
||||
port: host.proxyConfig.port,
|
||||
username: host.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(host.proxyConfig.password),
|
||||
}
|
||||
: undefined;
|
||||
let jumpHosts: NetcattyJumpHost[] | undefined;
|
||||
if (host.hostChain?.hostIds?.length) {
|
||||
const resolvedJumpHosts = host.hostChain.hostIds.map((hostId) =>
|
||||
hosts.find((candidate) => candidate.id === hostId),
|
||||
);
|
||||
const missingJumpHostIds = host.hostChain.hostIds.filter((_, index) => !resolvedJumpHosts[index]);
|
||||
if (missingJumpHostIds.length > 0) {
|
||||
throw new Error(`Missing jump host configuration for host chain: ${missingJumpHostIds.join(", ")}`);
|
||||
}
|
||||
jumpHosts = resolvedJumpHosts
|
||||
.filter((jumpHost): jumpHost is Host => Boolean(jumpHost))
|
||||
.map((jumpHost, index) => {
|
||||
const hasConfiguredJumpProxyEndpoint =
|
||||
index === 0 &&
|
||||
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
|
||||
if (
|
||||
hasConfiguredJumpProxyEndpoint &&
|
||||
jumpHost.proxyConfig?.username &&
|
||||
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig.password) &&
|
||||
!sanitizeCredentialValue(jumpHost.proxyConfig.password)
|
||||
) {
|
||||
throw new Error(`Proxy credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`);
|
||||
}
|
||||
const jumpResolved = resolveHostAuth({ host: jumpHost, keys, identities });
|
||||
const jumpKey = jumpResolved.key;
|
||||
return {
|
||||
hostname: jumpHost.hostname,
|
||||
port: jumpHost.port || 22,
|
||||
username: jumpResolved.username || 'root',
|
||||
password: jumpResolved.password,
|
||||
privateKey: jumpKey?.privateKey,
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: jumpResolved.passphrase || jumpKey?.passphrase,
|
||||
publicKey: jumpKey?.publicKey,
|
||||
keyId: jumpResolved.keyId,
|
||||
keySource: jumpKey?.source,
|
||||
label: jumpHost.label,
|
||||
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
|
||||
? {
|
||||
type: jumpHost.proxyConfig.type,
|
||||
host: jumpHost.proxyConfig.host,
|
||||
port: jumpHost.proxyConfig.port,
|
||||
username: jumpHost.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
|
||||
}
|
||||
: undefined,
|
||||
identityFilePaths: jumpHost.identityFilePaths,
|
||||
};
|
||||
});
|
||||
}
|
||||
const usesTargetProxyForFirstHop = !!proxy && !jumpHosts?.[0]?.proxy;
|
||||
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxy?.password) {
|
||||
throw new Error('Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.');
|
||||
}
|
||||
|
||||
// Subscribe to status updates first
|
||||
@@ -428,10 +487,15 @@ export const startPortForward = async (
|
||||
remotePort: rule.remotePort,
|
||||
hostname: host.hostname,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
privateKey,
|
||||
passphrase,
|
||||
username: resolved.username,
|
||||
password: resolved.password,
|
||||
privateKey: key?.privateKey,
|
||||
certificate: key?.certificate,
|
||||
keyId: resolved.keyId,
|
||||
passphrase: resolved.passphrase || key?.passphrase,
|
||||
proxy,
|
||||
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
identityFilePaths: host.identityFilePaths,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
Reference in New Issue
Block a user